Skip to main content

bamboo_engine/external_agents/
actor_adapter.rs

1//! Actor external child runner.
2//!
3//! Runs a child session as an independent **actor**: a separate OS process with its own
4//! isolated context, speaking the `bamboo-subagent` WebSocket protocol. This is the
5//! engine-side adapter on the `wants_external` seam: it spawns the worker binary, waits for
6//! it to self-register into the Tier-1 file fabric, connects, sends the assignment, and
7//! forwards the child's `AgentEvent`s back onto the parent's `event_tx`.
8//!
9//! The built-in **local actor** instance of this runner is the default runtime for
10//! every sub-agent (the in-process runtime was removed). The expert `externalAgents`
11//! tables can additionally route specific roles to other actor/a2a agents.
12
13use 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
52/// Default cap on simultaneously running actor activations. The event and
53/// broker transports are bounded independently, so 200 active sub-agents are a
54/// supported operating point rather than an opt-in escape hatch.
55pub const DEFAULT_MAX_CONCURRENT_ACTORS: usize = 200;
56
57/// Compatibility name for the default. Runtime creation uses Host config.
58pub use bamboo_config::DEFAULT_MAX_SPAWN_DEPTH as MAX_SPAWN_DEPTH;
59
60/// Default cap on idle pooled (warm, reusable) workers kept per fingerprint.
61const DEFAULT_MAX_IDLE_PER_KEY: usize = 4;
62
63/// Process-wide-per-runner cap across every reuse fingerprint. Without this,
64/// workloads that continually change role/model/workspace can leave one parked
65/// worker in an unbounded number of otherwise-small buckets.
66const DEFAULT_MAX_IDLE_TOTAL: usize = 16;
67
68/// How long a pooled worker waits for its next assignment before reclaiming
69/// itself (must comfortably exceed the gap between sibling spawns).
70const POOLED_IDLE_TIMEOUT_SECS: u64 = 300;
71
72/// Sweep often enough to retire an expired/dead pool entry even when no later
73/// child checks out the same fingerprint. The worker's own idle deadline is the
74/// primary process-side guard; this task closes the parent handle and pool entry.
75const POOLED_REAPER_INTERVAL: Duration = Duration::from_secs(30);
76
77/// Deadline for a local worker's FIRST frame after a Run is dispatched. A warm
78/// worker answers in seconds; a cold spawn within tens. Total silence past this
79/// means the worker is dead (e.g. a pooled worker that exited right after its
80/// liveness check) and its Run is queued with nobody to serve it — trip it so the
81/// runner respawns once instead of hanging forever. Generous, to never false-trip
82/// a slow-but-healthy cold start.
83const 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
114/// Plaintext token returned once by the server authority for one Codex run.
115/// `token_id` is non-secret and is the handle used for guaranteed revocation.
116pub struct IssuedCodexRunToken {
117    pub token_id: String,
118    pub token: String,
119}
120
121/// Server-owned authority for Bamboo-as-provider Codex credentials. The engine
122/// only needs mint/revoke; verification remains inside the HTTP server.
123pub 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
179/// Exact non-secret executor posture the host expects for one typed activation.
180///
181/// Echo is intentionally a transport-only smoke executor and CliAdapter is not
182/// implemented by the production worker. Neither claims the typed permission
183/// contract, so legacy/custom frame handling remains available only for those
184/// two variants. Every executable permission-aware variant must prove the
185/// mapping derived from its provisioned spec before any execution event.
186fn 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    // Project worktrees created by Bamboo live under
304    // `<project>/.bamboo/worktree/<name>` and carry the ownership marker used
305    // by the project-worktree lifecycle. A path that merely imitates the
306    // directory shape is not sufficient to bypass Codex's git guard.
307    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
360/// A warm worker on the mailbox bus, parked for reuse between runs. It stays
361/// dialed-in + subscribed to `mailbox_id`; the next interchangeable child
362/// delivers its `Run` there instead of spawning a fresh process. Dropping it
363/// kills a local kill-on-drop subprocess; a remote / schedulable handle is
364/// process-less (`kill()` is a no-op — it self-manages via its idle timeout).
365struct PooledWorker {
366    worker: SpawnedChild,
367    /// The bus mailbox this worker subscribes to (where its `Run`s are delivered).
368    mailbox_id: String,
369    /// Set only while the worker is parked. Checked both at checkout and by the
370    /// background pool sweep; cleared before the worker is handed to a run.
371    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/// A role pinned to a remote resident worker (remote-actor-plan §3.4 / P1.5,
464/// #193), resolved at runner-build time from `SubagentsConfig.remote_placements`:
465/// the env-named broker bearer is already READ into `token` here (the raw token
466/// never rides the config), and `ca_cert_file` pins the broker's TLS cert.
467#[derive(Clone, Default)]
468pub struct ResolvedRemotePlacement {
469    pub endpoint: String,
470    pub token: Option<String>,
471    pub ca_cert_file: Option<PathBuf>,
472    /// Display name for the machine this role runs on — the matching cluster
473    /// node's `label`/host, surfaced on the UI placement badge. `None` ⇒ derive
474    /// from the endpoint host.
475    pub host_label: Option<String>,
476    /// Some(Err) preserves explicit unavailable selection; never falls back.
477    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/// A role routed to a SCHEDULED worker (remote-actor-plan §3.4 / P2b, #181),
489/// resolved at runner-build time from `SubagentsConfig.schedulable_placements`.
490/// Names the logical `pool` (= the bus role) whose LIVE connected workers are the
491/// scheduling candidates — the runner picks one via the bus presence query
492/// (`BrokerClient::list_connected`). Phase 3 retired the old HTTP registry, so a
493/// pool is now just a role on the bus.
494#[derive(Debug, Clone, Default)]
495pub struct ResolvedSchedulablePlacement {
496    pub pool: String,
497    /// Display name for the machine this pool's workers run on — the matching
498    /// cluster node's `label`/host, surfaced on the UI placement badge. `None` ⇒
499    /// fall back to the pool name.
500    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
534/// Holds one atomic HostRegistry slot for the entire remote Run. A fresh
535/// authenticated broker observation extends the Host lease before the slot is
536/// renewed. Losing either identity cancels the Run; Drop releases the exact
537/// generation/epoch and cannot free a replacement worker's slot.
538struct 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
553/// No Run has reached a worker yet, so a failed placement preflight may end
554/// only the activation it claimed. A replacement activation has a different
555/// fence and must remain untouched.
556async fn fail_pre_dispatch_activation(
557    actor_store: &bamboo_storage::SessionStoreV2,
558    fence: &ActorActivationFence,
559) -> Result<(), bamboo_domain::ActorDirectoryError> {
560    actor_store
561        .finish_activation_observed(fence, chrono::Utc::now(), ActorActivationFinish::Failed)
562        .await
563        .map(|_| ())
564}
565
566/// Once the Run send is invoked, an error may mean that the broker persisted
567/// the FencedRun but its delivery receipt was lost. Only errors before this
568/// call can safely roll back the Actor activation and its reserved slot.
569async 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
596/// Commit a remote reply through the current Actor fence before marking the
597/// activation terminal. The SDK's later status save may then carry exactly
598/// the canonical main transcript instead of trying an unfenced append.
599async 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(&current.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(&current, 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(&current).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            // Keep the activation live until its lease expires. A silent Worker
922            // may still be executing; this blocks an immediate ActorId retry.
923            return if outcome == ActorActivationFinish::Succeeded {
924                Err(remote_broker_unavailable())
925            } else {
926                Ok(())
927            };
928        }
929        // Wait for the heartbeat to stop before reading the exact lease. It
930        // must not renew the slot after terminal verification or release.
931        self.heartbeat.abort();
932        let _ = (&mut self.heartbeat).await;
933        let current = self.lease.lock().await.clone();
934        // A correlated Outcome from the selected mailbox alone cannot release
935        // a new generation's slot. Reobserve the scoped broker identity at
936        // terminal time, including fast reconnects between heartbeat ticks.
937        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(&current.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(&current, chrono::Utc::now())
963            .await
964            .map_err(|_| remote_broker_unavailable())?;
965        self.actor_store
966            .finish_activation_observed(&self.fence, chrono::Utc::now(), outcome)
967            .await
968            .map_err(|_| remote_broker_unavailable())?;
969        // A terminal ActorDirectory record permits an immediate retry. Free
970        // its exact slot before returning rather than racing a Drop task.
971        self.release_on_drop.store(true, Ordering::SeqCst);
972        match self.registry.release_slot(&current).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(&current.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(&current).await;
1011                }
1012            });
1013        }
1014    }
1015}
1016
1017/// Host-only receipt repair uses the same immutable boot configuration as the
1018/// actor runner. It never infers a broker route from Child display metadata or
1019/// a Worker claim. A role whose fixed remote route is ambiguous or incomplete
1020/// stays blocked until an operator restores a provable route.
1021pub 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    /// One physical-tree scan. Every Child is independent; a failed route,
1080    /// broker, or ACK retains its Host receipt and never blocks another Child.
1081    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(&current).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        // Validate the Host checkpoint before opening a recovery connection.
1189        // This rejects an uncommitted terminal, stale transcript, or changed
1190        // Child identity before touching the broker.
1191        let receipts = self
1192            .store
1193            .recover_broker_terminal_receipts(&current)
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                    &current,
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            // The committed receipt records the route used by this run. A
1227            // later role configuration change must not redirect its ACK.
1228            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
1287/// Finish only the interrupted publication of an already committed Host
1288/// receipt. The pending source stays in the existing protected source record;
1289/// no provider, event admission or transcript reconstruction is involved.
1290async 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        // CAS reloads under the physical Session lock and mutates only the
1327        // source/status fields. Another reconciler or successor cannot be
1328        // overwritten by this earlier snapshot.
1329        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
1378/// How `execute_external_child` should obtain its worker connection, decided
1379/// once from `spec.placement`. Splits the divergent acquire/connect + retire
1380/// logic three ways while the shared middle (Run dispatch, live registration,
1381/// drive, close) stays identical. Remote uses a scoped broker peer route.
1382enum PlacementKind {
1383    Local,
1384    Remote,
1385    Schedulable,
1386}
1387
1388/// Ticket execution needs the local process-owned stop checkpoint before ACK.
1389fn 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
1407/// A read-only activation needs an executor and worker whose tool surface and
1408/// no-shell checker enforce the typed capability. Codex's read-only sandbox
1409/// prevents writes but still permits command execution, so neither Codex mode
1410/// can currently satisfy the no-shell contract. Local Bamboo/Claude workers are
1411/// fingerprinted by their baked fields, while remote/schedulable placements
1412/// connect to an already-provisioned resident and [`RunSpec`] currently carries
1413/// no equivalent per-activation authority. Fail closed before provisioning or
1414/// dispatch instead of treating either posture as read-only enforcement.
1415fn 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
1437/// Spawns and drives a child session as an independent actor: a `bamboo-subagent` worker process.
1438pub 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    /// Per-provider credentials snapshotted from the parent config at build
1449    /// time; the spec carries only the ONE the child's provider needs. Server
1450    /// runtimes install `live_provider_config` below so a provider hot reload
1451    /// cannot strand this fallback snapshot for the rest of the process.
1452    credentials: Vec<ScopedCredential>,
1453    /// Parent's default provider (used when the child has no explicit one).
1454    default_provider: String,
1455    /// Startup fallback for embeddings without a live Host configuration.
1456    max_spawn_depth: u32,
1457    /// Live server configuration used to resolve provider credentials at child
1458    /// activation time, including the depth cap for new worker provisioning.
1459    /// Executor/placement policy remains the immutable runner configuration.
1460    live_provider_config: Option<Arc<tokio::sync::RwLock<bamboo_llm::Config>>>,
1461    /// The mailbox bus to run local children over (the unified transport). Local
1462    /// sub-agents require it; `None` only when no broker could be embedded.
1463    bus: Option<bamboo_subagent::BusEndpoint>,
1464    /// Backpressure: bounds the number of concurrently *running* actors; further
1465    /// runs wait for a slot instead of exploding the process table. (Idle pooled
1466    /// workers do not hold a slot.)
1467    concurrency: std::sync::Arc<tokio::sync::Semaphore>,
1468    /// Warm-worker pool keyed by a reuse fingerprint
1469    /// (role/provider/model/workspace/disabled-tools/baked-caps). A finished run
1470    /// parks its bus worker here so the next interchangeable child reuses it
1471    /// (delivers its `Run` to the same mailbox) instead of spawning a fresh
1472    /// process — collapsing N sibling sub-agents onto a few warm workers.
1473    pool: Arc<tokio::sync::Mutex<WorkerPool>>,
1474    max_idle_per_key: usize,
1475    max_idle_total: usize,
1476    pool_reaper_started: AtomicBool,
1477    /// Monotonic fencing token for concrete worker attempts in this runtime.
1478    /// `activation_run_id` fences across runtime restarts; this epoch also
1479    /// fences a late first worker when an activation is retried in-place.
1480    next_execution_epoch: AtomicU64,
1481    /// Host-side decision for a child's gated-tool approval request (Phase 2).
1482    /// `None` ⇒ fail-closed DENY (the safe default). A wired decider (policy or
1483    /// human-routing bridge) returns approve/deny over the actor WS.
1484    approval_decider: Option<Arc<dyn ChildApprovalDecider>>,
1485    /// Off-loop parent-agent reviewer for forced-ask requests. The root server
1486    /// wires a session-aware reviewer; nested workers wire their owning model
1487    /// reviewer directly into the per-run runner.
1488    approval_reviewer: Option<Arc<dyn ChildApprovalReviewer>>,
1489    /// Per-run escalation host bridge for non-bypass child-approval routing (#68;
1490    /// Phase 6, Part B). The owning worker's `run()` installs its OWN host bridge
1491    /// here via `set_escalation_bridge`; `execute_external_child` CAPTURES it at
1492    /// grandchild-spawn time and hands the owned value to `drive()`, which uses it
1493    /// to RE-PROXY a child's approval request UP to the parent run — chaining up
1494    /// every level until a bypass level (model-review) or the top orchestrator
1495    /// (human) decides, then relaying the reply back down. Was a process-global
1496    /// slot; now per-runner so a fire-and-forget grandchild that OUTLIVES the run
1497    /// that spawned it keeps that run's bridge for its whole lifetime instead of
1498    /// reading a stale/overwritten global at approval time (→ fail-closed deny).
1499    escalation_bridge: Arc<std::sync::Mutex<Option<bamboo_subagent::executor::HostBridge>>>,
1500    /// Roles pinned to a REMOTE resident worker (#193), keyed by sub-agent role
1501    /// (the child's `subagent_type`). A role present here routes through the
1502    /// dedicated remote branch in `execute_external_child` (Bearer-authenticated
1503    /// `wss://` connect, no spawn, no pool, no kill) instead of the local
1504    /// subprocess + warm-pool path. Empty (the default) = all-local behavior.
1505    remote_placements: HashMap<String, ResolvedRemotePlacement>,
1506    /// Fixed parent mailbox subscription has exactly one in-process owner.
1507    strict_remote_subscription: tokio::sync::Mutex<()>,
1508    /// Roles routed to a REGISTRY-SCHEDULED worker (#181, P2b), keyed by sub-agent
1509    /// role. A role present here (AND not already in `remote_placements`, which
1510    /// wins) routes through the dedicated SCHEDULABLE branch in
1511    /// `execute_external_child`: query the registry for live workers in the pool,
1512    /// pick one (round-robin), connect over `wss://` — no spawn, no pool, no kill,
1513    /// and NO local-subprocess fallback (no live worker ⇒ a clear error). Empty
1514    /// (the default) = all-local behavior.
1515    schedulable_placements: HashMap<String, ResolvedSchedulablePlacement>,
1516    /// Per-pool round-robin cursor for schedulable scheduling (#181, P2b). Bumped
1517    /// once per pick so successive sibling spawns SPREAD across a pool's live
1518    /// workers instead of all landing on the first candidate. Best-effort spread,
1519    /// not a load balancer — the registry's live set can change between picks.
1520    /// Optional server authority used only by `Codex` in `bamboo` auth mode.
1521    codex_run_tokens: Option<Arc<dyn CodexRunTokenAuthority>>,
1522    /// Canonical logical-session inbox resources, late-bound by each owning
1523    /// runtime. Kept per runner/runtime; never process-global.
1524    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    /// A processed broker terminal still needs the Host's final Child Session
1530    /// save before its exact Event/Outcome mailbox receipts can be ACKed.
1531    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    /// Captured from this Host frame pump after canonical nested-wait checks.
1539    /// The map key binds it to the exact Child birth and activation Run.
1540    host_nested_wait_handoff: bool,
1541    // Never reconstructed from Worker JSON or ordinary Session metadata.
1542    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/// Decides how the host answers a child worker's gated-tool approval request
1577/// (Phase 2: child → parent approval delegation). Async so an implementation
1578/// can consult a policy. With no decider wired the host replies with a
1579/// fail-closed DENY.
1580///
1581/// NOTE: `decide` is awaited inside the per-child frame pump, so an
1582/// implementation must resolve promptly (e.g. a policy lookup). Model-based
1583/// review belongs in [`ChildApprovalReviewer`], which runs off-loop and returns
1584/// through the live steering channel without stalling the frame pump.
1585#[async_trait]
1586pub trait ChildApprovalDecider: Send + Sync {
1587    /// Decide whether `child_session_id` may perform the gated action described
1588    /// by `request` (`{tool_name, permission, resource}`).
1589    async fn decide(&self, child_session_id: &str, request: &serde_json::Value) -> bool;
1590}
1591
1592/// Resolve a child approval request to approve/deny. Fail-closed (DENY) when no
1593/// decider is wired — the single, testable seam for the host-side decision.
1594async 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
1605/// How long a chained parent-agent review may take before the child's gated
1606/// tool fails closed (DENY). Bounds an unanswered request so it cannot hang the
1607/// worker indefinitely.
1608const CHILD_APPROVAL_TIMEOUT: Duration = Duration::from_secs(300);
1609
1610/// Off-loop reviewer for a child's gated-tool approval request (Phase 6, Part B).
1611///
1612/// Installed (process-global) by a BYPASSED self-orchestrating worker so its
1613/// children's forced-ask (dangerous) gated actions — which still raise
1614/// `ConfirmationRequired` even under bypass — get an LLM reasonableness check
1615/// rather than a blind pass. `review` is an LLM call: `drive()` invokes it in a
1616/// SPAWNED task (NEVER in the frame pump) and delivers the verdict async via the
1617/// live channel, so the agent loop is never blocked.
1618/// Process-local Host scope. Durable data can compare its stamp but cannot
1619/// deserialize a new live authority from it.
1620#[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    /// Called by the actual Host frame pump, never from a wire payload.
1643    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    /// Judge whether the gated action `request` (`{tool_name, permission,
1701    /// resource}`) is reasonable for `child_session_id`'s task. `true` = approve.
1702    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
1725/// Install the process-global child-approval reviewer (idempotent; first wins).
1726pub fn set_child_approval_reviewer(reviewer: Arc<dyn ChildApprovalReviewer>) {
1727    let _ = child_approval_reviewer_slot().set(reviewer);
1728}
1729
1730/// The process-global child-approval reviewer, if installed.
1731pub 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    /// Bind the AppState-owned live configuration. Child activations read this
1785    /// after a successful provider reload, so newly added/rotated provider
1786    /// credentials take effect without restarting Bamboo.
1787    /// Only the built-in factory sets this from its immutable launch config.
1788    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    /// Run children over the mailbox bus (the unified actor+mailbox transport).
1841    /// When set, local children dial this bus and are driven by mailbox id; when
1842    /// unset they use the legacy direct-WS path. The server passes its in-process
1843    /// broker here (`subagents.broker`); tests without a broker leave it unset.
1844    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    /// Wire the host-side decider for child gated-tool approval requests
1850    /// (Phase 2). Without this the host fail-closed DENYs every request.
1851    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    /// Pin specific sub-agent roles to remote resident workers (#193). The map
1870    /// is keyed by role (`subagent_type`); a child whose role is present connects
1871    /// over `wss://` to the resolved endpoint instead of spawning a local
1872    /// subprocess. Default (empty) keeps every role on the local path — exactly
1873    /// today's behavior.
1874    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    /// Route specific sub-agent roles to a registry-SCHEDULED worker (#181, P2b).
1883    /// The map is keyed by role (`subagent_type`); a child whose role is present
1884    /// (and NOT already pinned by `remote_placements`, which takes precedence) is
1885    /// run on a live worker discovered from the registry instead of a local
1886    /// subprocess. Default (empty) keeps every role on the local path.
1887    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    /// Reuse fingerprint: two children are interchangeable on one warm worker iff
1896    /// they share role, provider, model, provider-credential revision,
1897    /// workspace, disabled-tool set, AND every
1898    /// capability the worker BAKES at provision time (`BambooRuntimeExecutor`
1899    /// stamps these once and reuses them across runs): nesting depth, nested-spawn
1900    /// stack, requested/effective permission modes, legacy bypass/auto flags,
1901    /// permission enforcement, and the depth cap. Omitting any
1902    /// of these lets the pool hand a run a worker baked for a DIFFERENT posture —
1903    /// e.g. a depth-1 worker (with its own spawn stack) reused for a depth-4
1904    /// child would re-stamp `spawn_depth=1` and pass the depth-cap check, breaking
1905    /// the recursion bound; or a bypass worker reused for a non-bypass child. So
1906    /// these MUST split the pool bucket. Everything else (assignment, history) is
1907    /// shipped per-run in the `RunSpec` and does not affect the fingerprint.
1908    /// Reuse fingerprint (role/provider/model/workspace/disabled-tools/baked
1909    /// caps): two children with the same fingerprint are interchangeable on one
1910    /// warm worker, so they share a pool bucket. Any axis the worker bakes ONCE
1911    /// at provision time MUST be in here, else a worker baked for one posture
1912    /// gets reused for another (see the `fingerprint_*` tests).
1913    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        // Provider credentials are baked into a worker at provision time. Hash
1925        // the scoped envelope so a hot-reloaded key/base URL cannot reuse a
1926        // worker provisioned with stale authority. The key itself never enters
1927        // the pool key or logs.
1928        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        // The worker constructs its executor exactly once. In particular,
1936        // Codex exec and app-server workers are not interchangeable.
1937        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            // #73 review (P1): a worker bakes `no_human_review` ONCE from this flag
1950            // at build() and never re-reads it per run, so the pool MUST NOT hand a
1951            // worker baked for one approval posture to a run of the opposite one —
1952            // else a scheduled-root worker reused for an interactive child would
1953            // silently model-review instead of asking the human (and vice-versa,
1954            // reintroducing the 300s-deny). Split the bucket on it.
1955            caps.no_human_approver,
1956            // The read-only no-shell checker is baked once at build(), so a planner
1957            // or Guardian worker must NOT be reused for an ordinary child.
1958            caps.read_only,
1959            // Preserve the legacy Guardian bit as a distinct fingerprint axis
1960            // during rolling upgrades even though both bits enforce read-only.
1961            caps.guardian_read_only,
1962            caps.child_creation_identity,
1963        )
1964    }
1965
1966    /// Start one weakly-owned sweep task for this runner. It does not keep the
1967    /// runner/pool alive, and it never holds the pool lock while killing a child.
1968    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            // Tokio intervals tick immediately once; consume that tick so a new
1981            // runner does not scan an empty pool before its first worker parks.
1982            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        // A killed one-shot worker may leave an unACKed Run in its mailbox.
1996        // A fresh subscriber must not replay that Run. Logical birth, storage
1997        // namespace, typed input targets and authority remain on the RunSpec.
1998        physical.identity.child_id = format!("required-worker-{}", uuid::Uuid::new_v4());
1999        physical
2000    }
2001
2002    /// Check out a warm bus worker for `key`, reusing a live parked one if any,
2003    /// else spawning a fresh one that dials the bus. The returned worker is OWNED
2004    /// by the caller for the run's duration (checkout removes it from the pool, so
2005    /// a concurrent sibling gets a different worker or spawns its own — one run per
2006    /// worker at a time, matching the pre-bus pool semantics).
2007    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        // Drain the bucket, skipping (and reaping) any worker whose process exited
2028        // or crossed its idle deadline while parked. A live, fresh one is handed
2029        // straight out for reuse.
2030        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    /// Park a warm bus worker for reuse after a clean run; if its bucket is full
2078    /// (or it died), kill it instead. The worker stays dialed-in + subscribed
2079    /// while parked, so a reusing child just delivers a new `Run` to its mailbox.
2080    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    /// Assemble the parent-resolved provisioning document for this child using
2119    /// one coherent provider snapshot.
2120    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                // The child session already carries the correct depth
2139                // (create_child_action's new_child_of did parent.spawn_depth+1);
2140                // stamp it so the worker can re-establish it on its run session
2141                // and enforce the max-depth cap across the actor boundary.
2142                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        // Unified transport: when a bus is configured, the child dials it (no
2159        // listen socket / file discovery) and the parent drives it by mailbox id.
2160        spec.bus = self.bus.clone();
2161        // Final model: the session's pinned model_ref (create.model / routing already applied),
2162        // falling back to the job's bare model on the parent's default provider.
2163        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            // Codex auth is independent of the session's normal Bamboo model
2180            // provider. Inherit/API-key/Bamboo modes need no credential-store
2181            // secret at provisioning; custom mode gets exactly its referenced
2182            // key. This prevents an unrelated upstream provider key from
2183            // reaching a Codex worker that only needs a per-run bcx1_ token.
2184            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            // Other executors keep the existing least-privilege contract: only
2211            // the credential for the child session's selected provider.
2212            _ => {
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        // Tool exposure is baked at provision time. Every actual nested create
2234        // is also checked against the canonical Host's current depth policy.
2235        spec.capabilities.nested_spawn = session.spawn_depth < max_spawn_depth;
2236        spec.capabilities.max_spawn_depth = Some(max_spawn_depth);
2237        // #69: activate child-approval review. Sub-agents enforce permissions so
2238        // their DANGEROUS actions (the worker uses a HIGH threshold) reach the
2239        // parent for review — escalated to the human, or model-reviewed off-loop
2240        // when the parent is in bypass. The worker installs no checker without
2241        // this, so the whole review chain would otherwise stay dormant.
2242        spec.capabilities.enforce_permissions = true;
2243        // Propagate "bypass permissions" so a self-orchestrating worker knows it
2244        // is a bypassed parent and installs the off-loop model-reviewer for its
2245        // children's forced-ask actions (Phase 6, Part B). The child session
2246        // already carries the inherited flag (create_child_action seeds it).
2247        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        // #73: propagate "no interactive human approver" (headless / scheduled /
2258        // deployed root, inherited by the child session). When set, the worker's
2259        // per-run approval proxy model-reviews a gated action locally instead of
2260        // escalating to a human who will never answer (which would 300s-deny).
2261        spec.capabilities.no_human_approver = session
2262            .agent_runtime_state
2263            .as_ref()
2264            .is_some_and(|s| s.no_human_approver);
2265        // Read-only authority comes from the child's typed runtime state, never
2266        // its cosmetic role label. The sole legacy recovery path additionally
2267        // requires the old Guardian role AND its host-owned mutation denylist,
2268        // preserving in-flight pre-upgrade reviewers without making a label an
2269        // authority boundary.
2270        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        // Emit the old field for every read-only child. Older workers ignore
2283        // `read_only` but understand this bit and will install the strict
2284        // no-shell checker, which prevents a rolling upgrade from weakening Plan.
2285        spec.capabilities.guardian_read_only = read_only;
2286        if spec.capabilities.read_only_enforced() {
2287            // Do not even construct nested delegation machinery for a read-only
2288            // inspection child; the denylist below remains a second boundary.
2289            spec.capabilities.nested_spawn = false;
2290            // Treat the capability as the authority boundary, not as a hint
2291            // that every caller must remember to pair with a schema filter.
2292            // Preserve caller-specific disables while always removing Bamboo's
2293            // shell, mutating, persistent, interactive, and delegation surfaces.
2294            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                    // User config may add MCP tools/hooks outside the provisioned
2305                    // surface, so it is never inherited by a read-only child.
2306                    *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                    // app-server provisioning accepts `on-request`; the typed
2321                    // Plan activation maps that to `approvalPolicy=never`.
2322                    *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        // #193: route this role to a REMOTE resident worker when one is pinned.
2356        // `spec.identity.role` was just computed from `subagent_type` above; a
2357        // match flips the placement to Remote. The scoped broker credential stays
2358        // on the Host transport and never enters the child RunSpec. No match
2359        // leaves the default `Placement::Local`,
2360        // so the local path is byte-for-byte unchanged for every non-pinned role.
2361        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            // #181 (P2b): route this role to a SCHEDULED worker — ONLY when it is
2368            // NOT already pinned to a fixed remote endpoint (the `else if` makes
2369            // remote_placements take precedence for a role in both). The concrete
2370            // worker is picked at run time in `execute_external_child` from the bus
2371            // (a live connected worker of the pool role). No per-placement bearer
2372            // now — the bus connection uses the bus token. No match in either map
2373            // leaves the default `Placement::Local`.
2374            spec.placement = Placement::Schedulable {
2375                pool: placement.pool.clone(),
2376            };
2377        }
2378        spec
2379    }
2380
2381    /// Assemble from the startup snapshot. Kept synchronous for focused unit
2382    /// tests and non-server callers that do not install a live config source.
2383    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    /// Assemble from the latest AppState config when available. Taking the
2394    /// async read lock here gives every new activation a post-reload credential
2395    /// view without copying secrets into a second independently refreshed
2396    /// runtime store.
2397    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    /// The `metadata["placement"]` JSON to stamp on a child from its resolved
2415    /// placement, preferring the matching cluster node's `host_label` (its
2416    /// operator label/host) over the raw endpoint/pool. `None` for a Local child
2417    /// (the DTO defaults it to the backend's own host). Split out of
2418    /// `execute_external_child` so the role→placement→host resolution is unit-testable.
2419    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        // The same exact-snapshot CAS serves live and cold publication. An
2876        // append-safe generic save could expose Completed after merging new
2877        // input that invalidates the source; CAS rejects before that write.
2878        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        // A later SDK timeout or Host postprocessing error cannot convert an
2926        // accepted Worker terminal into a different receipt status.
2927        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        // #68 CORRECTNESS CRUX: capture the per-run escalation bridge HERE, at the
3007        // moment this grandchild is spawned — while the parent run's bridge is
3008        // still in our slot — into an owned local handed to `drive()` for this
3009        // grandchild's whole lifetime. A fire-and-forget grandchild that OUTLIVES
3010        // the run that spawned it must NOT re-read `self.escalation_bridge` at
3011        // approval time: by then `run()` may have cleared/overwritten it (a worker
3012        // serves runs sequentially), and re-proxying through a closed bridge
3013        // fail-closed denies. Capturing at spawn pins the right bridge per run.
3014        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        // Reject before remote routing, Actor claims, provisioning or Ticket
3035        // Run admission: resident workers cannot produce local owned stop proof.
3036        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        // Mark the worker reusable + give it an idle timeout so it self-reaps if
3069        // orphaned. Warm bus workers are pooled per fingerprint and reused.
3070        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            // Positive native names are a leaf surface, never an expansion path.
3101            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        // Eligibility uses the actual Host callable ceiling, after the strict
3109        // built-in route and birth capability checks. A role label is not a grant.
3110        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                // Ordinary Glob remains legacy. Observe only its own mapped
3155                // initialization signals; never require a whole-tree snapshot.
3156                require_unowned_glob(store.as_ref(), session).await?;
3157                None
3158            } else {
3159                // Public bounded observation is only a no-replay check. It is
3160                // never an activation grant. Ordinary first runs stay legacy.
3161                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        // The recommended provider URL is deliberately parent-loopback. A
3219        // resident remote worker would interpret 127.0.0.1 as itself, not this
3220        // server, so reject that ambiguous deployment instead of minting a
3221        // credential that can never authenticate to the intended parent.
3222        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        // Policy is captured per activation (not only when a worker is
3236        // provisioned), so reused local workers and resident remote/broker
3237        // workers observe the latest durable revision and bypass flag at the
3238        // next run boundary. Session grants are intentionally not inherited.
3239        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        // Backpressure: hold a concurrency slot for the lifetime of the *run*
3313        // (cancellation still proceeds — the cancel branch in drive() runs while
3314        // we hold the permit). Released when this fn returns, i.e. once the worker
3315        // is parked back into the pool, so idle workers don't pin slots.
3316        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        // Bamboo-as-provider credentials are minted at the activation boundary,
3337        // after backpressure admits the run and never at worker provisioning.
3338        // This is load-bearing for warm workers: a parked process must never
3339        // retain a token from its previous run. The guard revokes on every return
3340        // path (success, error, cancellation, dispatch failure, or first-frame
3341        // retry exhaustion).
3342        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        // Split LOCAL (spawn + warm-pool) from the two process-less remote paths
3349        // ONLY at the divergent spots — acquire/connect here and the park/retire at
3350        // the end. Everything between (Run dispatch, live-actor registration,
3351        // drive, the close) is identical for all three. `kind` is the single guard.
3352        //   - Local       (#0):  byte-for-byte the pre-#193 reuse-or-spawn path.
3353        //   - Remote       (#1311): connect to a scoped broker peer, no spawn.
3354        //   - Schedulable  (#181): resolve a live worker from the registry, connect.
3355        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        // Stamp WHICH machine this child runs on onto its session metadata, so the
3382        // UI can show it (mirrored into the session index → SessionSummary.placement).
3383        // Only remote/scheduled placements need a stamp — a Local child falls through
3384        // to the DTO default (this backend's own host). Persisted by the caller with
3385        // the rest of the child session after we return.
3386        if let Some(placement_meta) = self.placement_stamp_for(&spec) {
3387            session
3388                .metadata
3389                .insert("placement".to_string(), placement_meta);
3390        }
3391
3392        // Retry-once loop: a pooled local worker can die between its liveness
3393        // check and handling the Run (a tiny TOCTOU window) — its Run then sits
3394        // queued with no server. The first-frame watchdog in `drive` surfaces that
3395        // as `WorkerUnresponsive`; we reap the dead worker and re-acquire ONCE
3396        // (which spawns fresh / reuses the next live one). Remote/schedulable have
3397        // no spawn fallback, so they never retry.
3398        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                    // LOCAL = the mailbox bus (the unified transport): check out a warm
3502                    // pooled worker (reuse a live parked one, else spawn fresh) and
3503                    // drive it by mailbox id — no listen socket, no file discovery, no
3504                    // respawn-on-connect-miss (the broker queues the Run until the
3505                    // worker handles it). The legacy direct-WS path was retired; the bus
3506                    // is required.
3507                    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            // Recover an older Run's already Host-checkpointed Event/Outcome
3535            // receipts before dispatching a successor. The private V2 ledger
3536            // binds exact Child birth, Run, transcript prefix and broker
3537            // Maildir identity. A different broker or an unproven final save
3538            // blocks this activation instead of deleting the wrong mailbox.
3539            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                        // This path already owns a successor Run and its public
3549                        // running marker. Old committed receipts authorize ACK
3550                        // only; never publish the predecessor over that owner.
3551                        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            // Publish the actor delivery owner and claim the complete bounded
3589            // authorized prefix before dispatching Run. These deliveries ride
3590            // inside RunSpec, so the worker durably enqueues them before its
3591            // first provider boundary rather than racing a later steer frame.
3592            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            // Recompute after claim reconciliation: a warm retry may have had a
3646            // canonical receipt whose transcript proof was restored above.
3647            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                    // The Run posture was resolved from Host configuration
3716                    // before provisioning. Bind that exact revision and the
3717                    // Host-selected local placement before claiming authority;
3718                    // a worker event or display placement cannot supply either.
3719                    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                // Cloned (not moved) so a retry can re-dispatch to a fresh worker.
3856                assignment: assignment.clone(),
3857                logical_session: Some(logical_identity),
3858                project_id: project_id.clone(),
3859                // Creation/update already resolves the child's explicit
3860                // selection and own role preference into this field. Do
3861                // not infer effort from a possibly inherited model_ref.
3862                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            // Register as a live actor so send_message (running, no interrupt) can
4020            // steer this child in-band over the existing WS connection. The guard
4021            // unregisters on every exit path.
4022            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                // First-frame watchdog for EVERY placement: a wedged-but-connected
4058                // worker (subscribed ≠ serving — e.g. stuck on a prior LLM call) emits
4059                // no first frame; without a deadline drive() blocks forever. Bounding it
4060                // turns the "running-but-unresponsive" hang into a recoverable
4061                // WorkerUnresponsive (reap+respawn local / re-pick schedulable / error
4062                // on a fixed remote endpoint).
4063                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                // BrokerChildLink replaces the actual correlation for each Run;
4078                // direct legacy WS Terminal frames carry no such identity.
4079                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); // No Host checkpoint proof means no broker ACK.
4174                    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            // Unregister IMMEDIATELY: after drive returns nobody consumes live_rx,
4189            // so a send_message landing in the close/park window below must see
4190            // "not live" and take the durable-queue fallback instead of vanishing.
4191            // (Even if one slipped in earlier, send_message also appends it to the
4192            // durable transcript, so the next activation still rehydrates it.)
4193            drop(live_guard);
4194            // Keep only a fully processed broker terminal's exact mailbox
4195            // receipts alive until the SDK has saved this Child's canonical
4196            // transcript and final status. No run fence means no Host proof.
4197            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                    // A Worker-local transcript is not Host checkpoint proof.
4222                    drop(client);
4223                }
4224            } else {
4225                drop(client);
4226            }
4227
4228            // Retry only a local worker that we can kill. A silent remote
4229            // Worker may still be executing its first Run; starting the same
4230            // Actor on another Host before a terminal fence would duplicate it.
4231            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        // Park the warm worker for reuse on a clean run, or kill it on
4260        // error/cancel (a wedged worker must not be reused). Remote / schedulable
4261        // workers are registry-managed — never ours to pool/kill, just drop.
4262        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                                // The canonical terminal save can retain output
4291                                // and retry this stop fact before broker ACK.
4292                                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                            // OutcomeUnconfirmed is an error, never ordinary-save fallback.
4365                            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        // Write-back: persist the actor's final reply onto the child session so
4388        // the transcript survives and the NEXT activation sees it as history.
4389        // (run_child_spawn saves the session right after we return.)
4390        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
4403// This is a conservative no-replay observation, not Actor authority. The
4404// canonical mapping comes from Storage; record/marker names match its writer.
4405async 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
4449// Zero-tool or read-only Glob named Child of a durable Ultra Root.
4450// Glob uses its typed tail; zero-tool supports new input after a plain completion
4451// as well as two bounded corrections within a Running activation.
4452// An expired initial owner can be replaced with one new input through the same
4453// checkpoint/release boundary. No lease renewal, raw steering or remote activation.
4454struct 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}
4497// Selection only. Canonical terminal status/new input are checked at start;
4498// the existing fenced input checkpoint validates the complete durable prefix.
4499fn 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}
4511// Selection only. Storage's closed raw bookkeeper/current-prefix validator is
4512// still mandatory after the actual replacement claim; this metadata is no grant.
4513fn 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    // Callee-side boxing keeps this storage chain out of the already large
4616    // external-runner future without changing its cancellation semantics.
4617    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            // Only completed plain local history gains a bounded initial batch.
4665            // Retry, expired owner and pre-ACK recovery retain their single-input
4666            // contracts. QueueOnly alone still cannot authorize a cold start.
4667            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            // The old initial Run may still be executing physically. Its final
4692            // append remains fenced; only a new owned input can release this
4693            // replacement worker. Previously admitted history is not replayed.
4694            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                        // Written only after the real worker capability probe
4772                        // and spawn. Opaque provenance, never a live-owner grant.
4773                        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                    // claim_owned is idempotent for this consumer. Repeating
4861                    // limit=1 would return the same first claim, not the next.
4862                    // Select once under the existing bounded queue transaction.
4863                    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                // Validate/claim the entire bounded selection before publishing
4879                // any input. Each checkpoint still uses its own physical lease,
4880                // complete current prefix and actual Actor fence. A partial or
4881                // uncertain checkpoint never dispatches a Run or ACKs a sibling.
4882                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            // The worker receives the committed pre-input prefix. Only typed
5003            // delivery adds the User; the Host-private bookkeeper is not a grant.
5004            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                    // A Parent can save ordinary tool/wait state while this
5020                    // Child checkpoints. PrefixConflict guarantees no publish;
5021                    // retry the SAME Child prefix, claim and fence, with every
5022                    // storage authority check repeated. Never adopt a changed
5023                    // Child transcript or retry an unconfirmed publication.
5024                    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                    // Parent writes may change the ancestor witness before
5102                    // publication. Retry this exact reply and Child prefix;
5103                    // never reload/adopt a different transcript or replay an
5104                    // unconfirmed write. Storage rechecks the same live fence.
5105                    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    // First Terminal is a real completed plain reply, not a cache event. Commit
5125    // it before appending the typed correction, then use the existing startup
5126    // admission barrier on a second correlated Run under the SAME Host fence.
5127    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            // Fresh actual Host audit, unchanged permission/ceiling checks.
5153            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                &current_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, &current_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                // Full current-prefix AlreadyCheckpointed readback still checks
5308                // the actual physical owner. A local worker receipt is insufficient.
5309                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                        // As at initial checkpoint, this error guarantees no
5330                        // publication. Repeat the identical prefix, claim and
5331                        // fence; a Parent's ordinary save grants no authority.
5332                        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            // ACK's terminal receipt is idempotent even after expiry. It cannot
5358            // replace these final current-owner/deadline/policy checks.
5359            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, &current_run, expected)?;
5381            if chrono::Utc::now() >= deadline {
5382                return Err(plain_actor_unsupported());
5383            }
5384            Ok(bamboo_subagent::proto::InitialInputRelease {
5385                request: request.clone(),
5386                expires_at: deadline,
5387            })
5388        })
5389    }
5390    async fn confirm_input(
5391        &self,
5392        binding: &SessionInboxRuntimeBinding,
5393        session: &Session,
5394        run_id: &str,
5395        claim: &SessionInboxOwnedClaim,
5396        confirmation: &bamboo_subagent::proto::SessionMessageAdmissionConfirmation,
5397    ) -> Result<(), AgentError> {
5398        if confirmation.target_session_id != session.id
5399            || confirmation.envelope_id != claim.claim.envelope.id.as_str()
5400            || confirmation.canonical_claim_generation != claim.claim.generation
5401            || confirmation.activation_run_id != run_id
5402            || run_id != self.fence.run_id
5403            || !binding.router.owns_run(&session.id, run_id).await
5404        {
5405            return Err(plain_actor_unsupported());
5406        }
5407        self.input_inbox
5408            .ack_owned(&session.id, claim, chrono::Utc::now())
5409            .await
5410            .map_err(|error| {
5411                tracing::warn!(%error, "Actor correction ACK unresolved");
5412                AgentError::LLM(
5413                    "Actor correction ACK unresolved; durable input is preserved".into(),
5414                )
5415            })
5416    }
5417    async fn finish(&self, outcome: ActorActivationFinish) -> Result<(), AgentError> {
5418        self.finish_with_observer(outcome, None).await
5419    }
5420
5421    async fn finish_with_observer(
5422        &self,
5423        outcome: ActorActivationFinish,
5424        observer: Option<&dyn ActorEventObserver>,
5425    ) -> Result<(), AgentError> {
5426        let committed = self
5427            .store
5428            .finish_activation_observed(&self.fence, chrono::Utc::now(), outcome)
5429            .await
5430            .map_err(|error| AgentError::LLM(format!("Actor completion unconfirmed: {error}")))?;
5431        // A validated final Terminal hands off this one router. Failed writes
5432        // never publish, and neither a retry nor another finish can reuse it.
5433        let router = self.completion_router.lock().recover_poison().take();
5434        if let (Some(observer), Some(router)) = (observer, router) {
5435            // The Directory may already hold a successor by this point. Never
5436            // relabel the old completion, and never turn an already committed
5437            // success into an execution failure if best-effort delivery is lost.
5438            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    // Only the selected Cancelled terminal may finish a validated Read/Glob
5459    // prefix. This is Host control flow, never a Worker DATA authority flag.
5460    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    // Installed only by the Host's exact Task callback receipt. Worker event
5478    // JSON and transcript metadata cannot grant this terminal exception.
5479    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            // Only the committed Host projection may publish a plan update.
5523            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                // Text reasoning is a stream observation. Its complete value
5605                // is preserved by the bounded typed completion, which still
5606                // rejects signatures and unsupported provider state.
5607            }
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            // Worker commit events are observations, not canonical Host commits.
5615            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            // An unclaimed correction can race its delivery notification.
5854            // Inspect only: neither admission nor ACK belongs to cancellation.
5855            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        // A new canonical input during cancellation has not been applied by
5942        // this Worker. Preserve it, but do not certify it with the old terminal.
5943        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    // Append-safe reconciliation may add a concurrent Host User; it may never
5958    // rewrite the validated Host prefix or any worker row being committed.
5959    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        // Worker cache commits are not host Store commits. Publish only the
6126        // actual guarded host append after it returns a committed snapshot.
6127        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
6136/// The `{kind,host}` placement descriptor stamped onto a child session's metadata
6137/// under `"placement"` — read back by the storage index → `SessionSummary.placement`
6138/// → the UI's machine badge. `None` for `Local` (those fall through to the DTO's
6139/// default of this backend's own host). The value is a JSON string matching
6140/// `bamboo_storage::SessionPlacement { kind, host }`.
6141fn placement_metadata(placement: &Placement, host_label: Option<&str>) -> Option<String> {
6142    // Prefer the cluster node's own label/host (its metadata) when the placement
6143    // maps to a node; else fall back to the raw endpoint host / pool name.
6144    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
6158/// Extract the host from a `ws[s]://host:port[/path]` bus endpoint, for display.
6159fn 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
6231/// Checkpoint a worker-confirmed envelope into the canonical logical Session,
6232/// then create the permanent host receipt/remove its exact claim. This order is
6233/// the actor-side equivalent of the local state_bridge crash boundary.
6234async 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                // An error after atomic replacement may still have advanced
6279                // Main. Keep the claim unacked and use that canonical context
6280                // for the SDK's terminal status save.
6281                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
6412/// Durably seed claimed typed messages into the canonical host transcript
6413/// before dispatching them to any actor worker, while deliberately leaving the
6414/// admission cursor and `cur/` claims untouched.
6415///
6416/// This is the cross-placement lost-confirmation invariant: if worker A admits
6417/// and reasons over the batch but its confirmations are lost, a retry on worker
6418/// B receives a host snapshot already containing each stable typed message
6419/// exactly once. Worker B's local safe boundary then records/acks the same ids
6420/// without appending duplicates. Only an exact worker confirmation advances the
6421/// host cursor; only a durable cursor checkpoint precedes host ack.
6422async 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                // Publication may have occurred before an unconfirmed
6495                // readback. Do not dispatch or ACK; adopt only the current
6496                // canonical context so SDK can still save an error status.
6497                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        // Cursor+transcript without the permanent tombstone is the recoverable
6584        // crash window after an exact worker confirmation was checkpointed but
6585        // before host ack removed `cur/`. Finish that ack without exposing the
6586        // message to another provider run.
6587        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
6646/// Borrowed and per-run-owned inputs for one actor frame pump.
6647struct 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    /// Legacy/custom actors were dispatched without a typed posture contract.
6696    NotRequired,
6697    /// The host dispatched an exact posture and no matching, durably recorded
6698    /// worker activation has arrived yet.
6699    Awaiting,
6700    /// One worker posture matched and its host-owned audit write succeeded.
6701    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    // Fence against the exact canonical identity used to build RunSpec. Some
6827    // migrated/test sessions have not yet materialized parent ancestry on the
6828    // in-memory Session, so comparing the batch directly with those raw fields
6829    // can reject the identity the host itself just dispatched.
6830    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
6859/// The plain Actor path already owns a durable Directory activation. Check that
6860/// exact owner before admitting either batched or rolling-upgrade live frames;
6861/// worker-provided batch identity alone is never an activation authority.
6862async 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
6889/// The Worker supplies only a cursor. All identity and scope inputs come from
6890/// this Host's active drive and its canonical durable Session store.
6891async 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(&current) {
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            &current,
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
6997/// Resolve the caller of a mutating nested SubAgent RPC entirely from the
6998/// Host's active drive and canonical store. The frame never supplies identity,
6999/// Project, ancestry, or a run fence.
7000async 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(&current).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(&current).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            &current,
7033            Some(run_id),
7034        )
7035        .await
7036        .ok()?;
7037    } else if !binding.router.owns_run(child_id, run_id).await {
7038        return None;
7039    }
7040    Some(current)
7041}
7042
7043async fn invoke_active_subagent(
7044    tool: &dyn bamboo_agent_core::tools::Tool,
7045    caller: &Session,
7046    args: serde_json::Value,
7047    tool_call_id: &str,
7048) -> serde_json::Value {
7049    use bamboo_agent_core::tools::{ToolCtx, ToolOutcome};
7050    let mut ctx = ToolCtx::none(tool_call_id.to_owned());
7051    ctx.session_id = Some(Arc::from(caller.id.as_str()));
7052    let output_cap = caller
7053        .effective_token_budget()
7054        .map(|budget| budget.max_tool_output_tokens);
7055    let outcome = bamboo_agent_core::tools::scope_tool_output_cap(
7056        &caller.id,
7057        tool_call_id,
7058        output_cap,
7059        tool.invoke(args, ctx),
7060    )
7061    .await;
7062    match outcome {
7063        Ok(ToolOutcome::Completed(result))
7064            if result.images.is_empty()
7065                && serde_json::to_vec(&result).is_ok_and(|bytes| bytes.len() <= 16 * 1024) =>
7066        {
7067            serde_json::json!({"result":result})
7068        }
7069        _ => serde_json::json!({
7070            "error":"SubAgent operation failed or its result exceeded the limit; inspect logical child state before retrying"
7071        }),
7072    }
7073}
7074
7075/// Commit one already-persisted Worker clarification under the Host's current
7076/// Child birth and Run fence. Only the Host supplies caller/parent identity.
7077#[allow(clippy::too_many_arguments)]
7078async fn checkpoint_active_parent_question(
7079    store: &bamboo_storage::SessionStoreV2,
7080    binding: &SessionInboxRuntimeBinding,
7081    run_id: &str,
7082    child_id: &str,
7083    parent_id: &str,
7084    logical_session: &mut Session,
7085    creation: &bamboo_subagent::proto::ChildCreationIdentity,
7086    plain_input: Option<&PlainActorActivation>,
7087    observation: ParentQuestionCheckpointV1,
7088) -> Option<String> {
7089    use bamboo_agent_core::storage::Storage;
7090    if plain_input.is_some() || observation.validate_shape().is_err() {
7091        return None;
7092    }
7093    // SessionRepository's final save and the direct-parent answer resolver
7094    // use this same Host-owned lock. Hold it across the V2 read/CAS/save so
7095    // neither can read an older Child and overwrite the checkpoint afterward.
7096    let _session_guard = binding
7097        .parent_question_lock
7098        .as_ref()?
7099        .acquire_lock(child_id)
7100        .await;
7101    let caller = load_active_subagent_caller(
7102        store,
7103        binding,
7104        run_id,
7105        child_id,
7106        parent_id,
7107        logical_session,
7108        creation,
7109        plain_input,
7110    )
7111    .await?;
7112    let parent = store.load_session(parent_id).await.ok().flatten()?;
7113    if parent.created_at > caller.created_at || !binding.router.owns_run(child_id, run_id).await {
7114        return None;
7115    }
7116    let (saved, question) = store
7117        .checkpoint_parent_question(&caller, &parent, &observation)
7118        .await
7119        .ok()
7120        .flatten()?;
7121    // The current Run's local snapshot must carry the committed canonical
7122    // prefix into finalization; sdk/spawn rechecks disk before its last save.
7123    logical_session.messages = saved.messages;
7124    logical_session.pending_question = saved.pending_question;
7125    if let Some(reason) = saved.metadata.get("runtime.suspend_reason") {
7126        logical_session
7127            .metadata
7128            .insert("runtime.suspend_reason".into(), reason.clone());
7129    }
7130    if let Some(request) = saved
7131        .metadata
7132        .get(bamboo_domain::PARENT_QUESTION_REQUEST_KEY)
7133    {
7134        logical_session.metadata.insert(
7135            bamboo_domain::PARENT_QUESTION_REQUEST_KEY.into(),
7136            request.clone(),
7137        );
7138    }
7139    logical_session.metadata.insert(
7140        "runtime.actor_parent_question_handoff".into(),
7141        "true".into(),
7142    );
7143    Some(question.id.as_str().to_owned())
7144}
7145
7146/// An actor Worker cannot see the Host's canonical descendants. At the end of
7147/// an otherwise completed Child turn, apply the same orphan-child safety net
7148/// used by the in-process root loop before publishing this Child as terminal.
7149/// The existing Host wait, including an explicit `any` policy, always wins.
7150async fn ensure_active_nested_wait(
7151    tool: &dyn bamboo_agent_core::tools::Tool,
7152    store: &bamboo_storage::SessionStoreV2,
7153    binding: &SessionInboxRuntimeBinding,
7154    run_id: &str,
7155    child_id: &str,
7156    parent_id: &str,
7157    logical_session: &Session,
7158    creation: &bamboo_subagent::proto::ChildCreationIdentity,
7159    plain_input: Option<&PlainActorActivation>,
7160) -> Result<bool, AgentError> {
7161    fn is_terminal_status(status: &str) -> bool {
7162        matches!(
7163            status,
7164            "completed" | "error" | "timeout" | "cancelled" | "skipped"
7165        )
7166    }
7167    use bamboo_agent_core::storage::Storage;
7168    use bamboo_agent_core::tools::{ToolCtx, ToolOutcome};
7169
7170    let caller = load_active_subagent_caller(
7171        store,
7172        binding,
7173        run_id,
7174        child_id,
7175        parent_id,
7176        logical_session,
7177        creation,
7178        plain_input,
7179    )
7180    .await
7181    .ok_or_else(|| AgentError::LLM("nested wait caller is no longer active".into()))?;
7182    if caller
7183        .agent_runtime_state
7184        .as_ref()
7185        .and_then(|state| state.waiting_for_children.as_ref())
7186        .is_some()
7187    {
7188        return Ok(true);
7189    }
7190
7191    let statuses = store
7192        .list_child_run_statuses(child_id)
7193        .await
7194        .map_err(|error| AgentError::LLM(format!("nested child status scan failed: {error}")))?;
7195    let mut active = Vec::new();
7196    for (id, status) in statuses {
7197        if !status.as_deref().is_some_and(is_terminal_status)
7198            && !store
7199                .is_supervisor_observer_child(&caller, &id)
7200                .await
7201                .map_err(|error| {
7202                    AgentError::LLM(format!("nested Observer classification failed: {error}"))
7203                })?
7204        {
7205            active.push(id);
7206        }
7207    }
7208    if active.is_empty() {
7209        return Ok(false);
7210    }
7211
7212    let mut ctx = ToolCtx::none(format!("host-nested-orphan-wait:{run_id}"));
7213    ctx.session_id = Some(Arc::from(caller.id.as_str()));
7214    let outcome = tokio::time::timeout(
7215        Duration::from_secs(30),
7216        tool.invoke(
7217            serde_json::json!({"action":"wait", "child_session_ids":active}),
7218            ctx,
7219        ),
7220    )
7221    .await
7222    .map_err(|_| AgentError::LLM("nested wait registration timed out".into()))?
7223    .map_err(|error| AgentError::LLM(format!("nested wait registration failed: {error}")))?;
7224    let wait_registered = match outcome {
7225        ToolOutcome::Completed(result) if result.success => {
7226            let payload: serde_json::Value = serde_json::from_str(&result.result)
7227                .map_err(|_| AgentError::LLM("nested wait result is invalid".into()))?;
7228            payload["status"] == "waiting"
7229        }
7230        _ => {
7231            return Err(AgentError::LLM(
7232                "nested wait registration was not completed".into(),
7233            ))
7234        }
7235    };
7236
7237    // A grandchild may finish while the wait is being registered and clear it.
7238    // A clear wait is safe only when no canonical descendants remain active.
7239    let latest = load_active_subagent_caller(
7240        store,
7241        binding,
7242        run_id,
7243        child_id,
7244        parent_id,
7245        logical_session,
7246        creation,
7247        plain_input,
7248    )
7249    .await
7250    .ok_or_else(|| AgentError::LLM("nested wait caller changed during registration".into()))?;
7251    if latest
7252        .agent_runtime_state
7253        .as_ref()
7254        .and_then(|state| state.waiting_for_children.as_ref())
7255        .is_some()
7256    {
7257        return Ok(true);
7258    }
7259    let statuses = store
7260        .list_child_run_statuses(child_id)
7261        .await
7262        .map_err(|error| {
7263            AgentError::LLM(format!("nested child wait verification failed: {error}"))
7264        })?;
7265    let mut still_active = false;
7266    for (id, status) in statuses {
7267        if !status.as_deref().is_some_and(is_terminal_status)
7268            && !store
7269                .is_supervisor_observer_child(&latest, &id)
7270                .await
7271                .map_err(|error| {
7272                    AgentError::LLM(format!("nested Observer classification failed: {error}"))
7273                })?
7274        {
7275            still_active = true;
7276            break;
7277        }
7278    }
7279    if still_active {
7280        return Err(AgentError::LLM(
7281            "nested child remains active without a durable parent wait".into(),
7282        ));
7283    }
7284    // Keep the current Run's suspend intent even if the last grandchild
7285    // finished and cleared the Host wait before SDK finalization. Its outcome
7286    // has already admitted a successor; publishing this Run as completed
7287    // would release the grandparent on the Child's interim response.
7288    Ok(wait_registered)
7289}
7290
7291fn actor_event_route_error(error: ActorEventRouteError) -> AgentError {
7292    let message = match error {
7293        ActorEventRouteError::SequenceGap { .. } => {
7294            "actor event sequence gap; authoritative session snapshot is required"
7295        }
7296        ActorEventRouteError::LifecycleMissing => {
7297            "actor emitted live content before its activation lifecycle event"
7298        }
7299        ActorEventRouteError::ForgedSession => {
7300            "actor event payload targets a different canonical session"
7301        }
7302        ActorEventRouteError::ConflictingReplay => {
7303            "actor replay reused an event sequence with different content"
7304        }
7305        ActorEventRouteError::StaleAuthority => "actor event owner is stale",
7306        ActorEventRouteError::SequenceExhausted => "actor event sequence is exhausted",
7307    };
7308    AgentError::LLM(message.into())
7309}
7310
7311const MAX_DISPLAY_CALLS: usize = 4_096;
7312const MAX_NESTED_DISPLAY_DEPTH: usize = 8;
7313const MAX_DISPLAY_ID_BYTES: usize = 256;
7314const MAX_DISPLAY_HOOK_RESULT_DEPTH: usize = 16;
7315
7316fn hook_result_for_display(result: HookResult, depth: usize) -> Option<HookResult> {
7317    if depth >= MAX_DISPLAY_HOOK_RESULT_DEPTH {
7318        return None;
7319    }
7320    Some(match result {
7321        HookResult::Continue => HookResult::Continue,
7322        HookResult::Mutated => HookResult::Mutated,
7323        HookResult::Allow => HookResult::Allow,
7324        HookResult::Ask => HookResult::Ask,
7325        HookResult::Deny { .. } => HookResult::Deny {
7326            reason: "Hook denied".into(),
7327        },
7328        HookResult::InjectContext { .. } => HookResult::InjectContext {
7329            text: "Hook context hidden".into(),
7330        },
7331        HookResult::WithContext { result, .. } => HookResult::WithContext {
7332            result: Box::new(hook_result_for_display(*result, depth + 1)?),
7333            text: "Hook context hidden".into(),
7334        },
7335        HookResult::Suspend { .. } => HookResult::Suspend {
7336            reason: "Hook suspended".into(),
7337        },
7338        HookResult::Abort { .. } => HookResult::Abort {
7339            reason: "Hook aborted".into(),
7340        },
7341    })
7342}
7343
7344#[derive(Clone, Copy, PartialEq, Eq)]
7345enum DisplayCallKind {
7346    Ordinary,
7347    PrivateBrowser,
7348    Reused,
7349}
7350
7351#[derive(Clone, Copy)]
7352struct DisplayCall {
7353    kind: DisplayCallKind,
7354    started: bool,
7355}
7356
7357#[derive(Default)]
7358struct ActorEventDisplay {
7359    calls: HashMap<String, DisplayCall>,
7360    hook_names: HashMap<String, usize>,
7361    children: HashMap<(String, String), Box<ActorEventDisplay>>,
7362    // Shared by every nested child: a per-level cap alone permits an
7363    // exponential number of identities across an actor event tree.
7364    identities: Arc<AtomicUsize>,
7365    overflowed: bool,
7366    nested_overflowed: bool,
7367}
7368
7369fn is_browser_tool_name(tool_name: &str) -> bool {
7370    tool_name
7371        .trim()
7372        .rsplit("::")
7373        .next()
7374        .is_some_and(|name| name.eq_ignore_ascii_case("browser"))
7375}
7376
7377fn known_browser_action(args: &serde_json::Value) -> bool {
7378    matches!(
7379        args.get("action").and_then(serde_json::Value::as_str),
7380        Some(
7381            "tabs"
7382                | "new_tab"
7383                | "activate_tab"
7384                | "close_tab"
7385                | "navigate"
7386                | "history"
7387                | "viewport"
7388                | "snapshot"
7389                | "click"
7390                | "click_at"
7391                | "hover"
7392                | "drag"
7393                | "fill"
7394                | "select_option"
7395                | "set_file_input"
7396                | "type"
7397                | "press"
7398                | "key"
7399                | "scroll"
7400                | "download"
7401                | "screenshot"
7402                | "dialog_respond"
7403        )
7404    )
7405}
7406
7407fn is_private_browser_call(tool_name: &str, args: &serde_json::Value) -> bool {
7408    is_browser_tool_name(tool_name)
7409        && (!known_browser_action(args)
7410            || args
7411                .get("action")
7412                .and_then(serde_json::Value::as_str)
7413                .is_some_and(|action| {
7414                    action.eq_ignore_ascii_case("download") || action == "set_file_input"
7415                })
7416            || args.get("data_base64").is_some())
7417}
7418
7419fn actor_arguments_for_display(tool_name: &str, args: &serde_json::Value) -> serde_json::Value {
7420    if is_browser_tool_name(tool_name) {
7421        // A malformed or unknown action has no reliable privacy class. A
7422        // mixed-version actor may emit it before its schema rejects it.
7423        if !known_browser_action(args) {
7424            return serde_json::json!({"details":"[redacted]"});
7425        }
7426    }
7427    tool_start_arguments_for_display(tool_name, args)
7428}
7429
7430impl ActorEventDisplay {
7431    fn fail_closed(&mut self) {
7432        self.identities
7433            .store(MAX_DISPLAY_CALLS + 1, Ordering::Relaxed);
7434        self.overflowed = true;
7435        self.calls.clear();
7436        self.hook_names.clear();
7437        self.nested_overflowed = true;
7438        self.children.clear();
7439    }
7440
7441    fn reserve_identity(&mut self) -> bool {
7442        if self.identities.fetch_add(1, Ordering::Relaxed) < MAX_DISPLAY_CALLS {
7443            true
7444        } else {
7445            self.fail_closed();
7446            false
7447        }
7448    }
7449
7450    fn remember_start(&mut self, id: &str, private: bool) -> DisplayCallKind {
7451        if self.overflowed || id.is_empty() || id.len() > MAX_DISPLAY_ID_BYTES {
7452            return DisplayCallKind::Reused;
7453        }
7454        let kind = if private {
7455            DisplayCallKind::PrivateBrowser
7456        } else {
7457            DisplayCallKind::Ordinary
7458        };
7459        if let Some(call) = self.calls.get_mut(id) {
7460            if call.started || call.kind != kind {
7461                call.kind = DisplayCallKind::Reused;
7462            }
7463            call.started = true;
7464            return call.kind;
7465        }
7466        if !self.reserve_identity() {
7467            return DisplayCallKind::Reused;
7468        }
7469        self.calls.insert(
7470            id.to_string(),
7471            DisplayCall {
7472                kind,
7473                started: true,
7474            },
7475        );
7476        kind
7477    }
7478
7479    fn remember_approval(&mut self, id: &str, private: bool) -> DisplayCallKind {
7480        if self.overflowed || id.is_empty() || id.len() > MAX_DISPLAY_ID_BYTES {
7481            return DisplayCallKind::Reused;
7482        }
7483        let kind = if private {
7484            DisplayCallKind::PrivateBrowser
7485        } else {
7486            DisplayCallKind::Ordinary
7487        };
7488        if let Some(call) = self.calls.get_mut(id) {
7489            if call.kind != kind {
7490                call.kind = DisplayCallKind::Reused;
7491            }
7492            return call.kind;
7493        }
7494        if !self.reserve_identity() {
7495            return DisplayCallKind::Reused;
7496        }
7497        self.calls.insert(
7498            id.to_string(),
7499            DisplayCall {
7500                kind,
7501                started: false,
7502            },
7503        );
7504        kind
7505    }
7506
7507    fn known_kind(&self, id: &str) -> Option<DisplayCallKind> {
7508        if self.overflowed || self.identities.load(Ordering::Relaxed) > MAX_DISPLAY_CALLS {
7509            return None;
7510        }
7511        self.calls
7512            .get(id)
7513            .filter(|call| call.started)
7514            .map(|call| call.kind)
7515    }
7516
7517    fn display_hook_name(&mut self, hook_name: &str) -> Option<String> {
7518        if self.overflowed || hook_name.is_empty() || hook_name.len() > MAX_DISPLAY_ID_BYTES {
7519            self.fail_closed();
7520            return None;
7521        }
7522        if let Some(index) = self.hook_names.get(hook_name) {
7523            return Some(format!("actor-hook-{index}"));
7524        }
7525        if !self.reserve_identity() {
7526            return None;
7527        }
7528        let index = self.hook_names.len() + 1;
7529        self.hook_names.insert(hook_name.to_string(), index);
7530        Some(format!("actor-hook-{index}"))
7531    }
7532
7533    fn project(&mut self, event: AgentEvent, depth: usize) -> Option<AgentEvent> {
7534        let call_id = match &event {
7535            AgentEvent::ToolStart { tool_call_id, .. }
7536            | AgentEvent::ToolApprovalRequested { tool_call_id, .. }
7537            | AgentEvent::ToolToken { tool_call_id, .. }
7538            | AgentEvent::ToolComplete { tool_call_id, .. }
7539            | AgentEvent::ToolError { tool_call_id, .. }
7540            | AgentEvent::ToolLifecycle { tool_call_id, .. } => Some(tool_call_id),
7541            _ => None,
7542        };
7543        if call_id.is_some_and(|id| id.is_empty() || id.len() > MAX_DISPLAY_ID_BYTES) {
7544            self.fail_closed();
7545            return None;
7546        }
7547        if self.identities.load(Ordering::Relaxed) > MAX_DISPLAY_CALLS {
7548            self.fail_closed();
7549        }
7550        Some(match event {
7551            AgentEvent::ToolStart {
7552                tool_call_id,
7553                tool_name,
7554                arguments,
7555            } => {
7556                let private = is_private_browser_call(&tool_name, &arguments);
7557                let kind = self.remember_start(&tool_call_id, private);
7558                let arguments = if kind == DisplayCallKind::Reused {
7559                    serde_json::json!({"details":"[redacted]"})
7560                } else {
7561                    actor_arguments_for_display(&tool_name, &arguments)
7562                };
7563                AgentEvent::ToolStart {
7564                    tool_call_id,
7565                    tool_name: match kind {
7566                        DisplayCallKind::Reused => "unknown".into(),
7567                        _ if is_browser_tool_name(&tool_name) => "browser".into(),
7568                        _ => tool_name,
7569                    },
7570                    arguments,
7571                }
7572            }
7573            AgentEvent::ToolApprovalRequested {
7574                tool_call_id,
7575                tool_name,
7576                parameters,
7577            } => {
7578                let private = is_private_browser_call(&tool_name, &parameters);
7579                let kind = self.remember_approval(&tool_call_id, private);
7580                let parameters = if kind == DisplayCallKind::Reused {
7581                    serde_json::json!({"details":"[redacted]"})
7582                } else {
7583                    actor_arguments_for_display(&tool_name, &parameters)
7584                };
7585                AgentEvent::ToolApprovalRequested {
7586                    tool_call_id,
7587                    tool_name: match kind {
7588                        DisplayCallKind::Reused => "unknown".into(),
7589                        _ if is_browser_tool_name(&tool_name) => "browser".into(),
7590                        _ => tool_name,
7591                    },
7592                    parameters,
7593                }
7594            }
7595            AgentEvent::ToolToken {
7596                tool_call_id,
7597                content,
7598            } => {
7599                let content = match self.known_kind(&tool_call_id) {
7600                    Some(DisplayCallKind::Ordinary) => content,
7601                    Some(DisplayCallKind::PrivateBrowser) => "Browser tool output hidden".into(),
7602                    _ => "Tool output hidden".into(),
7603                };
7604                AgentEvent::ToolToken {
7605                    tool_call_id,
7606                    content,
7607                }
7608            }
7609            AgentEvent::ToolComplete {
7610                tool_call_id,
7611                result,
7612            } => {
7613                let result = match self.known_kind(&tool_call_id) {
7614                    Some(DisplayCallKind::Ordinary) => result,
7615                    Some(DisplayCallKind::PrivateBrowser) => {
7616                        bamboo_agent_core::tools::ToolResult::text(
7617                            result.success,
7618                            "Browser tool result hidden",
7619                        )
7620                    }
7621                    _ => bamboo_agent_core::tools::ToolResult::text(
7622                        result.success,
7623                        "Tool result hidden",
7624                    ),
7625                };
7626                AgentEvent::ToolComplete {
7627                    tool_call_id,
7628                    result,
7629                }
7630            }
7631            AgentEvent::ToolError {
7632                tool_call_id,
7633                error,
7634            } => {
7635                let error = match self.known_kind(&tool_call_id) {
7636                    Some(DisplayCallKind::Ordinary) => error,
7637                    Some(DisplayCallKind::PrivateBrowser) => "Browser tool failed".into(),
7638                    _ => "Tool failed".into(),
7639                };
7640                AgentEvent::ToolError {
7641                    tool_call_id,
7642                    error,
7643                }
7644            }
7645            AgentEvent::ToolLifecycle {
7646                tool_call_id,
7647                tool_name,
7648                phase,
7649                elapsed_ms,
7650                is_mutating,
7651                auto_approved,
7652                summary,
7653                error,
7654            } => {
7655                let kind = self.known_kind(&tool_call_id);
7656                let (summary, error) = match kind {
7657                    Some(DisplayCallKind::Ordinary) => (summary, error),
7658                    Some(DisplayCallKind::PrivateBrowser) => (
7659                        summary.map(|_| "Browser tool activity hidden".into()),
7660                        error.map(|_| "Browser tool failed".into()),
7661                    ),
7662                    _ => (
7663                        summary.map(|_| "Tool activity hidden".into()),
7664                        error.map(|_| "Tool failed".into()),
7665                    ),
7666                };
7667                AgentEvent::ToolLifecycle {
7668                    tool_call_id,
7669                    tool_name: match kind {
7670                        Some(DisplayCallKind::Ordinary) => tool_name,
7671                        Some(DisplayCallKind::PrivateBrowser) => "browser".into(),
7672                        _ => "unknown".into(),
7673                    },
7674                    phase: if kind == Some(DisplayCallKind::Ordinary)
7675                        || matches!(phase.as_str(), "begin" | "finished" | "error" | "cancelled")
7676                    {
7677                        phase
7678                    } else {
7679                        "event".into()
7680                    },
7681                    elapsed_ms,
7682                    is_mutating,
7683                    auto_approved,
7684                    summary,
7685                    error,
7686                }
7687            }
7688            AgentEvent::HookLifecycle {
7689                hook_name,
7690                point,
7691                phase,
7692                duration_ms,
7693                decision,
7694            } => {
7695                let Some(decision) = hook_result_for_display(decision, 0) else {
7696                    self.fail_closed();
7697                    return None;
7698                };
7699                let hook_name = self.display_hook_name(&hook_name)?;
7700                let phase = match phase.as_str() {
7701                    "completed" | "started" | "error" => phase,
7702                    _ => "event".into(),
7703                };
7704                AgentEvent::HookLifecycle {
7705                    hook_name,
7706                    point,
7707                    phase,
7708                    duration_ms,
7709                    decision,
7710                }
7711            }
7712            AgentEvent::ChildApprovalRequested {
7713                child_session_id,
7714                request_id,
7715                tool_name,
7716                permission,
7717                resource,
7718            } => {
7719                let (tool_name, permission, resource) = if is_browser_tool_name(&tool_name) {
7720                    (
7721                        "browser".into(),
7722                        "Browser interaction approval".into(),
7723                        "[redacted]".into(),
7724                    )
7725                } else {
7726                    (tool_name, permission, resource)
7727                };
7728                AgentEvent::ChildApprovalRequested {
7729                    child_session_id,
7730                    request_id,
7731                    tool_name,
7732                    permission,
7733                    resource,
7734                }
7735            }
7736            AgentEvent::ChildApprovalChanged { tool_name, .. }
7737                if is_browser_tool_name(&tool_name) =>
7738            {
7739                // This durable approval delta is host-owned. An actor-supplied
7740                // copy is neither authoritative nor safe to mirror to the
7741                // account feed; the host publishes its own record separately.
7742                return None;
7743            }
7744            AgentEvent::SubAgentEvent {
7745                parent_session_id,
7746                child_session_id,
7747                event,
7748            } => {
7749                if depth >= MAX_NESTED_DISPLAY_DEPTH || self.nested_overflowed {
7750                    return None;
7751                }
7752                if parent_session_id.is_empty()
7753                    || child_session_id.is_empty()
7754                    || parent_session_id.len() > MAX_DISPLAY_ID_BYTES
7755                    || child_session_id.len() > MAX_DISPLAY_ID_BYTES
7756                {
7757                    self.fail_closed();
7758                    return None;
7759                }
7760                let key = (parent_session_id.clone(), child_session_id.clone());
7761                if !self.children.contains_key(&key) && !self.reserve_identity() {
7762                    return None;
7763                }
7764                let identities = self.identities.clone();
7765                let child = self.children.entry(key).or_insert_with(|| {
7766                    Box::new(ActorEventDisplay {
7767                        identities,
7768                        ..Default::default()
7769                    })
7770                });
7771                let event = child.project(*event, depth + 1)?;
7772                if self.identities.load(Ordering::Relaxed) > MAX_DISPLAY_CALLS {
7773                    self.fail_closed();
7774                    return None;
7775                }
7776                AgentEvent::SubAgentEvent {
7777                    parent_session_id,
7778                    child_session_id,
7779                    event: Box::new(event),
7780                }
7781            }
7782            other => other,
7783        })
7784    }
7785}
7786
7787#[allow(clippy::too_many_arguments)]
7788async fn process_actor_event(
7789    event: serde_json::Value,
7790    strict_permission_events: bool,
7791    permission_handshake: &mut PermissionPostureHandshake,
7792    expected_permission_posture: Option<&ExpectedPermissionPosture>,
7793    session_inbox_runtime: Option<&SessionInboxRuntimeBinding>,
7794    logical_session: &mut Session,
7795    event_tx: &mpsc::Sender<AgentEvent>,
7796    display: &mut ActorEventDisplay,
7797) -> crate::runtime::runner::Result<()> {
7798    let event = match serde_json::from_value::<AgentEvent>(event) {
7799        Ok(event) => event,
7800        Err(_) if strict_permission_events => {
7801            // Serde errors can quote an unrecognized variant or field value.
7802            // The actor event is untrusted display input, including browser
7803            // download metadata, so never echo its decode details to SSE.
7804            return Err(AgentError::LLM(
7805                "actor emitted malformed AgentEvent under a typed permission posture contract"
7806                    .to_string(),
7807            ));
7808        }
7809        Err(_) => {
7810            // A legacy actor can omit a malformed ToolStart that reuses an
7811            // earlier public call ID. Its later result must not inherit the
7812            // stale public display classification.
7813            display.fail_closed();
7814            return Ok(());
7815        }
7816    };
7817    process_typed_actor_event(
7818        event,
7819        permission_handshake,
7820        expected_permission_posture,
7821        session_inbox_runtime,
7822        logical_session,
7823        event_tx,
7824        display,
7825    )
7826    .await
7827}
7828
7829#[allow(clippy::too_many_arguments)]
7830async fn process_typed_actor_event(
7831    event: AgentEvent,
7832    permission_handshake: &mut PermissionPostureHandshake,
7833    expected_permission_posture: Option<&ExpectedPermissionPosture>,
7834    session_inbox_runtime: Option<&SessionInboxRuntimeBinding>,
7835    logical_session: &mut Session,
7836    event_tx: &mpsc::Sender<AgentEvent>,
7837    display: &mut ActorEventDisplay,
7838) -> crate::runtime::runner::Result<()> {
7839    if matches!(&event, AgentEvent::PermissionPostureActivated { .. }) {
7840        if permission_handshake.posture_was_confirmed() {
7841            return Err(AgentError::LLM(
7842                "actor emitted a duplicate permission posture activation".to_string(),
7843            ));
7844        }
7845        let seed = permission_posture_seed_from_event(logical_session, &event)
7846            .map_err(AgentError::LLM)?
7847            .ok_or_else(|| {
7848                AgentError::LLM(
7849                    "actor permission posture event did not decode as a posture".to_string(),
7850                )
7851            })?;
7852        if let Some(expected) = expected_permission_posture {
7853            if seed.policy_revision != expected.policy_revision
7854                || seed.resolution != expected.resolution
7855            {
7856                return Err(AgentError::LLM(
7857                    "permission posture event does not match the host-dispatched policy"
7858                        .to_string(),
7859                ));
7860            }
7861            if seed.executor_mapping() != expected.executor_mapping {
7862                return Err(AgentError::LLM(
7863                    "permission posture event does not match the host-dispatched executor mapping"
7864                        .to_string(),
7865                ));
7866            }
7867        }
7868        if let Some(binding) = session_inbox_runtime {
7869            let saved = binding
7870                .persistence
7871                .record_permission_posture_activation(
7872                    &logical_session.id,
7873                    expected_permission_posture
7874                        .and_then(|expected| expected.expected_audit_revision),
7875                    &seed,
7876                )
7877                .await
7878                .map_err(|error| {
7879                    AgentError::LLM(format!(
7880                        "persist child permission posture bootstrap: {error}"
7881                    ))
7882                })?
7883                .ok_or_else(|| {
7884                    AgentError::LLM(
7885                        "persist child permission posture bootstrap: session not found".to_string(),
7886                    )
7887                })?;
7888            let snapshot = bamboo_domain::PermissionAuditSnapshot::from_metadata(&saved.metadata)
7889                .ok_or_else(|| {
7890                AgentError::LLM(
7891                    "persisted child permission posture audit is incomplete".to_string(),
7892                )
7893            })?;
7894            snapshot.write_to(&mut logical_session.metadata);
7895        } else {
7896            // In-memory/custom actor embeddings still use a host-owned clock.
7897            // Durable server paths always take the atomic branch.
7898            bamboo_domain::record_permission_audit(&mut logical_session.metadata, &seed, None)
7899                .map_err(|error| {
7900                    AgentError::LLM(format!(
7901                        "record in-memory child permission posture: {error}"
7902                    ))
7903                })?;
7904        }
7905        // Confirmation is deliberately last: matching alone is not enough.
7906        // The host-owned audit write must succeed first.
7907        *permission_handshake = PermissionPostureHandshake::Confirmed;
7908    } else if permission_handshake.is_awaiting() {
7909        return Err(AgentError::LLM(
7910            "actor emitted an execution event before permission posture confirmation".to_string(),
7911        ));
7912    }
7913    if let Some(event) = display.project(event, 0) {
7914        let _ = event_tx.send(event).await;
7915    }
7916    Ok(())
7917}
7918
7919/// Pump child frames -> parent events until a terminal frame (or cancellation).
7920/// On success, yields the actor's final result text (for session write-back).
7921/// `live_rx` carries in-band frames (steering messages) from the live registry.
7922///
7923/// `escalation_bridge` (#68) is the per-run escalation host bridge CAPTURED BY
7924/// VALUE at spawn time in `execute_external_child` (NOT read live here): when a
7925/// non-bypass child re-proxies an approval request, this owned bridge routes it
7926/// UP to the parent run. Owning it for the call's lifetime is what lets a
7927/// fire-and-forget grandchild that outlives its spawning run still escalate to
7928/// the correct (then-current) parent bridge rather than a stale/overwritten one.
7929async fn drive(context: ActorDriveContext<'_>) -> crate::runtime::runner::Result<Option<String>> {
7930    let ActorDriveContext {
7931        client,
7932        parent_session_id,
7933        child_session_id,
7934        child_attempt,
7935        approval_registry,
7936        approval_decider,
7937        approval_reviewer,
7938        escalation_bridge,
7939        event_tx,
7940        cancel_token,
7941        live_rx,
7942        delivery_rx,
7943        logical_session,
7944        mut expected_permission_posture,
7945        expected_creation,
7946        session_inbox_runtime,
7947        actor_directory_store,
7948        canonical_subagent_tool,
7949        ticket_service,
7950        activation_run_id,
7951        execution_epoch,
7952        expected_source_actor_id,
7953        initial_inflight_claims,
7954        first_frame_timeout,
7955        plain_actor,
7956        remote_environment_lease,
7957        mut readonly_output,
7958        local_history_tools,
7959        local_history_read_only,
7960        mut strict_history_output,
7961        plain_input,
7962        canonical_activation,
7963        canonical_placement_ref,
7964        actor_event_observer,
7965        plain_run,
7966    } = context;
7967
7968    // A projection belongs only to this Host-validated Run. A loaded Session
7969    // or a prior attempt may carry a stale marker; only the canonical wait
7970    // checks in the terminal branch below may set it again.
7971    logical_session
7972        .metadata
7973        .remove("runtime.actor_nested_wait_handoff");
7974
7975    // First-frame watchdog: a live worker emits its first frame (run-started /
7976    // first token) within seconds; total silence past the deadline means the
7977    // worker is dead (e.g. a pooled worker that exited right after checkout), so
7978    // its Run sits queued forever. We trip ONLY before the first frame — once any
7979    // frame arrives the worker is proven live and a legitimately long run (a slow
7980    // tool between tokens) never trips it.
7981    let mut got_first_frame = false;
7982    let mut first_frame_watch = first_frame_timeout.map(|d| Box::pin(tokio::time::sleep(d)));
7983    let mut inflight_claims = initial_inflight_claims;
7984    let mut owned_inputs = plain_input
7985        .map(|activation| VecDeque::from(activation.initial_inputs.clone()))
7986        .unwrap_or_default();
7987    // A retry/recovery or completed initial batch consumes its cold allowance;
7988    // only a fresh activation can continue twice. No lease/watchdog restarts.
7989    let continuation_limit = if owned_inputs.is_empty() { 2 } else { 1 };
7990    let mut continuations = usize::from(!owned_inputs.is_empty());
7991    let mut released_inputs: Vec<(
7992        SessionInboxOwnedClaim,
7993        bamboo_subagent::proto::InitialInputRelease,
7994    )> = Vec::new();
7995    let mut current_epoch = execution_epoch;
7996    let approval_epoch = Arc::new(AtomicU64::new(execution_epoch));
7997    let approval_cancel = cancel_token.child_token();
7998    let _approval_lifetime = approval_cancel.clone().drop_guard();
7999    let mut approval_scopes: HashMap<String, (serde_json::Value, ChildApprovalScope)> =
8000        HashMap::new();
8001    let mut owned_tree_requests = 0usize;
8002    let mut canonical_subagent_requests = 0usize;
8003    let mut committed_plan_calls = HashMap::new();
8004    // Once this Run registers a canonical Child wait, a fast completion may
8005    // clear it before the Worker sends Terminal. Keep the current Run
8006    // nonterminal so its already admitted outcome belongs to the successor.
8007    let mut nested_wait_registered_this_run = false;
8008    let mut parent_question_checkpoint_id: Option<String> = None;
8009    let strict_permission_events = expected_permission_posture.is_some();
8010    let mut permission_handshake =
8011        PermissionPostureHandshake::new(expected_permission_posture.as_ref());
8012    let mut next_actor_event_seq = 1u64;
8013    let mut canonical_router: Option<ActorEventRouter> = None;
8014    let canonical_fence = plain_input
8015        .map(|activation| &activation.fence)
8016        .or(canonical_activation);
8017    let canonical_store = plain_input
8018        .map(|activation| activation.store.as_ref())
8019        .or(actor_directory_store);
8020    let remote_claim_fence = actor_directory_store.zip(canonical_activation);
8021    let workflow_usage_requested = logical_session
8022        .metadata
8023        .get(bamboo_subagent::proto::WORKFLOW_USAGE_REQUESTED_KEY)
8024        .is_some_and(|v| v == "true");
8025    if workflow_usage_requested {
8026        logical_session
8027            .metadata
8028            .remove(bamboo_subagent::proto::WORKFLOW_USAGE_OBSERVATION_KEY);
8029        logical_session
8030            .metadata
8031            .remove(bamboo_subagent::proto::WORKFLOW_TERMINAL_OBSERVATION_KEY);
8032    }
8033    let mut remote_cancel_deadline: Option<tokio::time::Instant> = None;
8034    let mut display = ActorEventDisplay::default();
8035    let mut readonly = readonly_output
8036        .as_ref()
8037        .map(|_| ReadOnlyActorCollector::default());
8038    let mut local_history = local_history_tools.map(|_| LocalToolCollector::default());
8039    loop {
8040        tokio::select! {
8041            _ = cancel_token.cancelled(), if remote_cancel_deadline.is_none() => {
8042                if remote_claim_fence.is_some() || workflow_usage_requested {
8043                    // A remote Worker may finish after its subscriber cancels.
8044                    // Keep the same correlated frame pump alive briefly so an
8045                    // exact Cancelled Outcome can terminalize the Actor fence.
8046                    // Without that proof the activation stays live until its
8047                    // lease expires and a retry must remain blocked.
8048                    let _ = client.send(ParentFrame::Cancel).await;
8049                    remote_cancel_deadline = Some(
8050                        tokio::time::Instant::now() + Duration::from_secs(10)
8051                    );
8052                    continue;
8053                }
8054                // Direct/local links retain their existing cancel behavior.
8055                break;
8056            }
8057            _ = async {
8058                if let Some(deadline) = remote_cancel_deadline {
8059                    tokio::time::sleep_until(deadline).await;
8060                } else {
8061                    std::future::pending::<()>().await;
8062                }
8063            } => {
8064                return Err(AgentError::Cancelled);
8065            }
8066            _ = async {
8067                match first_frame_watch.as_mut() {
8068                    Some(s) => s.as_mut().await,
8069                    None => std::future::pending::<()>().await,
8070                }
8071            }, if !got_first_frame => {
8072                return Err(AgentError::WorkerUnresponsive(format!(
8073                    "child {child_session_id} produced no frame within {:?}",
8074                    first_frame_timeout.unwrap_or_default()
8075                )));
8076            }
8077            Some(_generation) = delivery_rx.recv(),
8078                if session_inbox_runtime.is_some() && activation_run_id.is_some() =>
8079            {
8080                if plain_input.is_some() {
8081                    // Keep the real Inbox pending until this Run's completed
8082                    // reply can be committed BEFORE its correction User.
8083                    continue;
8084                }
8085                forward_next_canonical_claim(
8086                    client,
8087                    session_inbox_runtime.expect("guarded"),
8088                    logical_session,
8089                    activation_run_id.expect("guarded"),
8090                    &mut inflight_claims,
8091                    remote_claim_fence,
8092                )
8093                .await?;
8094            }
8095            Some(frame) = live_rx.recv() => {
8096                if plain_actor { return Err(plain_actor_unsupported()); }
8097                // Forward in-band steering to the worker over the existing WS.
8098                if client.send(frame).await.is_err() {
8099                    tracing::warn!("live steering frame could not be sent; connection failing");
8100                }
8101            }
8102            frame = client.next_frame() => {
8103                // Any frame (event / approval / terminal / close / error) proves
8104                // the worker responded — disarm the first-frame watchdog.
8105                got_first_frame = true;
8106                first_frame_watch = None;
8107                match frame {
8108                    Ok(Some(ChildFrame::Event { event })) => {
8109                        if canonical_activation.is_some() {
8110                            return Err(AgentError::LLM("remote_actor_event_batch_required".into()));
8111                        }
8112                        if local_history.is_some() {
8113                            return Err(local_tool_history_unsupported());
8114                        }
8115                        if event.get("initial_input_control").is_some() {
8116                            let bamboo_subagent::proto::InitialInputControl::Request { request } =
8117                                bamboo_subagent::proto::InitialInputControl::decode(event).map_err(|_| plain_actor_unsupported())?
8118                            else { return Err(plain_actor_unsupported()); };
8119                            if !permission_handshake.posture_was_confirmed() || cancel_token.is_cancelled() { return Err(plain_actor_unsupported()); }
8120                            let activation = plain_input.ok_or_else(|| plain_actor_unsupported())?;
8121                            let binding = session_inbox_runtime.ok_or_else(|| plain_actor_unsupported())?;
8122                            let (run, _) = plain_run.ok_or_else(|| plain_actor_unsupported())?;
8123                            // Exact retransmission may refer to an earlier
8124                            // release while the next input is still waiting.
8125                            // Every new request must match the ordered front.
8126                            let previous = released_inputs.iter()
8127                                .find(|(_, release)| release.request == request);
8128                            let (claim, previous) = match previous {
8129                                Some((claim, release)) => (claim.clone(), Some(release.clone())),
8130                                None => (owned_inputs.front().cloned()
8131                                    .ok_or_else(|| plain_actor_unsupported())?, None),
8132                            };
8133                            let release = activation.release_initial_input(binding, logical_session, run, current_epoch, &claim,
8134                                &request, &mut expected_permission_posture, previous.as_ref()).await?;
8135                            if cancel_token.is_cancelled() { return Err(AgentError::Cancelled); }
8136                            client.send(ParentFrame::InitialInputRelease { release: release.clone() }).await
8137                                .map_err(|_| AgentError::LLM("Actor initial release unconfirmed; durable input is preserved".into()))?;
8138                            if previous.is_none() {
8139                                let _ = owned_inputs.pop_front();
8140                                released_inputs.push((claim, release));
8141                            }
8142                            continue;
8143                        }
8144                        if expected_creation.is_some() {
8145                            return Err(AgentError::LLM("worker omitted required Child creation event identity".into()));
8146                        }
8147                        if event["type"] == bamboo_subagent::proto::LocalToolMessages::TYPE { continue; }
8148                        if event["type"] == "owned_readonly_transcript" {
8149                            if plain_actor { return Err(plain_actor_unsupported()); }
8150                            continue;
8151                        }
8152                        if plain_actor && !plain_actor_event(&event)? { continue; }
8153                        if let Some(activation) = plain_input {
8154                            validate_directory_actor_event(
8155                                activation.store.as_ref(),
8156                                &activation.fence,
8157                                logical_session,
8158                                None,
8159                            ).await?;
8160                        }
8161                        // Rolling-upgrade compatibility: old actors have no
8162                        // route/sequence metadata, but retain the same typed
8163                        // permission handshake and event validation.
8164                        process_actor_event(
8165                            event,
8166                            strict_permission_events,
8167                            &mut permission_handshake,
8168                            expected_permission_posture.as_ref(),
8169                            session_inbox_runtime,
8170                            logical_session,
8171                            event_tx,
8172                            &mut display,
8173                        )
8174                        .await?;
8175                    }
8176                    Ok(Some(ChildFrame::EventBatch { batch })) => {
8177                        validate_actor_event_batch(
8178                            &batch,
8179                            logical_session,
8180                            parent_session_id,
8181                            activation_run_id,
8182                            current_epoch,
8183                            expected_source_actor_id,
8184                            expected_creation,
8185                        )?;
8186                        let directory_entry = if let (Some(store), Some(fence)) = (canonical_store, canonical_fence) {
8187                            let entry = validate_directory_actor_event(
8188                                store, fence, logical_session, batch.activation_id.as_deref(),
8189                            ).await?;
8190                            if canonical_placement_ref.is_some_and(|expected| {
8191                                entry.activation.as_ref().and_then(|activation| activation.placement_ref.as_ref()) != Some(expected)
8192                            }) {
8193                                return Err(AgentError::LLM("remote_actor_placement_ref_changed".into()));
8194                            }
8195                            Some(entry)
8196                        } else { None };
8197                        if let (Some(entry), Some(fence)) = (&directory_entry, canonical_fence) {
8198                            if canonical_router.is_none() {
8199                                canonical_router = Some(
8200                                    ActorEventRouter::new(
8201                                        entry,
8202                                        fence,
8203                                        current_epoch,
8204                                        strict_permission_events && permission_handshake.is_awaiting(),
8205                                    )
8206                                    .map_err(actor_event_route_error)?,
8207                                );
8208                            }
8209                        }
8210                        if local_history_tools.is_some()
8211                            && (batch.first_seq != next_actor_event_seq
8212                                || batch.qos != bamboo_subagent::ActorEventQos::Durable
8213                                || batch.last_seq == u64::MAX) {
8214                            return Err(local_tool_history_unsupported());
8215                        }
8216                        if batch.last_seq < next_actor_event_seq {
8217                            continue;
8218                        }
8219                        if batch.first_seq > next_actor_event_seq {
8220                            if plain_actor {
8221                                return Err(AgentError::LLM(
8222                                    "actor event sequence gap; authoritative session snapshot is required".into(),
8223                                ));
8224                            }
8225                            tracing::warn!(
8226                                child_session_id,
8227                                expected_seq = next_actor_event_seq,
8228                                received_seq = batch.first_seq,
8229                                "actor event sequence gap; authoritative session snapshot is required"
8230                            );
8231                            // A missing ToolStart may have reused an earlier
8232                            // call ID for a private browser download. No prior
8233                            // identity remains safe for display after a gap.
8234                            display.fail_closed();
8235                        }
8236                        let skip = next_actor_event_seq
8237                            .saturating_sub(batch.first_seq)
8238                            .min(batch.events.len() as u64) as usize;
8239                        for (offset, event) in batch.events.into_iter().enumerate().skip(skip) {
8240                            let seq = batch.first_seq + offset as u64;
8241                            let mut publish = true;
8242                            if event["type"] == bamboo_subagent::proto::WorkflowAgentUsage::TYPE {
8243                                if workflow_usage_requested {
8244                                    if permission_handshake.is_awaiting() { return Err(plain_actor_unsupported()); }
8245                                    let usage: bamboo_subagent::proto::WorkflowAgentUsage = serde_json::from_value(event.clone()).map_err(|_| plain_actor_unsupported())?;
8246                                    let activation = activation_run_id.ok_or_else(plain_actor_unsupported)?;
8247                                    if !usage.matches(logical_session, activation) { return Err(plain_actor_unsupported()); }
8248                                    if logical_session.metadata.contains_key(bamboo_subagent::proto::WORKFLOW_USAGE_OBSERVATION_KEY) { return Err(plain_actor_unsupported()); }
8249                                    logical_session.metadata.insert(bamboo_subagent::proto::WORKFLOW_USAGE_OBSERVATION_KEY.into(), serde_json::to_string(&usage).map_err(|_| plain_actor_unsupported())?);
8250                                }
8251                                publish = false;
8252                            }
8253                            if publish { if let Some(collector) = local_history.as_mut() {
8254                                if permission_handshake.is_awaiting() && event["type"] != "permission_posture_activated" {
8255                                    return Err(local_tool_history_unsupported());
8256                                }
8257                                if logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY) {
8258                                    publish = collector.ticket_event(&event, &committed_plan_calls)?;
8259                                }
8260                                publish &= collector.event(&event)?;
8261                            } else if event["type"] == bamboo_subagent::proto::LocalToolMessages::TYPE {
8262                                // Completion DATA is private and grants nothing on an unselected route.
8263                                publish = false;
8264                            }}
8265                            if publish { publish = if let Some(collector) = readonly.as_mut() {
8266                                if permission_handshake.is_awaiting()
8267                                    && event["type"] != "permission_posture_activated" {
8268                                    return Err(plain_actor_unsupported());
8269                                }
8270                                collector.event(&event)?
8271                            } else if event["type"] == "owned_readonly_transcript" {
8272                                if plain_actor { return Err(plain_actor_unsupported()); }
8273                                false // A worker cache observation is not a legacy Host commit.
8274                            } else if plain_actor { plain_actor_event(&event)? } else { true };
8275                            }
8276                            if let (Some(router), Some(entry), Some(fence)) =
8277                                (canonical_router.as_mut(), directory_entry.as_ref(), canonical_fence)
8278                            {
8279                                let typed = if publish {
8280                                    Some(serde_json::from_value::<AgentEvent>(event.clone())
8281                                        .map_err(|_| plain_actor_unsupported())?)
8282                                } else {
8283                                    None
8284                                };
8285                                match router.route(entry, fence, current_epoch, seq, typed)
8286                                    .map_err(actor_event_route_error)?
8287                                {
8288                                    ActorEventRoute::Publish(envelope) => {
8289                                        let public_event = actor_event_observer
8290                                            .filter(|_| envelope.class != ActorEventClass::Ephemeral)
8291                                            .map(|_| PublicActorEvent::from(envelope.as_ref()));
8292                                        tracing::trace!(
8293                                            actor_id = %envelope.actor_id,
8294                                            event_id = %envelope.event_id,
8295                                            class = ?envelope.class,
8296                                            "publishing canonical Actor event"
8297                                        );
8298                                        process_typed_actor_event(
8299                                            envelope.payload,
8300                                            &mut permission_handshake,
8301                                            expected_permission_posture.as_ref(),
8302                                            session_inbox_runtime,
8303                                            logical_session,
8304                                            event_tx,
8305                                            &mut display,
8306                                        ).await?;
8307                                        if let (Some(observer), Some(public_event)) =
8308                                            (actor_event_observer, public_event)
8309                                        {
8310                                            observer.publish(public_event);
8311                                        }
8312                                    }
8313                                    ActorEventRoute::Duplicate | ActorEventRoute::Suppressed => {}
8314                                }
8315                                next_actor_event_seq = seq.saturating_add(1);
8316                                continue;
8317                            }
8318                            if !publish {
8319                                next_actor_event_seq = seq.saturating_add(1);
8320                                continue;
8321                            }
8322                            process_actor_event(
8323                                event,
8324                                strict_permission_events,
8325                                &mut permission_handshake,
8326                                expected_permission_posture.as_ref(),
8327                                session_inbox_runtime,
8328                                logical_session,
8329                                event_tx,
8330                                &mut display,
8331                            )
8332                            .await?;
8333                            next_actor_event_seq = seq.saturating_add(1);
8334                        }
8335                    }
8336                    Ok(Some(ChildFrame::OwnedTreeRequest { id, cursor })) => {
8337                        owned_tree_requests += 1;
8338                        if owned_tree_requests > 16 {
8339                            return Err(AgentError::LLM("owned tree request limit exceeded".into()));
8340                        }
8341                        let page = if id.len() <= 128
8342                            && !logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
8343                            && cursor.as_ref().is_none_or(|value| !value.is_empty() && value.len() <= 128)
8344                            && !cancel_token.is_cancelled()
8345                        {
8346                            match (actor_directory_store, session_inbox_runtime,
8347                                activation_run_id, expected_creation) {
8348                                (Some(store), Some(binding), Some(run_id), Some(creation)) => {
8349                                    tokio::time::timeout(
8350                                        Duration::from_secs(8),
8351                                        inspect_active_owned_tree(
8352                                            store, binding, run_id, child_session_id,
8353                                            parent_session_id, logical_session, creation,
8354                                            plain_input, cursor.as_deref(),
8355                                        ),
8356                                    ).await.ok().flatten()
8357                                }
8358                                _ => {
8359                                    tracing::warn!("owned tree denied: canonical Host scope unavailable");
8360                                    None
8361                                },
8362                            }
8363                        } else {
8364                            tracing::warn!("owned tree denied: request bounds or cancellation");
8365                            None
8366                        };
8367                        if cancel_token.is_cancelled() {
8368                            return Err(AgentError::Cancelled);
8369                        }
8370                        client.send(ParentFrame::OwnedTreeReply { id, page }).await
8371                            .map_err(|_| AgentError::LLM("owned tree reply transport closed".into()))?;
8372                    }
8373                    Ok(Some(ChildFrame::SubAgentRequest { id, tool_call_id, args })) => {
8374                        canonical_subagent_requests += 1;
8375                        if canonical_subagent_requests > 64 {
8376                            return Err(AgentError::LLM("canonical SubAgent request limit exceeded".into()));
8377                        }
8378                        let valid_shape = !id.is_empty() && id.len() <= 128
8379                            && !tool_call_id.is_empty() && tool_call_id.len() <= 128
8380                            && args.is_object()
8381                            && serde_json::to_vec(&args)
8382                                .is_ok_and(|bytes| bytes.len() <= 64 * 1024);
8383                        let registers_wait = matches!(args.get("action").and_then(|value| value.as_str()),
8384                            Some("wait"))
8385                            || (args.get("action").and_then(|value| value.as_str()) == Some("create")
8386                                && args.get("wait").and_then(|value| value.as_bool()) == Some(true));
8387                        let checkpoint_payload = args.get(PARENT_QUESTION_CHECKPOINT_ACTION).cloned();
8388                        let ticket_bootstrap_pending = valid_shape
8389                            && logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
8390                            && args == serde_json::json!({(crate::ticket_worker_plan::TICKET_PLAN_ACTION): {"read":true}})
8391                            && permission_handshake.is_awaiting()
8392                            && !cancel_token.is_cancelled();
8393                        let result = if ticket_bootstrap_pending {
8394                            // A read control can overtake the queued posture
8395                            // event on the separate lanes. Keep the handshake
8396                            // fence; the exact bootstrap read may retry briefly.
8397                            serde_json::json!({"error":crate::ticket_worker_plan::remote::TICKET_BOOTSTRAP_POSTURE_PENDING})
8398                        } else if valid_shape
8399                            && (!logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
8400                                || args.get(crate::ticket_worker_plan::TICKET_PLAN_ACTION).is_some()
8401                                || args.get(crate::ticket_worker_plan::files::TICKET_FILE_ACTION).is_some())
8402                            && !cancel_token.is_cancelled()
8403                            && !permission_handshake.is_awaiting()
8404                        {
8405                            if args.get(crate::ticket_worker_plan::TICKET_PLAN_ACTION).is_some() {
8406                                match (ticket_service.as_deref(), actor_directory_store,
8407                                    session_inbox_runtime, activation_run_id, expected_creation) {
8408                                    (Some(service), Some(store), Some(binding), Some(run_id), Some(creation)) => {
8409                                        if let Some(caller) = load_active_subagent_caller(
8410                                            store, binding, run_id, child_session_id,
8411                                            parent_session_id, logical_session, creation, plain_input,
8412                                        ).await {
8413                                            match crate::ticket_worker_plan::remote::apply_host_plan_request(service, &caller, run_id, &args, &tool_call_id) {
8414                                                Ok(projection) => {
8415                                                    logical_session.task_list = projection.task_list.clone();
8416                                                    logical_session.set_task_list_version_meta(projection.plan_revision.to_string());
8417                                                    if let Some(question) = &projection.question {
8418                                                        let raw = serde_json::to_string(question).map_err(|_| local_tool_history_unsupported())?;
8419                                                        logical_session.metadata.insert(crate::ticket_worker_plan::TICKET_QUESTION_YIELD_KEY.into(), raw);
8420                                                        if let Some(collector) = local_history.as_mut() {
8421                                                            collector.ticket_yield = Some((tool_call_id.clone(), question.clone()));
8422                                                        }
8423                                                    }
8424                                                    if let Some(task) = args[crate::ticket_worker_plan::TICKET_PLAN_ACTION].get("task") {
8425                                                        committed_plan_calls.insert(tool_call_id.clone(), task.clone());
8426                                                        if let Some(task_list) = projection.task_list.clone() {
8427                                                            let _ = event_tx.send(AgentEvent::TaskListUpdated {
8428                                                                task_list,
8429                                                                version: Some(projection.plan_revision),
8430                                                            }).await;
8431                                                        }
8432                                                    }
8433                                                    serde_json::json!({"result":projection})
8434                                                }
8435                                                Err(error) => serde_json::json!({"error":error.to_string()}),
8436                                            }
8437                                        } else { serde_json::json!({"error":"LocalPlan Child Run is no longer current"}) }
8438                                    }
8439                                    _ => serde_json::json!({"error":"LocalPlan Host authority unavailable"}),
8440                                }
8441                            } else if args.get(crate::ticket_worker_plan::files::TICKET_FILE_ACTION).is_some() {
8442                                match (ticket_service.as_deref(), actor_directory_store, session_inbox_runtime,
8443                                    activation_run_id, expected_creation, local_history_tools) {
8444                                    (Some(service),Some(store),Some(binding),Some(run_id),Some(creation),Some(ceiling)) => {
8445                                        if let Some(caller)=load_active_subagent_caller(store,binding,run_id,child_session_id,parent_session_id,logical_session,creation,plain_input).await {
8446                                            match crate::ticket_worker_plan::files::apply_host_file_request(service,&caller,run_id,&args,&tool_call_id,ceiling) {
8447                                                Ok(reply)=>{
8448                                                    let body=&args[crate::ticket_worker_plan::files::TICKET_FILE_ACTION];
8449                                                    committed_plan_calls.insert(tool_call_id.clone(),serde_json::json!({"file_tool":body["tool"],"arguments":body["arguments"],"result":serde_json::to_string(&reply).map_err(|_|local_tool_history_unsupported())?}));
8450                                                    serde_json::json!({"result":reply})
8451                                                },
8452                                                Err(error)=>serde_json::json!({"error":error.to_string()}),
8453                                            }
8454                                        } else { serde_json::json!({"error":"Ticket file Child Run is no longer current"}) }
8455                                    },
8456                                    _=>serde_json::json!({"error":"Ticket file Host authority unavailable"}),
8457                                }
8458                            } else if let Some(payload) = checkpoint_payload {
8459                                let parsed = (args.as_object().is_some_and(|object| object.len() == 1))
8460                                    .then(|| serde_json::from_value::<ParentQuestionCheckpointV1>(payload).ok())
8461                                    .flatten()
8462                                    .filter(|observation| observation.tool_call_id == tool_call_id);
8463                                match (parsed, actor_directory_store, session_inbox_runtime,
8464                                    activation_run_id, expected_creation) {
8465                                    (Some(observation), Some(store), Some(binding), Some(run_id), Some(creation)) => {
8466                                        let receipt = tokio::time::timeout(Duration::from_secs(25),
8467                                            checkpoint_active_parent_question(
8468                                                store, binding, run_id, child_session_id,
8469                                                parent_session_id, logical_session, creation,
8470                                                plain_input, observation,
8471                                            )).await.ok().flatten();
8472                                        if let Some(request_id) = receipt {
8473                                            parent_question_checkpoint_id = Some(request_id.clone());
8474                                            serde_json::json!({"result":{"request_id":request_id}})
8475                                        } else {
8476                                            serde_json::json!({"error":"canonical Child question checkpoint rejected; persisted worker question is preserved"})
8477                                        }
8478                                    }
8479                                    _ => serde_json::json!({"error":"canonical Child question checkpoint unavailable"}),
8480                                }
8481                            } else {
8482                            match (canonical_subagent_tool.as_deref(), actor_directory_store,
8483                                session_inbox_runtime, activation_run_id, expected_creation) {
8484                                (Some(tool), Some(store), Some(binding), Some(run_id), Some(creation)) => {
8485                                    if let Some(caller) = load_active_subagent_caller(
8486                                        store, binding, run_id, child_session_id,
8487                                        parent_session_id, logical_session, creation,
8488                                        plain_input,
8489                                    ).await {
8490                                        tokio::time::timeout(
8491                                            Duration::from_secs(30),
8492                                            invoke_active_subagent(tool, &caller, args, &tool_call_id),
8493                                        ).await.unwrap_or_else(|_| serde_json::json!({
8494                                            "error":"canonical SubAgent operation timed out; inspect logical child state before retrying"
8495                                        }))
8496                                    } else {
8497                                        serde_json::json!({"error":"canonical SubAgent caller is no longer active"})
8498                                    }
8499                                }
8500                                _ => serde_json::json!({"error":"canonical SubAgent Host authority unavailable"}),
8501                            }
8502                            }
8503                        } else {
8504                            serde_json::json!({"error":"canonical SubAgent request rejected"})
8505                        };
8506                        if registers_wait
8507                            && result["result"]["success"] == true
8508                            && result["result"]["result"]
8509                                .as_str()
8510                                .and_then(|value| serde_json::from_str::<serde_json::Value>(value).ok())
8511                                .is_some_and(|value| value["status"] == "waiting")
8512                        {
8513                            nested_wait_registered_this_run = true;
8514                        }
8515                        if cancel_token.is_cancelled() {
8516                            return Err(AgentError::Cancelled);
8517                        }
8518                        client.send(ParentFrame::SubAgentReply { id, result }).await
8519                            .map_err(|_| AgentError::LLM("canonical SubAgent reply transport closed".into()))?;
8520                    }
8521                    Ok(Some(ChildFrame::ApprovalRequest { id, body })) => {
8522                        if logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY) {
8523                            return Err(AgentError::LLM("Ticket Task-only Worker cannot request unrelated action authority".into()));
8524                        }
8525                        if plain_actor { return Err(plain_actor_unsupported()); }
8526                        if permission_handshake.is_awaiting() {
8527                            return Err(AgentError::LLM(
8528                                "actor requested approval before permission posture confirmation"
8529                                    .to_string(),
8530                            ));
8531                        }
8532                        // Phase 2: a worker proxied a gated-tool approval back to
8533                        // the host. The WORKER side is live — its executor installs
8534                        // a per-run task-local `ApprovalProxy` (subagent_worker.rs)
8535                        // that calls `host.approval_call`, so this frame arrives
8536                        // when a child hits `ConfirmationRequired`.
8537                        if let Some(reviewer) = approval_reviewer
8538                            .cloned()
8539                            .or_else(child_approval_reviewer)
8540                        {
8541                            // Phase 6, Part B: a BYPASSED parent worker
8542                            // model-reviews its children's forced-ask (dangerous)
8543                            // actions. The review is an LLM call, so run it OFF
8544                            // the frame pump in a spawned task and deliver the
8545                            // verdict async via the live channel — the pump keeps
8546                            // forwarding events and the agent loop never blocks. A
8547                            // timeout denies a hung review so the child can't hang.
8548                            let child = child_session_id.to_string();
8549                            let parent = parent_session_id.to_string();
8550                            let req_id = id.clone();
8551                            let body = body.clone();
8552                            let registry = approval_registry.cloned();
8553                            let Some((binding, run)) = session_inbox_runtime.zip(activation_run_id) else {
8554                                // Legacy/custom reviewers retain their original boolean seam;
8555                                // the canonical server reviewer denies without a live scope.
8556                                tokio::spawn(async move {
8557                                    let approved = tokio::time::timeout(CHILD_APPROVAL_TIMEOUT,
8558                                        reviewer.review(&parent, &child, &body)).await.unwrap_or(false);
8559                                    super::live::deliver_approval_scoped(registry.as_ref(), &child,
8560                                        child_attempt, &req_id, approved);
8561                                });
8562                                continue;
8563                            };
8564                            let generation = body.pointer("/permission_request/request_generation")
8565                                .and_then(serde_json::Value::as_str).unwrap_or("").to_string();
8566                            let scope = if let Some((original, scope)) = approval_scopes.get(&generation) {
8567                                if original != &body || scope.stamp()["reply"] != req_id {
8568                                    return Err(AgentError::LLM("approval generation changed in live scope".into()));
8569                                }
8570                                scope.clone()
8571                            } else {
8572                                if approval_scopes.len() >= 64 {
8573                                    return Err(AgentError::LLM("approval live scope limit exceeded".into()));
8574                                }
8575                                let scope = ChildApprovalScope::new(&parent, &child,
8576                                    (child_attempt, run, current_epoch, &req_id, chrono::Utc::now() + chrono::Duration::seconds(240)), binding.router.clone(),
8577                                    approval_cancel.clone(), approval_epoch.clone());
8578                                approval_scopes.insert(generation, (body.clone(), scope.clone()));
8579                                scope
8580                            };
8581                            tokio::spawn(async move {
8582                                let result = tokio::time::timeout(
8583                                    CHILD_APPROVAL_TIMEOUT,
8584                                    reviewer.review_scoped(&parent, &child, &body, &scope),
8585                                ).await.unwrap_or(ChildApprovalReview::Reply(false));
8586                                if let ChildApprovalReview::Reply(approved) = result {
8587                                    if scope.is_current(&parent, &child).await {
8588                                        super::live::deliver_approval_scoped(registry.as_ref(), &child,
8589                                            child_attempt, &req_id, approved && chrono::Utc::now() < scope.deadline());
8590                                    }
8591                                }
8592                            });
8593                        } else if approval_decider.is_some() {
8594                            // A decider is wired (policy / auto): decide promptly
8595                            // and reply inline. (Must not block the pump — see the
8596                            // `ChildApprovalDecider` doc.)
8597                            let approved =
8598                                decide_child_approval(approval_decider, child_session_id, &body)
8599                                    .await;
8600                            if client
8601                                .send(ParentFrame::ApprovalReply { id, approved })
8602                                .await
8603                                .is_err()
8604                            {
8605                                tracing::warn!(
8606                                    "failed to answer approval_request; connection failing"
8607                                );
8608                            }
8609                        } else if let Some(host) = escalation_bridge.clone() {
8610                            // Non-bypass WORKER: ESCALATE up our own actor link
8611                            // (re-proxy) so the request chains to our parent — and
8612                            // up every level until a bypass level or the top
8613                            // orchestrator's model reviewer decides. With no such
8614                            // reviewer the top level fails closed. Off-loop so the
8615                            // pump never blocks; relay the reply down to the child.
8616                            let child = child_session_id.to_string();
8617                            let req_id = id.clone();
8618                            let body = body.clone();
8619                            let registry = approval_registry.cloned();
8620                            tokio::spawn(async move {
8621                                let approved = match tokio::time::timeout(
8622                                    CHILD_APPROVAL_TIMEOUT,
8623                                    host.approval_call(body),
8624                                )
8625                                .await
8626                                {
8627                                    Ok(Ok(reply)) => reply
8628                                        .get("approved")
8629                                        .and_then(|v| v.as_bool())
8630                                        .unwrap_or(false),
8631                                    // Transport error or timeout ⇒ fail closed.
8632                                    _ => false,
8633                                };
8634                                super::live::deliver_approval_scoped(
8635                                    registry.as_ref(),
8636                                    &child,
8637                                    child_attempt,
8638                                    &req_id,
8639                                    approved,
8640                                );
8641                            });
8642                        } else {
8643                            // There is no parent-agent reviewer or upstream actor
8644                            // to own this decision. Never open a manual/UI approval
8645                            // path: forced-ask is parent-reviewed or fail-closed.
8646                            tracing::warn!(
8647                                parent_session_id,
8648                                child_session_id,
8649                                request_id = %id,
8650                                "forced-ask request has no parent-agent reviewer; denying"
8651                            );
8652                            if client
8653                                .send(ParentFrame::ApprovalReply {
8654                                    id,
8655                                    approved: false,
8656                                })
8657                                .await
8658                                .is_err()
8659                            {
8660                                tracing::warn!(
8661                                    "failed to send fail-closed approval reply; connection failing"
8662                                );
8663                            }
8664                        }
8665                    }
8666                    Ok(Some(ChildFrame::SessionMessageAdmitted { confirmation })) => {
8667                        if let Some(activation) = plain_input {
8668                            if activation.initial_release_required { return Err(plain_actor_unsupported()); }
8669                            let binding = session_inbox_runtime.ok_or_else(|| plain_actor_unsupported())?;
8670                            let run_id = activation_run_id.ok_or_else(|| plain_actor_unsupported())?;
8671                            let claim = owned_inputs.front().ok_or_else(|| plain_actor_unsupported())?;
8672                            activation.confirm_input(binding, logical_session, run_id, claim, &confirmation).await?;
8673                            let _ = owned_inputs.pop_front();
8674                            continue;
8675                        }
8676                        let Some(binding) = session_inbox_runtime else {
8677                            tracing::warn!(
8678                                child_session_id,
8679                                "ignoring SessionInbox confirmation without a runtime binding"
8680                            );
8681                            continue;
8682                        };
8683                        let Some(bound_run_id) = activation_run_id else {
8684                            tracing::warn!(
8685                                child_session_id,
8686                                "ignoring SessionInbox confirmation without an activation owner"
8687                            );
8688                            continue;
8689                        };
8690                        let Some(claim) = inflight_claims.front() else {
8691                            tracing::warn!(
8692                                child_session_id,
8693                                envelope_id = %confirmation.envelope_id,
8694                                "rejecting stale SessionInbox confirmation with no in-flight canonical claim"
8695                            );
8696                            continue;
8697                        };
8698                        let exact = confirmation.target_session_id == logical_session.id
8699                            && confirmation.envelope_id == claim.envelope.id.as_str()
8700                            && confirmation.canonical_claim_generation == claim.generation
8701                            && confirmation.activation_run_id == bound_run_id;
8702                        if !exact
8703                            || !binding
8704                                .router
8705                                .owns_run(&logical_session.id, bound_run_id)
8706                                .await
8707                        {
8708                            tracing::warn!(
8709                                child_session_id,
8710                                expected_target = %logical_session.id,
8711                                received_target = %confirmation.target_session_id,
8712                                expected_envelope_id = %claim.envelope.id,
8713                                received_envelope_id = %confirmation.envelope_id,
8714                                expected_generation = claim.generation,
8715                                received_generation = confirmation.canonical_claim_generation,
8716                                expected_run_id = bound_run_id,
8717                                received_run_id = %confirmation.activation_run_id,
8718                                "rejecting stale or mismatched SessionInbox admission confirmation"
8719                            );
8720                            continue;
8721                        }
8722                        let claim = inflight_claims
8723                            .pop_front()
8724                            .expect("validated in-flight canonical claim");
8725                        // On failure the durable canonical cur file remains
8726                        // recoverable for the next owner.
8727                        checkpoint_and_ack_canonical_claim(
8728                            binding,
8729                            logical_session,
8730                            &claim,
8731                            remote_claim_fence,
8732                        )
8733                            .await?;
8734                        // Ordered single-consumer: only after the exact prior
8735                        // claim is checkpointed+acked may the driver claim and
8736                        // forward the next envelope.
8737                        if inflight_claims.is_empty() {
8738                            forward_next_canonical_claim(
8739                                client,
8740                                binding,
8741                                logical_session,
8742                                bound_run_id,
8743                                &mut inflight_claims,
8744                                remote_claim_fence,
8745                            )
8746                            .await?;
8747                        }
8748                    }
8749                    Ok(Some(ChildFrame::Terminal { status, result, error, final_event_watermark, .. })) => {
8750                        if local_history_tools.is_some() {
8751                            // Check before history commit or terminal receipt acceptance.
8752                            // Normal lossy observations do not promise a complete trace.
8753                            validate_strict_history_terminal(
8754                                final_event_watermark.as_ref(), logical_session, parent_session_id,
8755                                activation_run_id, current_epoch, expected_creation,
8756                                next_actor_event_seq - 1,
8757                            )?;
8758                        }
8759                        if remote_cancel_deadline.is_some() && status == TerminalStatus::Cancelled {
8760                            if let Some(tools) = local_history_tools {
8761                                // The watermark proves transport completeness, not
8762                                // persistence. Certify only an entirely validated
8763                                // local Read/Glob prefix with no pending handoff.
8764                                if permission_handshake.is_awaiting()
8765                                    || !owned_inputs.is_empty()
8766                                    || !inflight_claims.is_empty()
8767                                    || parent_question_checkpoint_id.is_some()
8768                                    || nested_wait_registered_this_run
8769                                    || strict_history_output.is_none()
8770                                {
8771                                    return Err(local_tool_history_unsupported());
8772                                }
8773                                commit_local_tool_history(
8774                                    local_history.take().ok_or_else(local_tool_history_unsupported)?,
8775                                    logical_session,
8776                                    session_inbox_runtime.ok_or_else(local_tool_history_unsupported)?,
8777                                    activation_run_id.ok_or_else(local_tool_history_unsupported)?,
8778                                    (tools, local_history_read_only),
8779                                    LocalToolTerminal::Cancelled,
8780                                    cancel_token,
8781                                ).await?;
8782                                let proof = bamboo_storage::v2::HostTerminalCompleteness::from_verified_host(
8783                                    logical_session,
8784                                    activation_run_id.ok_or_else(local_tool_history_unsupported)?,
8785                                    current_epoch,
8786                                    next_actor_event_seq - 1,
8787                                ).map_err(|_| local_tool_history_unsupported())?;
8788                                *strict_history_output.as_deref_mut()
8789                                    .ok_or_else(local_tool_history_unsupported)? = Some(StrictTerminalHistory {
8790                                        proof,
8791                                        watermark: final_event_watermark.ok_or_else(local_tool_history_unsupported)?,
8792                                    });
8793                            }
8794                            // A selected Worker terminal proves that its Run
8795                            // ended even if permission posture or an Inbox
8796                            // correction was still pending. Claims remain
8797                            // unacked for retry; only the placement fence can
8798                            // now be released after SDK saves Cancelled.
8799                            client.accept_durable_terminal(status);
8800                            if workflow_usage_requested {
8801                                logical_session.metadata.insert(bamboo_subagent::proto::WORKFLOW_TERMINAL_OBSERVATION_KEY.into(), activation_run_id.ok_or_else(plain_actor_unsupported)?.into());
8802                            }
8803                            return Err(AgentError::Cancelled);
8804                        }
8805                        if parent_question_checkpoint_id.is_some()
8806                            && status != TerminalStatus::Suspended
8807                        {
8808                            return Err(AgentError::LLM(
8809                                "Child question checkpoint must end in a suspended Run".into(),
8810                            ));
8811                        }
8812                        if permission_handshake.is_awaiting() {
8813                            if remote_environment_lease && status == TerminalStatus::Error {
8814                                if let Some(code) = error.as_deref().and_then(remote_environment_error_code) {
8815                                    // The Worker rejected the EnvironmentLease before it
8816                                    // could confirm its permission posture. This exact
8817                                    // correlated Outcome still proves the Run ended, so
8818                                    // retain its durable receipt and release the slot after
8819                                    // the Host records the failure. Other early terminals
8820                                    // remain unproven and keep their placement fence.
8821                                    client.accept_durable_terminal(status);
8822                            if workflow_usage_requested {
8823                                logical_session.metadata.insert(bamboo_subagent::proto::WORKFLOW_TERMINAL_OBSERVATION_KEY.into(), activation_run_id.ok_or_else(plain_actor_unsupported)?.into());
8824                            }
8825                                    return Err(AgentError::LLM(code.to_owned()));
8826                                }
8827                            }
8828                            return Err(AgentError::LLM(
8829                                "actor terminated before permission posture confirmation"
8830                                    .to_string(),
8831                            ));
8832                        }
8833                        if !owned_inputs.is_empty() {
8834                            return Err(AgentError::LLM("Actor terminated before confirming its durable correction; Inbox claim is retained".into()));
8835                        }
8836                        if let Some(claim) = inflight_claims.front() {
8837                            return Err(AgentError::LLM(format!(
8838                                "actor terminated before durably admitting SessionInbox message {}; canonical claim remains recoverable",
8839                                claim.envelope.id
8840                            )));
8841                        }
8842                        if status == TerminalStatus::Completed {
8843                            if let Some(collector) = local_history.take() {
8844                                commit_local_tool_history(
8845                                    collector, logical_session,
8846                                    session_inbox_runtime.ok_or_else(local_tool_history_unsupported)?,
8847                                    activation_run_id.ok_or_else(local_tool_history_unsupported)?,
8848                                    (local_history_tools.ok_or_else(local_tool_history_unsupported)?, local_history_read_only),
8849                                    LocalToolTerminal::Completed(result.as_deref()), cancel_token,
8850                                ).await?;
8851                            }
8852                        }
8853                        if let Some(collector) = readonly.take() {
8854                            if status == TerminalStatus::Completed {
8855                                let activation = plain_input.ok_or_else(|| plain_actor_unsupported())?;
8856                                let backlog = activation.input_inbox.inspect(&logical_session.id).await
8857                                    .map_err(|_| plain_actor_unsupported())?;
8858                                if backlog.pending != 0 || backlog.claimed != 0 || backlog.activation_pending() {
8859                                    return Err(AgentError::LLM("Glob Actor continuation is unsupported; pending Inbox input is preserved".into()));
8860                                }
8861                                *readonly_output.as_deref_mut().ok_or_else(|| plain_actor_unsupported())? =
8862                                    collector.finish(result.as_deref())?;
8863                            }
8864                        } else if let Some(activation) = plain_input {
8865                            if status == TerminalStatus::Completed {
8866                                let binding = session_inbox_runtime.ok_or_else(|| plain_actor_unsupported())?;
8867                                let run_id = activation_run_id.ok_or_else(|| plain_actor_unsupported())?;
8868                                if continuations < continuation_limit {
8869                                    if let Some((claim, epoch)) = activation.continue_input(
8870                                        client, binding, logical_session, run_id, plain_run, &mut expected_permission_posture,
8871                                        result.clone().filter(|text| !text.is_empty()).ok_or_else(|| plain_actor_unsupported())?, event_tx,
8872                                    ).await? {
8873                                        owned_inputs = VecDeque::from([claim]);
8874                                        released_inputs.clear();
8875                                        continuations += 1;
8876                                        current_epoch = epoch;
8877                                        approval_epoch.store(epoch, Ordering::Release);
8878                                        // These are native coordinates scoped to the newly
8879                                        // correlated epoch. Public Host feed seq is untouched.
8880                                        next_actor_event_seq = 1;
8881                                        canonical_router = None;
8882                                        permission_handshake = PermissionPostureHandshake::new(expected_permission_posture.as_ref());
8883                                        display = ActorEventDisplay::default();
8884                                        continue;
8885                                    }
8886                                } else if activation.input_inbox.inspect(&logical_session.id).await
8887                                    .map_err(|_| plain_actor_unsupported())?.activation_pending() {
8888                                    if continuation_limit == 1 {
8889                                        return Err(AgentError::LLM("Only one Actor correction continuation is supported; remaining Inbox input is preserved".into()));
8890                                    }
8891                                    // The outer success path appends the last reply. An
8892                                    // explicit cap refusal must preserve it here instead,
8893                                    // without claiming/acking the excess input or saving
8894                                    // through the ordinary writer on this owned Actor.
8895                                    activation.append_reply(logical_session,
8896                                        result.clone().filter(|text| !text.is_empty()).ok_or_else(|| plain_actor_unsupported())?,
8897                                        event_tx).await?;
8898                                    return Err(AgentError::LLM("Actor correction continuation limit reached; completed reply committed and remaining Inbox input preserved".into()));
8899                                }
8900                            }
8901                        }
8902                        if status == TerminalStatus::Completed {
8903                            // Only an activation with a canonical Child creation
8904                            // identity can invoke the Host SubAgent route. Legacy
8905                            // Echo and other executors may share this runner's
8906                            // installed tool but have no nested wait to fence.
8907                            if let (Some(tool), Some(creation)) =
8908                                (canonical_subagent_tool.as_deref(), expected_creation)
8909                            {
8910                                let (Some(store), Some(binding), Some(run_id)) =
8911                                    (actor_directory_store, session_inbox_runtime,
8912                                        activation_run_id)
8913                                else {
8914                                    return Err(AgentError::LLM(
8915                                        "nested SubAgent Host authority unavailable at Child terminal"
8916                                            .into(),
8917                                    ));
8918                                };
8919                                let must_suspend = ensure_active_nested_wait(
8920                                    tool, store, binding, run_id, child_session_id,
8921                                    parent_session_id, logical_session, creation, plain_input,
8922                                ).await?;
8923                                if must_suspend || nested_wait_registered_this_run {
8924                                    // This is only a current-Run handoff fence.
8925                                    // sdk/spawn re-reads the durable Host wait;
8926                                    // copying a stale wait into this Session
8927                                    // would re-arm it after a fast grandchild
8928                                    // completion, while copying no wait would
8929                                    // erase an active registration on final save.
8930                                    logical_session.metadata.insert(
8931                                        "runtime.actor_nested_wait_handoff".into(),
8932                                        "true".into(),
8933                                    );
8934                                }
8935                            }
8936                        }
8937                        let terminal_result = match status {
8938                            TerminalStatus::Completed => Ok(result),
8939                            TerminalStatus::Cancelled => Err(AgentError::Cancelled),
8940                            TerminalStatus::Error => Err(AgentError::LLM(
8941                                error.unwrap_or_else(|| "actor child errored".to_string()),
8942                            )),
8943                            TerminalStatus::Suspended => {
8944                                use bamboo_agent_core::storage::Storage;
8945                                let (Some(request_id), Some(store), Some(binding), Some(run_id), Some(creation)) =
8946                                    (parent_question_checkpoint_id.as_ref(), actor_directory_store,
8947                                        session_inbox_runtime, activation_run_id, expected_creation)
8948                                else {
8949                                    return Err(AgentError::LLM("uncheckpointed Child suspension rejected".into()));
8950                                };
8951                                if load_active_subagent_caller(
8952                                    store, binding, run_id, child_session_id,
8953                                    parent_session_id, logical_session, creation, plain_input,
8954                                ).await.is_none() {
8955                                    return Err(AgentError::LLM("stale Child question suspension rejected".into()));
8956                                }
8957                                let id = bamboo_domain::SessionMessageId::parse(request_id.clone())
8958                                    .map_err(|_| AgentError::LLM("invalid Child question receipt".into()))?;
8959                                let child = store.load_session(child_session_id).await
8960                                    .map_err(|_| AgentError::LLM("canonical Child question unavailable".into()))?
8961                                    .ok_or_else(|| AgentError::LLM("canonical Child question missing".into()))?;
8962                                let parent = store.load_session(parent_session_id).await
8963                                    .map_err(|_| AgentError::LLM("canonical direct parent unavailable".into()))?
8964                                    .ok_or_else(|| AgentError::LLM("canonical direct parent missing".into()))?;
8965                                let pending = ParentQuestion::for_pending(&parent, &child)
8966                                    .is_some_and(|question| question.id == id);
8967                                let answered = bamboo_domain::ParentQuestionResolution::from_child(
8968                                    &parent, &child, &id,
8969                                ).is_some();
8970                                if !pending && !answered {
8971                                    return Err(AgentError::LLM("canonical Child question proof changed".into()));
8972                                }
8973                                logical_session.metadata.insert(
8974                                    "runtime.actor_parent_question_handoff".into(), "true".into(),
8975                                );
8976                                Ok(None)
8977                            }
8978                        };
8979                        if local_history_tools.is_some() {
8980                            // Failed strict Runs discard their collected suffix. A
8981                            // consumed watermark cannot certify those unapplied
8982                            // effects; retain every broker frame for recovery.
8983                            if status != TerminalStatus::Completed {
8984                                return terminal_result;
8985                            }
8986                            let proof = bamboo_storage::v2::HostTerminalCompleteness::from_verified_host(
8987                                logical_session,
8988                                activation_run_id.ok_or_else(local_tool_history_unsupported)?,
8989                                current_epoch,
8990                                next_actor_event_seq - 1,
8991                            ).map_err(|_| local_tool_history_unsupported())?;
8992                            let history = StrictTerminalHistory {
8993                                proof,
8994                                watermark: final_event_watermark.ok_or_else(local_tool_history_unsupported)?,
8995                            };
8996                            if let Some(output) = strict_history_output.as_deref_mut() {
8997                                *output = Some(history);
8998                            }
8999                        }
9000                        // This marker is set only after every Host terminal
9001                        // validation above succeeded. A surfaced broker Outcome
9002                        // that failed permission, Inbox, or question checks must
9003                        // never receive a durable ACK merely because SDK saved
9004                        // an error status. Genuine Worker Error/Cancelled frames
9005                        // are accepted terminals even though drive returns Err.
9006                        client.accept_durable_terminal(status);
9007                            if workflow_usage_requested {
9008                                logical_session.metadata.insert(bamboo_subagent::proto::WORKFLOW_TERMINAL_OBSERVATION_KEY.into(), activation_run_id.ok_or_else(plain_actor_unsupported)?.into());
9009                            }
9010                        if status == TerminalStatus::Completed {
9011                            if let Some(activation) = plain_input {
9012                                // Only the final epoch reaches here: continued Runs
9013                                // above reset their router and stay in the pump.
9014                                *activation.completion_router.lock().recover_poison() =
9015                                    canonical_router.take();
9016                            }
9017                        }
9018                        return terminal_result;
9019                    }
9020                    Ok(None) => {
9021                        return Err(AgentError::LLM(
9022                            "actor child closed before terminal".to_string(),
9023                        ));
9024                    }
9025                    Err(e) => {
9026                        return Err(AgentError::LLM(format!("actor transport error: {e}")));
9027                    }
9028                }
9029            }
9030        }
9031    }
9032
9033    // Only reached on cancellation: ask the child to stop (best-effort), then report cancelled.
9034    let _ = client.send(ParentFrame::Cancel).await;
9035    Err(AgentError::Cancelled)
9036}
9037
9038/// The assignment text = the child session's latest user message (falls back to its title).
9039fn project_id_for_actor_run(
9040    session: &Session,
9041) -> Result<Option<bamboo_domain::ProjectId>, AgentError> {
9042    match crate::project_context::ProjectContextResolver::session_project_identity(session) {
9043        crate::project_context::SessionProjectIdentity::Assigned(project_id) => {
9044            Ok(Some(project_id))
9045        }
9046        crate::project_context::SessionProjectIdentity::Unassigned => Ok(None),
9047        crate::project_context::SessionProjectIdentity::Invalid { raw, message } => {
9048            Err(AgentError::LLM(format!(
9049                "child session carries an invalid Project identity '{raw}': {message}"
9050            )))
9051        }
9052    }
9053}
9054
9055fn remote_broker_unavailable() -> AgentError {
9056    AgentError::LLM("remote_broker_activation_unavailable".into())
9057}
9058
9059/// Only these bounded worker admission errors may cross the fixed Remote
9060/// diagnostic boundary before permission posture confirmation. Transport and
9061/// arbitrary worker text retain the generic remote error.
9062fn remote_environment_error_code(value: &str) -> Option<&'static str> {
9063    const CODES: &[&str] = &[
9064        "remote_environment_lease_invalid",
9065        "remote_environment_snapshot_mismatch",
9066        "remote_environment_checkout_not_clean",
9067        "remote_environment_checkout_unsupported",
9068        "remote_environment_workspace_unavailable",
9069        "remote_environment_workspace_outside_git_root",
9070        "remote_environment_path_unsupported",
9071        "remote_environment_git_unavailable",
9072        "remote_environment_git_inspection_failed",
9073        "remote_environment_git_inspection_timed_out",
9074        "remote_environment_git_output_too_large",
9075        "remote_environment_snapshot_too_large",
9076        "remote_environment_snapshot_changed",
9077        "remote_environment_index_invalid",
9078        "remote_environment_index_unsupported",
9079        "remote_environment_host_path_or_grants_rejected",
9080        "remote_environment_worker_workspace_missing",
9081        "remote_environment_runspec_invalid",
9082        "remote_environment_lease_missing_or_host_path_present",
9083    ];
9084    CODES.iter().copied().find(|code| *code == value)
9085}
9086
9087// Read only: no ensure/repair/Actor initialization or subtree budget. Capture
9088// exactly the canonical chain and compare it again immediately before Run.
9089async fn remote_canonical_lineage(
9090    binding: &SessionInboxRuntimeBinding,
9091    session: &Session,
9092) -> Result<Vec<bamboo_domain::ActorSession>, AgentError> {
9093    let mut current = bamboo_domain::ActorSession::from_session(session)
9094        .map_err(|_| remote_broker_unavailable())?;
9095    let mut chain = Vec::new();
9096    loop {
9097        if chain.len() > session.spawn_depth as usize {
9098            return Err(remote_broker_unavailable());
9099        }
9100        let saved = binding
9101            .storage
9102            .load_session(&current.actor_id)
9103            .await
9104            .map_err(|_| remote_broker_unavailable())?
9105            .ok_or_else(remote_broker_unavailable)?;
9106        let observed = bamboo_domain::ActorSession::from_session(&saved)
9107            .map_err(|_| remote_broker_unavailable())?;
9108        if observed.actor_id != current.actor_id
9109            || observed.session_created_at != current.session_created_at
9110            || observed.parent_actor_id != current.parent_actor_id
9111            || observed.root_actor_id != current.root_actor_id
9112            || observed.spawn_depth != current.spawn_depth
9113            || observed.project_id != current.project_id
9114        {
9115            return Err(remote_broker_unavailable());
9116        }
9117        chain.push(observed.clone());
9118        let Some(parent_id) = observed.parent_actor_id.as_ref() else {
9119            if observed.actor_id != session.root_session_id || observed.spawn_depth != 0 {
9120                return Err(remote_broker_unavailable());
9121            }
9122            return Ok(chain);
9123        };
9124        let parent = binding
9125            .storage
9126            .load_session(parent_id)
9127            .await
9128            .map_err(|_| remote_broker_unavailable())?
9129            .ok_or_else(remote_broker_unavailable)?;
9130        let parent = bamboo_domain::ActorSession::from_session(&parent)
9131            .map_err(|_| remote_broker_unavailable())?;
9132        if parent.root_actor_id != observed.root_actor_id
9133            || parent.project_id != observed.project_id
9134            || parent.spawn_depth.checked_add(1) != Some(observed.spawn_depth)
9135            || parent.session_created_at > observed.session_created_at
9136        {
9137            return Err(remote_broker_unavailable());
9138        }
9139        current = parent;
9140    }
9141}
9142
9143async fn canonical_child_creation(
9144    binding: &SessionInboxRuntimeBinding,
9145    session: &Session,
9146    job: &SpawnJob,
9147) -> Result<bamboo_subagent::proto::ChildCreationIdentity, AgentError> {
9148    let conflict = || {
9149        AgentError::LLM(
9150            "child creation identity unavailable or changed in canonical host Storage".into(),
9151        )
9152    };
9153    let current = binding
9154        .storage
9155        .load_session(&job.child_session_id)
9156        .await
9157        .map_err(|_| conflict())?
9158        .ok_or_else(conflict)?;
9159    if bamboo_domain::ActorSession::from_session(&current).is_err()
9160        || bamboo_domain::ActorSession::from_session(session).is_err()
9161        || current.kind != bamboo_domain::SessionKind::Child
9162        || session.kind != current.kind
9163        || current.id != session.id
9164        || current.id != job.child_session_id
9165        || current.parent_session_id.as_deref() != Some(job.parent_session_id.as_str())
9166        || current.parent_session_id != session.parent_session_id
9167        || current.root_session_id.trim().is_empty()
9168        || current.root_session_id != session.root_session_id
9169        || current.created_at != session.created_at
9170        || current.spawn_depth != session.spawn_depth
9171        || current.spawn_depth == 0
9172        || project_id_for_actor_run(&current)? != project_id_for_actor_run(session)?
9173    {
9174        return Err(conflict());
9175    }
9176    Ok(bamboo_subagent::proto::ChildCreationIdentity {
9177        created_at: current.created_at,
9178        spawn_depth: current.spawn_depth,
9179    })
9180}
9181
9182fn logical_identity_for_actor_run(session: &Session, job: &SpawnJob) -> LogicalSessionIdentity {
9183    LogicalSessionIdentity {
9184        creation: None,
9185        session_id: session.id.clone(),
9186        parent_session_id: session
9187            .parent_session_id
9188            .clone()
9189            .or_else(|| Some(job.parent_session_id.clone())),
9190        root_session_id: if session.root_session_id.trim().is_empty() {
9191            job.parent_session_id.clone()
9192        } else {
9193            session.root_session_id.clone()
9194        },
9195    }
9196}
9197
9198fn extract_assignment(session: &Session) -> String {
9199    session
9200        .messages
9201        .iter()
9202        .rev()
9203        .find(|m| matches!(m.role, Role::User))
9204        .map(|m| m.content.clone())
9205        .unwrap_or_else(|| {
9206            session
9207                .metadata
9208                .get("title")
9209                .cloned()
9210                .unwrap_or_else(|| "Execute task".to_string())
9211        })
9212}
9213
9214#[cfg(test)]
9215mod tests {
9216    use super::*;
9217    use crate::SessionActivationRouter;
9218    use bamboo_domain::{RuntimeSessionPersistence, SessionInboxPort, Storage};
9219
9220    include!("expired_plain_owner_tests.rs");
9221
9222    include!("owned_cold_input_tests.rs");
9223
9224    struct SubAgentCapProbe;
9225
9226    #[async_trait]
9227    impl bamboo_agent_core::tools::Tool for SubAgentCapProbe {
9228        fn name(&self) -> &str {
9229            "SubAgent"
9230        }
9231
9232        fn description(&self) -> &str {
9233            "Capture the Host's output-cap observation"
9234        }
9235
9236        fn parameters_schema(&self) -> serde_json::Value {
9237            serde_json::json!({"type":"object"})
9238        }
9239
9240        async fn invoke(
9241            &self,
9242            _args: serde_json::Value,
9243            ctx: bamboo_agent_core::tools::ToolCtx,
9244        ) -> Result<bamboo_agent_core::tools::ToolOutcome, bamboo_agent_core::tools::ToolError>
9245        {
9246            let cap = bamboo_agent_core::tools::observed_tool_output_cap(&ctx);
9247            Ok(bamboo_agent_core::tools::ToolOutcome::Completed(
9248                bamboo_agent_core::tools::ToolResult::text(
9249                    true,
9250                    serde_json::json!(cap).to_string(),
9251                ),
9252            ))
9253        }
9254    }
9255
9256    #[tokio::test]
9257    async fn host_subagent_dispatch_scopes_the_callers_current_output_cap() {
9258        for cap in [None, Some(0), Some(512)] {
9259            let mut caller = Session::new_child("cap-child", "cap-parent", "model", "Child");
9260            caller.token_budget = cap.map(|max_tool_output_tokens| bamboo_domain::TokenBudget {
9261                max_tool_output_tokens,
9262                ..Default::default()
9263            });
9264            let response = invoke_active_subagent(
9265                &SubAgentCapProbe,
9266                &caller,
9267                serde_json::json!({}),
9268                "cap-call",
9269            )
9270            .await;
9271            let result: bamboo_agent_core::tools::ToolResult =
9272                serde_json::from_value(response["result"].clone()).unwrap();
9273            assert_eq!(
9274                serde_json::from_str::<Option<u32>>(&result.result).unwrap(),
9275                cap
9276            );
9277        }
9278    }
9279
9280    #[test]
9281    fn plain_rejection_logs_branch_and_run_without_changing_error() {
9282        // Keep shared callsites consulting the current subscriber during scoped capture.
9283        let capture_interest_guard = tracing::Dispatch::new(
9284            tracing_subscriber::fmt()
9285                .with_max_level(tracing_subscriber::filter::LevelFilter::OFF)
9286                .with_writer(std::io::sink)
9287                .finish(),
9288        );
9289        let output = tempfile::NamedTempFile::new().unwrap();
9290        let subscriber = tracing_subscriber::fmt()
9291            .with_ansi(false)
9292            .without_time()
9293            .with_writer(output.reopen().unwrap())
9294            .finish();
9295        let branch_line = std::cell::Cell::new(0);
9296        let error = tracing::subscriber::with_default(subscriber, || {
9297            // Exercise the shared warning callsite without this thread's scoped subscriber.
9298            std::thread::spawn(|| drop(plain_actor_unsupported()))
9299                .join()
9300                .expect("unscoped rejection warning thread panicked");
9301            let span = tracing::warn_span!(
9302                "actor_child_run",
9303                child_session_id = %"diagnostic-child",
9304                activation_run_id = tracing::field::Empty
9305            );
9306            let _entered = span.enter();
9307            span.record("activation_run_id", "diagnostic-run");
9308            None::<()>
9309                .ok_or_else(|| {
9310                    let caller_line = line!() + 1;
9311                    let error = plain_actor_unsupported();
9312                    branch_line.set(caller_line);
9313                    error
9314                })
9315                .unwrap_err()
9316        });
9317        assert!(matches!(&error, AgentError::LLM(message)
9318            if message.starts_with("This Actor Child supports a fresh plain activation,")
9319                && message.ends_with("Other continuation is unsupported; durable history is preserved.")));
9320        let log = std::fs::read_to_string(output.path()).unwrap();
9321        assert!(log.contains("child_session_id=diagnostic-child"), "{log}");
9322        assert!(
9323            log.contains("activation_run_id=\"diagnostic-run\""),
9324            "{log}"
9325        );
9326        assert!(
9327            log.contains(&format!("process_id={}", std::process::id())),
9328            "{log}"
9329        );
9330        assert!(log.contains(&format!("source_file={:?}", file!())), "{log}");
9331        assert!(
9332            log.contains(&format!("source_line={}", branch_line.get())),
9333            "{log}"
9334        );
9335        assert!(!error.to_string().contains("diagnostic-run"));
9336        drop(capture_interest_guard);
9337    }
9338
9339    #[tokio::test]
9340    async fn completed_plain_continuation_requires_bounded_inputs_and_current_identity() {
9341        for case in [
9342            "valid",
9343            "over-batch-limit",
9344            "tool-history",
9345            "stale-lineage",
9346            "no-release",
9347        ] {
9348            let temp = tempfile::tempdir().unwrap();
9349            let store = Arc::new(
9350                bamboo_storage::SessionStoreV2::new(temp.path().into())
9351                    .await
9352                    .unwrap(),
9353            );
9354            let parent = Session::new("continuation-parent", "model");
9355            store.save_session(&parent).await.unwrap();
9356            let mut child =
9357                Session::new_child_of("continuation-child", &parent, "model", "first task");
9358            child.add_message(bamboo_agent_core::Message::assistant("first result", None));
9359            if case == "tool-history" {
9360                let mut tool = bamboo_agent_core::Message::assistant("old tool result", None);
9361                tool.role = Role::Tool;
9362                child.add_message(tool);
9363            }
9364            child.set_last_run_status("completed");
9365            store.save_session(&child).await.unwrap();
9366            store.ensure_actor(&child.id).await.unwrap();
9367            let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
9368            bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
9369            let now = chrono::Utc::now();
9370            let old = store
9371                .claim_activation(&ActorActivationClaim {
9372                    actor_id: child.id.clone(),
9373                    run_id: "first-run".into(),
9374                    lease_owner: "first-owner".into(),
9375                    lease_expires_at: now + chrono::Duration::minutes(1),
9376                    inbox_generation: 0,
9377                    placement_ref: None,
9378                    now,
9379                })
9380                .await
9381                .unwrap();
9382            store.start_activation(&old.fence(), now).await.unwrap();
9383            store
9384                .finish_activation(&old.fence(), now, ActorActivationFinish::Succeeded)
9385                .await
9386                .unwrap();
9387            let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
9388                store.clone(),
9389                Default::default(),
9390            ));
9391            for slot in 0..if case == "over-batch-limit" {
9392                bamboo_domain::SessionInboxLimits::default().max_claim_batch + 1
9393            } else {
9394                1
9395            } {
9396                let envelope = bamboo_domain::SessionMessageEnvelope::user_input(
9397                    &child.id,
9398                    format!("new task {slot}"),
9399                );
9400                let receipt = inbox.deliver(&envelope).await.unwrap();
9401                inbox
9402                    .mark_activation_eligible(
9403                        &child.id,
9404                        receipt.generation,
9405                        bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
9406                    )
9407                    .await
9408                    .unwrap();
9409            }
9410            let storage: Arc<dyn Storage> = store.clone();
9411            let persistence = Arc::new(bamboo_storage::LockedSessionStore::new(storage));
9412            let binding = actor_binding(store.clone(), inbox, persistence);
9413            let _registration = binding
9414                .router
9415                .register_run(&child.id, "second-run")
9416                .await
9417                .unwrap();
9418            if case == "stale-lineage" {
9419                child.parent_session_id = Some("foreign-parent".into());
9420            }
9421            let result = PlainActorActivation::start(
9422                store.clone(),
9423                &child,
9424                &binding,
9425                Some("second-run"),
9426                Some(policy),
9427                true,
9428                (case != "no-release").then_some("second-worker"),
9429            )
9430            .await;
9431            if case == "valid" {
9432                let activation = result.expect("one new input on a completed plain Child");
9433                assert_eq!(activation.fence.attempt, old.attempt + 1);
9434                assert_eq!(activation.fence.actor_id, child.id);
9435                assert_eq!(activation.inbox_generation, 1);
9436                activation
9437                    .finish(ActorActivationFinish::Failed)
9438                    .await
9439                    .unwrap();
9440            } else {
9441                assert!(
9442                    result.is_err(),
9443                    "unsupported continuation {case} must fail before claim"
9444                );
9445                assert_eq!(
9446                    store
9447                        .inspect_actor(&child.id)
9448                        .await
9449                        .unwrap()
9450                        .actor
9451                        .current_attempt,
9452                    old.attempt
9453                );
9454            }
9455            let canonical = store.load_session(&child.id).await.unwrap().unwrap();
9456            assert_eq!(
9457                canonical.parent_session_id.as_deref(),
9458                Some(parent.id.as_str())
9459            );
9460            assert!(canonical
9461                .messages
9462                .iter()
9463                .any(|message| message.content == "first result"));
9464        }
9465    }
9466
9467    #[tokio::test]
9468    async fn pre_dispatch_placement_failure_fails_only_claimed_activation() {
9469        let temp = tempfile::tempdir().unwrap();
9470        let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
9471            .await
9472            .unwrap();
9473        let parent = Session::new("placement-parent", "model");
9474        let child = Session::new_child_of("placement-child", &parent, "model", "task");
9475        store.save_session(&parent).await.unwrap();
9476        store.save_session(&child).await.unwrap();
9477        store.ensure_actor(&child.id).await.unwrap();
9478        store
9479            .bind_control_plane(
9480                &child.id,
9481                bamboo_domain::ActorControlPlaneBinding {
9482                    policy_revision: None,
9483                    placement_intent: Some(bamboo_domain::ActorPlacementIntent::Pool {
9484                        pool_id: "remote-worker".into(),
9485                    }),
9486                },
9487            )
9488            .await
9489            .unwrap();
9490
9491        let claim = |run_id: &str| ActorActivationClaim {
9492            actor_id: child.id.clone(),
9493            run_id: run_id.into(),
9494            lease_owner: format!("host-{run_id}"),
9495            lease_expires_at: chrono::Utc::now() + chrono::Duration::seconds(80),
9496            inbox_generation: 0,
9497            placement_ref: Some(bamboo_domain::ActorPlacementRef {
9498                class: bamboo_domain::ActorPlacementClass::Remote,
9499                lease_id: format!("slot-{run_id}"),
9500                slot_epoch: Some(1),
9501            }),
9502            now: chrono::Utc::now(),
9503        };
9504        let first = store.claim_activation(&claim("first")).await.unwrap();
9505        store
9506            .start_activation(&first.fence(), chrono::Utc::now())
9507            .await
9508            .unwrap();
9509        assert!(store.claim_activation(&claim("retry")).await.is_err());
9510
9511        // This is the post-claim placement preflight failure path. No Run has
9512        // been dispatched, so the same ActorId can retry before lease expiry.
9513        fail_pre_dispatch_activation(&store, &first.fence())
9514            .await
9515            .unwrap();
9516        let failed = store.inspect_actor(&child.id).await.unwrap();
9517        assert_eq!(
9518            failed.activation.unwrap().status,
9519            bamboo_domain::ActorActivationStatus::Failed
9520        );
9521        let retry = store.claim_activation(&claim("retry")).await.unwrap();
9522        assert_eq!(retry.attempt, first.attempt + 1);
9523        assert!(fail_pre_dispatch_activation(&store, &first.fence())
9524            .await
9525            .is_err());
9526        assert_eq!(
9527            store.inspect_actor(&child.id).await.unwrap().activation,
9528            Some(retry)
9529        );
9530    }
9531
9532    #[tokio::test]
9533    async fn lost_run_delivery_receipt_keeps_actor_activation_live() {
9534        struct AcceptedWithoutReceipt {
9535            published: bool,
9536        }
9537
9538        #[async_trait]
9539        impl bamboo_subagent::ChildLink for AcceptedWithoutReceipt {
9540            async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
9541                assert!(matches!(frame, ParentFrame::Run(_)));
9542                self.published = true;
9543                Err(bamboo_subagent::TransportError::Protocol(
9544                    "broker delivery receipt lost after publication".into(),
9545                ))
9546            }
9547
9548            async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
9549                std::future::pending().await
9550            }
9551        }
9552
9553        let temp = tempfile::tempdir().unwrap();
9554        let store = Arc::new(
9555            bamboo_storage::SessionStoreV2::new(temp.path().into())
9556                .await
9557                .unwrap(),
9558        );
9559        let parent = Session::new("dispatch-parent", "model");
9560        let child = Session::new_child_of("dispatch-child", &parent, "model", "task");
9561        store.save_session(&parent).await.unwrap();
9562        store.save_session(&child).await.unwrap();
9563        store.ensure_actor(&child.id).await.unwrap();
9564        let now = chrono::Utc::now();
9565        let claim = ActorActivationClaim {
9566            actor_id: child.id.clone(),
9567            run_id: "dispatch-run".into(),
9568            lease_owner: "dispatch-host".into(),
9569            lease_expires_at: now + chrono::Duration::seconds(80),
9570            inbox_generation: 0,
9571            placement_ref: None,
9572            now,
9573        };
9574        let activation = store.claim_activation(&claim).await.unwrap();
9575        store
9576            .start_activation(&activation.fence(), chrono::Utc::now())
9577            .await
9578            .unwrap();
9579        let mut link = AcceptedWithoutReceipt { published: false };
9580        let mut send_invoked = false;
9581        let run = RunSpec {
9582            assignment: "task".into(),
9583            logical_session: None,
9584            project_id: None,
9585            reasoning_effort: None,
9586            permission_policy: None,
9587            messages: Vec::new(),
9588            activation_run_id: Some(claim.run_id.clone()),
9589            execution_epoch: 1,
9590            initial_session_messages: Vec::new(),
9591            secrets: Default::default(),
9592        };
9593        assert!(send_actor_run(&mut link, run, &mut send_invoked)
9594            .await
9595            .is_err());
9596        assert!(send_invoked && link.published);
9597        let slot = WorkerSlotLease {
9598            lease_id: "dispatch-slot".into(),
9599            host_ref: "dispatch-worker".into(),
9600            connection_generation: "dispatch-generation".into(),
9601            slot: 0,
9602            epoch: 1,
9603            actor_id: child.id.clone(),
9604            run_id: claim.run_id.clone(),
9605            expires_at: claim.lease_expires_at,
9606        };
9607        let mut lease = ActivePlacementLease {
9608            registry: bamboo_storage::v2::FileHostRegistry::new(temp.path().into())
9609                .await
9610                .unwrap(),
9611            actor_store: store.clone(),
9612            route: ScopedPlacementRoute {
9613                endpoint: String::new(),
9614                token: String::new(),
9615                ca_cert_file: PathBuf::new(),
9616                parent: bamboo_subagent::AgentRef {
9617                    session_id: "dispatch-parent".into(),
9618                    role: Some("host".into()),
9619                },
9620                worker_role: "worker".into(),
9621                pinned_mailbox: None,
9622                requirements: Default::default(),
9623            },
9624            mailbox: "dispatch-worker".into(),
9625            host_ref: "dispatch-worker".into(),
9626            fence: activation.fence(),
9627            placement_ref: bamboo_domain::ActorPlacementRef {
9628                class: bamboo_domain::ActorPlacementClass::Remote,
9629                lease_id: slot.lease_id.clone(),
9630                slot_epoch: Some(slot.epoch),
9631            },
9632            lease: Arc::new(tokio::sync::Mutex::new(slot)),
9633            heartbeat: tokio::spawn(std::future::pending()),
9634            run_cancel: CancellationToken::new(),
9635            lost: Arc::new(AtomicBool::new(false)),
9636            release_on_drop: AtomicBool::new(false),
9637        };
9638        assert!(!lease.abort_if_run_unsent(send_invoked).await.unwrap());
9639        assert_eq!(
9640            store
9641                .inspect_actor(&child.id)
9642                .await
9643                .unwrap()
9644                .activation
9645                .unwrap()
9646                .status,
9647            bamboo_domain::ActorActivationStatus::Running
9648        );
9649        let mut retry = claim.clone();
9650        retry.run_id = "dispatch-retry".into();
9651        retry.now = chrono::Utc::now();
9652        retry.lease_expires_at = retry.now + chrono::Duration::seconds(80);
9653        assert!(matches!(
9654            store.claim_activation(&retry).await,
9655            Err(bamboo_domain::ActorDirectoryError::Busy)
9656        ));
9657    }
9658
9659    #[test]
9660    fn accepted_cancelled_terminal_maps_to_cancelled_actor_activation() {
9661        let cancelled: Result<Option<String>, AgentError> = Err(AgentError::Cancelled);
9662        assert_eq!(
9663            remote_activation_finish(&cancelled, Some(TerminalStatus::Cancelled)),
9664            ActorActivationFinish::Cancelled
9665        );
9666        assert_eq!(
9667            remote_activation_finish(&cancelled, None),
9668            ActorActivationFinish::Failed
9669        );
9670        assert_eq!(
9671            remote_activation_finish(&cancelled, Some(TerminalStatus::Completed)),
9672            ActorActivationFinish::Failed
9673        );
9674    }
9675
9676    #[tokio::test]
9677    async fn remote_reply_is_fenced_before_terminal_status_save() {
9678        let temp = tempfile::tempdir().unwrap();
9679        let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
9680            .await
9681            .unwrap();
9682        let parent = Session::new("remote-reply-parent", "model");
9683        let mut child = Session::new_child_of("remote-reply-child", &parent, "model", "task");
9684        child.add_message(bamboo_agent_core::Message::user("task"));
9685        store.save_session(&parent).await.unwrap();
9686        store.save_session(&child).await.unwrap();
9687        store.ensure_actor(&child.id).await.unwrap();
9688        store
9689            .bind_control_plane(
9690                &child.id,
9691                bamboo_domain::ActorControlPlaneBinding {
9692                    policy_revision: None,
9693                    placement_intent: Some(bamboo_domain::ActorPlacementIntent::Pool {
9694                        pool_id: "remote-worker".into(),
9695                    }),
9696                },
9697            )
9698            .await
9699            .unwrap();
9700        let now = chrono::Utc::now();
9701        let activation = store
9702            .claim_activation(&ActorActivationClaim {
9703                actor_id: child.id.clone(),
9704                run_id: "remote-reply-run".into(),
9705                lease_owner: "remote-worker".into(),
9706                lease_expires_at: now + chrono::Duration::seconds(80),
9707                inbox_generation: 0,
9708                placement_ref: Some(bamboo_domain::ActorPlacementRef {
9709                    class: bamboo_domain::ActorPlacementClass::Remote,
9710                    lease_id: "remote-slot".into(),
9711                    slot_epoch: Some(1),
9712                }),
9713                now,
9714            })
9715            .await
9716            .unwrap();
9717        let fence = activation.fence();
9718        store.start_activation(&fence, now).await.unwrap();
9719
9720        append_fenced_remote_reply(&store, &fence, &mut child, "done")
9721            .await
9722            .unwrap();
9723        store
9724            .finish_activation(&fence, chrono::Utc::now(), ActorActivationFinish::Succeeded)
9725            .await
9726            .unwrap();
9727        child.set_last_run_status("completed");
9728        store.save_session(&child).await.unwrap();
9729        let saved = store.load_session(&child.id).await.unwrap().unwrap();
9730        assert_eq!(saved.last_run_status().as_deref(), Some("completed"));
9731        assert_eq!(saved.messages.len(), 2);
9732        assert_eq!(saved.messages.last().unwrap().content, "done");
9733        assert!(
9734            append_fenced_remote_reply(&store, &fence, &mut child, "duplicate")
9735                .await
9736                .is_err()
9737        );
9738        assert_eq!(
9739            store
9740                .load_session(&child.id)
9741                .await
9742                .unwrap()
9743                .unwrap()
9744                .messages
9745                .len(),
9746            2
9747        );
9748    }
9749
9750    async fn start_bus() -> (String, tempfile::TempDir) {
9751        let dir = tempfile::tempdir().unwrap();
9752        let core = Arc::new(bamboo_broker::BrokerCore::new(dir.path()));
9753        let server = Arc::new(bamboo_broker::BrokerServer::new(core, "t"));
9754        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9755        let addr = listener.local_addr().unwrap();
9756        tokio::spawn(async move {
9757            let _ = server.serve(listener).await;
9758        });
9759        (format!("ws://{addr}"), dir)
9760    }
9761
9762    struct DurableAckProbe {
9763        calls: Arc<AtomicUsize>,
9764        fail: bool,
9765        terminal_status: TerminalStatus,
9766        parent_mailbox: String,
9767    }
9768
9769    #[async_trait]
9770    impl bamboo_subagent::ChildLink for DurableAckProbe {
9771        async fn send(&mut self, _frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
9772            Ok(())
9773        }
9774
9775        async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
9776            Ok(None)
9777        }
9778
9779        fn has_pending_durable_terminal(&self) -> bool {
9780            true
9781        }
9782
9783        fn durable_delivery_receipt(&self) -> Option<bamboo_subagent::DurableChildDeliveryReceipt> {
9784            Some(bamboo_subagent::DurableChildDeliveryReceipt {
9785                broker_identity: "00000000-0000-4000-8000-000000000001".into(),
9786                parent_mailbox: self.parent_mailbox.clone(),
9787                correlation_id: "broker-run-1".into(),
9788                message_ids: vec!["broker-outcome-1".into()],
9789                terminal_status: self.terminal_status,
9790            })
9791        }
9792
9793        async fn acknowledge_durable_frames(&mut self) -> bamboo_subagent::TransportResult<()> {
9794            self.calls.fetch_add(1, Ordering::SeqCst);
9795            if self.fail {
9796                Err(bamboo_subagent::TransportError::Protocol(
9797                    "injected ACK delivery failure".into(),
9798                ))
9799            } else {
9800                Ok(())
9801            }
9802        }
9803    }
9804
9805    async fn insert_ack_probe(
9806        runner: &ActorChildRunner,
9807        session: &Session,
9808        calls: &Arc<AtomicUsize>,
9809        terminal_status: TerminalStatus,
9810    ) {
9811        insert_ack_probe_with_nested_wait(runner, session, calls, terminal_status, false).await;
9812    }
9813
9814    async fn insert_ack_probe_with_nested_wait(
9815        runner: &ActorChildRunner,
9816        session: &Session,
9817        calls: &Arc<AtomicUsize>,
9818        terminal_status: TerminalStatus,
9819        host_nested_wait_handoff: bool,
9820    ) {
9821        runner.pending_durable_links.lock().await.insert(
9822            (session.id.clone(), session.created_at, "run-1".into()),
9823            PendingDurableChildLink {
9824                link: Box::new(DurableAckProbe {
9825                    calls: calls.clone(),
9826                    fail: false,
9827                    terminal_status,
9828                    parent_mailbox: format!("p-{}", session.id),
9829                }),
9830                host_nested_wait_handoff,
9831                strict_history: None,
9832                strict_history_required: false,
9833                strict_history_committed: false,
9834            },
9835        );
9836    }
9837
9838    #[tokio::test]
9839    async fn broker_receipt_reconciler_repairs_only_the_proven_local_mailbox() {
9840        let (endpoint, broker_dir) = start_bus().await;
9841        let home = tempfile::tempdir().unwrap();
9842        let store = Arc::new(
9843            bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
9844                .await
9845                .unwrap(),
9846        );
9847        let root = Session::new("receipt-root", "model");
9848        store.save_session(&root).await.unwrap();
9849        let mut sender = bamboo_broker::BrokerClient::connect(
9850            &endpoint,
9851            bamboo_subagent::AgentRef {
9852                session_id: "receipt-worker".into(),
9853                role: None,
9854            },
9855            "t",
9856        )
9857        .await
9858        .unwrap();
9859        let broker_identity = sender.durable_broker_identity().await.unwrap();
9860        let wrong_broker_identity = if broker_identity == "00000000-0000-4000-8000-000000000001" {
9861            "00000000-0000-4000-8000-000000000002"
9862        } else {
9863            "00000000-0000-4000-8000-000000000001"
9864        };
9865
9866        for (id, receipt_mailbox, receipt_broker) in [
9867            ("receipt-good", "p-receipt-good", broker_identity.as_str()),
9868            (
9869                "receipt-wrong-mailbox",
9870                "p-another-child",
9871                broker_identity.as_str(),
9872            ),
9873            (
9874                "receipt-wrong-broker",
9875                "p-receipt-wrong-broker",
9876                wrong_broker_identity,
9877            ),
9878            (
9879                "receipt-local-role-changed",
9880                "p-receipt-local-role-changed",
9881                broker_identity.as_str(),
9882            ),
9883            (
9884                "receipt-remote-unproven",
9885                "remote-parent-unproven",
9886                broker_identity.as_str(),
9887            ),
9888        ] {
9889            let mut child = Session::new_child_of(id, &root, "model", "task");
9890            child
9891                .metadata
9892                .insert("runtime.kind".into(), "external".into());
9893            child
9894                .metadata
9895                .insert("external.protocol".into(), "actor".into());
9896            child.metadata.insert(
9897                "external.agent_id".into(),
9898                crate::external_agents::config::LOCAL_ACTOR_AGENT_ID.into(),
9899            );
9900            if id == "receipt-local-role-changed" || id == "receipt-remote-unproven" {
9901                child
9902                    .metadata
9903                    .insert("subagent_type".into(), "remote-worker".into());
9904            }
9905            child.add_message(bamboo_agent_core::Message::user("work"));
9906            store.save_session(&child).await.unwrap();
9907            let message_id = bamboo_subagent::MsgId::new();
9908            sender
9909                .deliver(
9910                    &format!("p-{id}"),
9911                    bamboo_subagent::InboxMessage {
9912                        id: message_id.clone(),
9913                        from: bamboo_subagent::AgentRef {
9914                            session_id: "receipt-worker".into(),
9915                            role: None,
9916                        },
9917                        kind: bamboo_subagent::InboxKind::Outcome,
9918                        body: serde_json::to_value(bamboo_subagent::ChildOutcome::completed(
9919                            "done",
9920                        ))
9921                        .unwrap(),
9922                        created_at: chrono::Utc::now(),
9923                        correlation_id: Some(bamboo_subagent::MsgId::new()),
9924                    },
9925                )
9926                .await
9927                .unwrap();
9928            // The Host can checkpoint only a frame already surfaced from the
9929            // broker, which moves its Maildir file from new/ into cur/.
9930            let mailbox =
9931                bamboo_subagent::Mailbox::at(broker_dir.path().join(format!("mailboxes/p-{id}")));
9932            assert_eq!(mailbox.drain().await.unwrap().len(), 1);
9933            child.add_message(bamboo_agent_core::Message::assistant("done", None));
9934            child.set_last_run_status("completed");
9935            if id == "receipt-good" {
9936                let prior_ids = child.messages[..child.messages.len() - 1]
9937                    .iter()
9938                    .map(|message| message.id.clone())
9939                    .collect();
9940                crate::execution::ChildCompletionSource::prepare(&mut child, "run-1", &prior_ids);
9941                assert!(
9942                    crate::execution::ChildCompletionSource::defer_for_durable_delivery(
9943                        &mut child, "run-1"
9944                    )
9945                    .unwrap()
9946                );
9947                let proof = bamboo_storage::v2::HostTerminalCompleteness::from_verified_host(
9948                    &child, "run-1", 7, 1,
9949                )
9950                .unwrap();
9951                store
9952                    .prepare_broker_terminal_receipt_with_completeness(
9953                        &child,
9954                        bamboo_storage::v2::BrokerTerminalRoute {
9955                            activation_run_id: "run-1",
9956                            broker_identity: receipt_broker,
9957                            parent_mailbox: receipt_mailbox,
9958                            broker_correlation_id: "correlation-1",
9959                            message_ids: &[message_id.as_str().to_owned()],
9960                        },
9961                        &proof,
9962                    )
9963                    .await
9964                    .unwrap();
9965                let mut staged = child.clone();
9966                staged.set_last_run_status("running");
9967                staged.clear_last_run_error();
9968                store.save_session(&staged).await.unwrap();
9969                store
9970                    .commit_broker_terminal_receipt_with_completeness(&child, "run-1", &proof)
9971                    .await
9972                    .unwrap();
9973            } else {
9974                store
9975                    .prepare_broker_terminal_receipt(
9976                        &child,
9977                        "run-1",
9978                        receipt_broker,
9979                        receipt_mailbox,
9980                        "correlation-1",
9981                        &[message_id.as_str().to_owned()],
9982                    )
9983                    .await
9984                    .unwrap();
9985                store.save_session(&child).await.unwrap();
9986            }
9987        }
9988
9989        let mut config = bamboo_config::Config::default();
9990        config.subagents_mut().broker = Some(bamboo_config::BrokerClientConfig {
9991            endpoint,
9992            token: "t".into(),
9993            ..Default::default()
9994        });
9995        config
9996            .subagents_mut()
9997            .remote_placements
9998            .push(bamboo_config::RemoteActorPlacement {
9999                role: "remote-worker".into(),
10000                endpoint: "ws://unscoped.example.invalid".into(),
10001                token_env: None,
10002                ca_cert_file: None,
10003                broker_peer: None,
10004                placement_requirements: None,
10005            });
10006        // Recover on an independently reopened Store, with no Actor executor or
10007        // provider available. Only the committed Host proof can authorize ACK.
10008        let cold_store = Arc::new(
10009            bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
10010                .await
10011                .unwrap(),
10012        );
10013        let before = cold_store
10014            .load_session("receipt-good")
10015            .await
10016            .unwrap()
10017            .unwrap();
10018        assert!(crate::execution::ChildCompletionSource::has_source_record(
10019            &before
10020        ));
10021        assert_eq!(before.last_run_status().as_deref(), Some("running"));
10022        assert!(crate::execution::ChildCompletionSource::from_committed_session(&before).is_none());
10023        let repair = BrokerTerminalReceiptReconciler::new(cold_store.clone(), &config);
10024        let report = repair.reconcile_once().await;
10025        assert_eq!(report.candidates, 5);
10026        assert_eq!(report.repaired, 2);
10027        assert_eq!(report.blocked, 3);
10028        let after = cold_store
10029            .load_session("receipt-good")
10030            .await
10031            .unwrap()
10032            .unwrap();
10033        assert!(crate::execution::ChildCompletionSource::from_committed_session(&after).is_some());
10034        assert_eq!(
10035            serde_json::to_value(before.messages).unwrap(),
10036            serde_json::to_value(after.messages).unwrap()
10037        );
10038        let retry = repair.reconcile_once().await;
10039        assert_eq!(retry.repaired, 0, "committed strict recovery is idempotent");
10040
10041        let good = bamboo_subagent::Mailbox::at(broker_dir.path().join("mailboxes/p-receipt-good"));
10042        let wrong = bamboo_subagent::Mailbox::at(
10043            broker_dir.path().join("mailboxes/p-receipt-wrong-mailbox"),
10044        );
10045        let wrong_broker = bamboo_subagent::Mailbox::at(
10046            broker_dir.path().join("mailboxes/p-receipt-wrong-broker"),
10047        );
10048        let local_role_changed = bamboo_subagent::Mailbox::at(
10049            broker_dir
10050                .path()
10051                .join("mailboxes/p-receipt-local-role-changed"),
10052        );
10053        let remote_unproven = bamboo_subagent::Mailbox::at(
10054            broker_dir
10055                .path()
10056                .join("mailboxes/p-receipt-remote-unproven"),
10057        );
10058        assert_eq!(good.pending_count().await.unwrap(), 0);
10059        assert_eq!(wrong.pending_count().await.unwrap(), 1);
10060        assert_eq!(wrong_broker.pending_count().await.unwrap(), 1);
10061        assert_eq!(local_role_changed.pending_count().await.unwrap(), 0);
10062        assert_eq!(remote_unproven.pending_count().await.unwrap(), 1);
10063        let good_child = store.load_session("receipt-good").await.unwrap().unwrap();
10064        let wrong_child = store
10065            .load_session("receipt-wrong-mailbox")
10066            .await
10067            .unwrap()
10068            .unwrap();
10069        let wrong_broker_child = store
10070            .load_session("receipt-wrong-broker")
10071            .await
10072            .unwrap()
10073            .unwrap();
10074        let local_role_changed_child = store
10075            .load_session("receipt-local-role-changed")
10076            .await
10077            .unwrap()
10078            .unwrap();
10079        let remote_unproven_child = store
10080            .load_session("receipt-remote-unproven")
10081            .await
10082            .unwrap()
10083            .unwrap();
10084        assert!(store
10085            .recover_broker_terminal_receipts(&good_child)
10086            .await
10087            .unwrap()
10088            .is_empty());
10089        assert_eq!(
10090            store
10091                .recover_broker_terminal_receipts(&wrong_child)
10092                .await
10093                .unwrap()
10094                .len(),
10095            1
10096        );
10097        assert_eq!(
10098            store
10099                .recover_broker_terminal_receipts(&wrong_broker_child)
10100                .await
10101                .unwrap()
10102                .len(),
10103            1
10104        );
10105        assert!(store
10106            .recover_broker_terminal_receipts(&local_role_changed_child)
10107            .await
10108            .unwrap()
10109            .is_empty());
10110        assert_eq!(
10111            store
10112                .recover_broker_terminal_receipts(&remote_unproven_child)
10113                .await
10114                .unwrap()
10115                .len(),
10116            1
10117        );
10118    }
10119
10120    #[tokio::test]
10121    async fn broker_receipt_confirmation_requires_exact_birth_run_and_committed_status() {
10122        let runner = ActorChildRunner::new(
10123            "test".into(),
10124            PathBuf::new(),
10125            vec![],
10126            PathBuf::new(),
10127            ExecutorSpec::BambooRuntime,
10128            vec![],
10129            "test".into(),
10130            1,
10131        );
10132        let home = tempfile::tempdir().unwrap();
10133        let store = Arc::new(
10134            bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
10135                .await
10136                .unwrap(),
10137        );
10138        let root = Session::new("ack-parent", "model");
10139        store.save_session(&root).await.unwrap();
10140        let mut child = Session::new_child_of("ack-child", &root, "model", "task");
10141        child.add_message(bamboo_agent_core::Message::user("work"));
10142        store.save_session(&child).await.unwrap();
10143        child.add_message(bamboo_agent_core::Message::assistant("done", None));
10144        child.set_last_run_status("completed");
10145        store
10146            .prepare_broker_terminal_receipt(
10147                &child,
10148                "run-1",
10149                "00000000-0000-4000-8000-000000000001",
10150                "p-ack-child",
10151                "broker-run-1",
10152                &["broker-outcome-1".into()],
10153            )
10154            .await
10155            .unwrap();
10156        store.save_session(&child).await.unwrap();
10157        runner.set_actor_directory_store(Some(store));
10158        let calls = Arc::new(AtomicUsize::new(0));
10159        insert_ack_probe(&runner, &child, &calls, TerminalStatus::Completed).await;
10160        runner
10161            .confirm_durable_child_delivery(&child, "wrong-run", true)
10162            .await
10163            .unwrap();
10164        let mut wrong_birth = child.clone();
10165        wrong_birth.created_at += chrono::Duration::seconds(1);
10166        runner
10167            .confirm_durable_child_delivery(&wrong_birth, "run-1", true)
10168            .await
10169            .unwrap();
10170        assert_eq!(runner.pending_durable_links.lock().await.len(), 1);
10171        child.set_last_run_status("completed");
10172        runner
10173            .confirm_durable_child_delivery(&child, "run-1", false)
10174            .await
10175            .unwrap();
10176        assert_eq!(calls.load(Ordering::SeqCst), 0);
10177
10178        insert_ack_probe(&runner, &child, &calls, TerminalStatus::Completed).await;
10179        child.set_last_run_status("error");
10180        runner
10181            .confirm_durable_child_delivery(&child, "run-1", true)
10182            .await
10183            .unwrap();
10184        assert_eq!(calls.load(Ordering::SeqCst), 0);
10185
10186        insert_ack_probe(&runner, &child, &calls, TerminalStatus::Completed).await;
10187        child.set_last_run_status("completed");
10188        runner
10189            .confirm_durable_child_delivery(&child, "run-1", true)
10190            .await
10191            .unwrap();
10192        assert_eq!(calls.load(Ordering::SeqCst), 1);
10193    }
10194
10195    #[tokio::test]
10196    async fn strict_completeness_gate_precedes_ack_and_ack_failure_preserves_cold_proof() {
10197        for commit in [false, true] {
10198            let runner = ActorChildRunner::new(
10199                "test".into(),
10200                PathBuf::new(),
10201                vec![],
10202                PathBuf::new(),
10203                ExecutorSpec::BambooRuntime,
10204                vec![],
10205                "test".into(),
10206                1,
10207            );
10208            let home = tempfile::tempdir().unwrap();
10209            let store = Arc::new(
10210                bamboo_storage::SessionStoreV2::new(home.path().into())
10211                    .await
10212                    .unwrap(),
10213            );
10214            let root = Session::new("strict-ack-parent", "model");
10215            store.save_session(&root).await.unwrap();
10216            let mut child = Session::new_child_of("strict-ack-child", &root, "model", "work");
10217            child.add_message(bamboo_agent_core::Message::user("work"));
10218            store.save_session(&child).await.unwrap();
10219            child.add_message(bamboo_agent_core::Message::assistant("finished", None));
10220            child.set_last_run_status("completed");
10221            let proof = bamboo_storage::v2::HostTerminalCompleteness::from_verified_host(
10222                &child, "run-1", 7, 2,
10223            )
10224            .unwrap();
10225            let creation = bamboo_subagent::proto::ChildCreationIdentity {
10226                created_at: child.created_at,
10227                spawn_depth: child.spawn_depth,
10228            };
10229            let history = StrictTerminalHistory {
10230                proof: proof.clone(),
10231                watermark: bamboo_subagent::ActorEventWatermark {
10232                    version: 1,
10233                    logical_session: Some(actor_event_logical_identity(
10234                        &child,
10235                        &root.id,
10236                        Some(&creation),
10237                    )),
10238                    activation_id: Some("run-1".into()),
10239                    execution_epoch: 7,
10240                    final_seq: 2,
10241                },
10242            };
10243            let parent_mailbox = "p-strict-ack-child";
10244            store
10245                .prepare_broker_terminal_receipt_with_completeness(
10246                    &child,
10247                    bamboo_storage::v2::BrokerTerminalRoute {
10248                        activation_run_id: "run-1",
10249                        broker_identity: "00000000-0000-4000-8000-000000000001",
10250                        parent_mailbox,
10251                        broker_correlation_id: "broker-run-1",
10252                        message_ids: &["broker-outcome-1".into()],
10253                    },
10254                    &proof,
10255                )
10256                .await
10257                .unwrap();
10258            store.save_session(&child).await.unwrap();
10259            runner.set_actor_directory_store(Some(store.clone()));
10260            let calls = Arc::new(AtomicUsize::new(0));
10261            runner.pending_durable_links.lock().await.insert(
10262                (child.id.clone(), child.created_at, "run-1".into()),
10263                PendingDurableChildLink {
10264                    link: Box::new(DurableAckProbe {
10265                        calls: calls.clone(),
10266                        fail: true,
10267                        terminal_status: TerminalStatus::Completed,
10268                        parent_mailbox: parent_mailbox.into(),
10269                    }),
10270                    host_nested_wait_handoff: false,
10271                    strict_history: Some(history),
10272                    strict_history_required: true,
10273                    strict_history_committed: false,
10274                },
10275            );
10276            if commit {
10277                runner
10278                    .commit_durable_child_delivery(&child, "run-1")
10279                    .await
10280                    .unwrap();
10281                assert_eq!(calls.load(Ordering::SeqCst), 0, "proof commit cannot ACK");
10282            }
10283            assert!(runner
10284                .confirm_durable_child_delivery(&child, "run-1", true)
10285                .await
10286                .is_err());
10287            assert_eq!(calls.load(Ordering::SeqCst), usize::from(commit));
10288            let cold = bamboo_storage::SessionStoreV2::new(home.path().into())
10289                .await
10290                .unwrap();
10291            let recovered = cold.recover_broker_terminal_receipts(&child).await;
10292            if commit {
10293                let receipts = recovered.unwrap();
10294                assert_eq!(receipts.len(), 1);
10295                assert_eq!(
10296                    receipts[0]
10297                        .terminal_completeness
10298                        .as_ref()
10299                        .unwrap()
10300                        .contiguous_applied_seq,
10301                    2
10302                );
10303                assert_eq!(
10304                    cold.load_session(&child.id)
10305                        .await
10306                        .unwrap()
10307                        .unwrap()
10308                        .last_run_status()
10309                        .as_deref(),
10310                    Some("completed")
10311                );
10312            } else {
10313                assert!(
10314                    recovered.is_err(),
10315                    "matching canonical history is not proof commitment"
10316                );
10317            }
10318            assert_eq!(
10319                serde_json::to_value(
10320                    cold.load_session(&child.id)
10321                        .await
10322                        .unwrap()
10323                        .unwrap()
10324                        .messages
10325                )
10326                .unwrap(),
10327                serde_json::to_value(&child.messages).unwrap(),
10328            );
10329        }
10330    }
10331
10332    #[tokio::test]
10333    async fn broker_receipt_confirms_canonical_worker_error_and_cancelled_status() {
10334        for (terminal_status, status) in [
10335            (TerminalStatus::Error, "error"),
10336            (TerminalStatus::Cancelled, "cancelled"),
10337        ] {
10338            let runner = ActorChildRunner::new(
10339                "test".into(),
10340                PathBuf::new(),
10341                vec![],
10342                PathBuf::new(),
10343                ExecutorSpec::BambooRuntime,
10344                vec![],
10345                "test".into(),
10346                1,
10347            );
10348            let home = tempfile::tempdir().unwrap();
10349            let store = Arc::new(
10350                bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
10351                    .await
10352                    .unwrap(),
10353            );
10354            let root = Session::new("failed-ack-parent", "model");
10355            store.save_session(&root).await.unwrap();
10356            let mut child = Session::new_child_of("failed-ack-child", &root, "model", "task");
10357            child.add_message(bamboo_agent_core::Message::user("work"));
10358            store.save_session(&child).await.unwrap();
10359            child.set_last_run_status(status);
10360            child.set_last_run_error(format!("worker {status}"));
10361            store
10362                .prepare_broker_terminal_receipt(
10363                    &child,
10364                    "run-1",
10365                    "00000000-0000-4000-8000-000000000001",
10366                    "p-failed-ack-child",
10367                    "broker-run-1",
10368                    &["broker-outcome-1".into()],
10369                )
10370                .await
10371                .unwrap();
10372            store.save_session(&child).await.unwrap();
10373            runner.set_actor_directory_store(Some(store));
10374            let calls = Arc::new(AtomicUsize::new(0));
10375            insert_ack_probe(&runner, &child, &calls, terminal_status).await;
10376            runner
10377                .confirm_durable_child_delivery(&child, "run-1", true)
10378                .await
10379                .unwrap();
10380            assert_eq!(calls.load(Ordering::SeqCst), 1, "{status}");
10381        }
10382    }
10383
10384    #[tokio::test]
10385    async fn broker_completed_terminal_projects_only_host_verified_nested_wait() {
10386        assert_eq!(
10387            effective_broker_terminal_status(TerminalStatus::Completed, true).unwrap(),
10388            "suspended"
10389        );
10390        for raw in [TerminalStatus::Error, TerminalStatus::Cancelled] {
10391            assert!(effective_broker_terminal_status(raw, true).is_err());
10392        }
10393        let runner = ActorChildRunner::new(
10394            "test".into(),
10395            PathBuf::new(),
10396            vec![],
10397            PathBuf::new(),
10398            ExecutorSpec::BambooRuntime,
10399            vec![],
10400            "test".into(),
10401            1,
10402        );
10403        let home = tempfile::tempdir().unwrap();
10404        let store = Arc::new(
10405            bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
10406                .await
10407                .unwrap(),
10408        );
10409        let root = Session::new("nested-ack-parent", "model");
10410        store.save_session(&root).await.unwrap();
10411        let mut child = Session::new_child_of("nested-ack-child", &root, "model", "task");
10412        child.add_message(bamboo_agent_core::Message::user("spawn a grandchild"));
10413        store.save_session(&child).await.unwrap();
10414        child.set_last_run_status("suspended");
10415        store
10416            .prepare_broker_terminal_receipt(
10417                &child,
10418                "run-1",
10419                "00000000-0000-4000-8000-000000000001",
10420                "p-nested-ack-child",
10421                "broker-run-1",
10422                &["broker-outcome-1".into()],
10423            )
10424            .await
10425            .unwrap();
10426        store.save_session(&child).await.unwrap();
10427        runner.set_actor_directory_store(Some(store.clone()));
10428        let calls = Arc::new(AtomicUsize::new(0));
10429
10430        insert_ack_probe(&runner, &child, &calls, TerminalStatus::Completed).await;
10431        runner
10432            .confirm_durable_child_delivery(&child, "run-1", true)
10433            .await
10434            .unwrap();
10435        assert_eq!(calls.load(Ordering::SeqCst), 0);
10436
10437        insert_ack_probe_with_nested_wait(&runner, &child, &calls, TerminalStatus::Completed, true)
10438            .await;
10439        runner
10440            .confirm_durable_child_delivery(&child, "run-1", true)
10441            .await
10442            .unwrap();
10443        assert_eq!(calls.load(Ordering::SeqCst), 1);
10444        assert_eq!(
10445            store
10446                .load_session(&child.id)
10447                .await
10448                .unwrap()
10449                .unwrap()
10450                .last_run_status()
10451                .as_deref(),
10452            Some("suspended")
10453        );
10454        assert!(store
10455            .recover_broker_terminal_receipts(&child)
10456            .await
10457            .unwrap()
10458            .is_empty());
10459    }
10460
10461    #[tokio::test]
10462    async fn host_completion_notifies_once_only_after_reply_and_finish_commit() {
10463        struct CommittedObserver {
10464            authority: PathBuf,
10465            events: std::sync::Mutex<Vec<PublicActorEvent>>,
10466        }
10467        impl ActorEventObserver for CommittedObserver {
10468            fn publish(&self, event: PublicActorEvent) {
10469                // Observe the real durable sidecar synchronously at publication.
10470                let entry: bamboo_domain::ActorDirectoryEntry =
10471                    serde_json::from_slice(&std::fs::read(&self.authority).unwrap()).unwrap();
10472                let committed = entry.activation.unwrap();
10473                assert_eq!(
10474                    committed.status,
10475                    bamboo_domain::ActorActivationStatus::Succeeded
10476                );
10477                assert_eq!(committed.activation_id, event.activation_id);
10478                assert!(committed.finished_at.is_some());
10479                self.events.lock().unwrap().push(event);
10480            }
10481        }
10482        struct CompletionLink(VecDeque<ChildFrame>);
10483        #[async_trait]
10484        impl bamboo_subagent::ChildLink for CompletionLink {
10485            async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
10486                Ok(())
10487            }
10488            async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
10489                Ok(self.0.pop_front())
10490            }
10491        }
10492        for cut in [
10493            "success",
10494            "no_terminal",
10495            "invalid_terminal",
10496            "stale",
10497            "storage_failure",
10498            "append_failure",
10499        ] {
10500            let temp = tempfile::tempdir().unwrap();
10501            let store = Arc::new(
10502                bamboo_storage::SessionStoreV2::new(temp.path().into())
10503                    .await
10504                    .unwrap(),
10505            );
10506            let root = Session::new("notify-root", "model");
10507            store.save_session(&root).await.unwrap();
10508            let mut child = Session::new_child_of("notify-child", &root, "model", "assignment");
10509            store.save_session(&child).await.unwrap();
10510            let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
10511                store.clone(),
10512                bamboo_domain::SessionInboxLimits::default(),
10513            ));
10514            let binding = actor_binding(
10515                store.clone(),
10516                inbox,
10517                Arc::new(bamboo_storage::LockedSessionStore::new(store.clone())),
10518            );
10519            let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
10520            bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
10521            let activation = PlainActorActivation::start(
10522                store.clone(),
10523                &child,
10524                &binding,
10525                Some("notify-run"),
10526                Some(policy),
10527                false,
10528                None,
10529            )
10530            .await
10531            .unwrap();
10532            let _owner = binding
10533                .router
10534                .register_run(&child.id, "notify-run")
10535                .await
10536                .unwrap();
10537            let batch = ActorEventBatch {
10538                logical_session: Some(LogicalSessionIdentity {
10539                    session_id: child.id.clone(),
10540                    parent_session_id: child.parent_session_id.clone(),
10541                    root_session_id: child.root_session_id.clone(),
10542                    creation: None,
10543                }),
10544                activation_id: Some("notify-run".into()),
10545                execution_epoch: 9,
10546                source_node_id: None,
10547                source_actor_id: Some("notify-worker".into()),
10548                first_seq: 1,
10549                last_seq: 2,
10550                qos: bamboo_subagent::ActorEventQos::Durable,
10551                events: vec![
10552                    serde_json::to_value(AgentEvent::Token {
10553                        content: "PRIVATE_PROVIDER_TOKEN".into(),
10554                    })
10555                    .unwrap(),
10556                    serde_json::to_value(AgentEvent::message_appended(
10557                        &child.id,
10558                        &bamboo_agent_core::Message::assistant("PRIVATE_WORKER_CACHE", None),
10559                    ))
10560                    .unwrap(),
10561                ],
10562            };
10563            let mut frames = VecDeque::from([ChildFrame::EventBatch { batch }]);
10564            if cut != "no_terminal" {
10565                let mut terminal = completed_actor_frame();
10566                if cut == "invalid_terminal" {
10567                    if let ChildFrame::Terminal { status, .. } = &mut terminal {
10568                        *status = TerminalStatus::Suspended;
10569                    }
10570                }
10571                frames.push_back(terminal);
10572            }
10573            let mut link = CompletionLink(frames);
10574            let (tx, mut rx) = mpsc::channel(8);
10575            let (_live, mut live_rx) = mpsc::unbounded_channel();
10576            let (_delivery, mut delivery_rx) = mpsc::unbounded_channel();
10577            let cancel = CancellationToken::new();
10578            let outcome = drive(ActorDriveContext {
10579                client: &mut link,
10580                parent_session_id: "notify-root",
10581                child_session_id: "notify-child",
10582                child_attempt: 0,
10583                approval_registry: None,
10584                approval_decider: None,
10585                approval_reviewer: None,
10586                escalation_bridge: None,
10587                event_tx: &tx,
10588                cancel_token: &cancel,
10589                live_rx: &mut live_rx,
10590                delivery_rx: &mut delivery_rx,
10591                logical_session: &mut child,
10592                expected_permission_posture: None,
10593                expected_creation: None,
10594                session_inbox_runtime: Some(&binding),
10595                actor_directory_store: Some(store.as_ref()),
10596                canonical_subagent_tool: None,
10597                ticket_service: None,
10598                activation_run_id: Some("notify-run"),
10599                execution_epoch: 9,
10600                expected_source_actor_id: "notify-worker",
10601                initial_inflight_claims: VecDeque::new(),
10602                plain_actor: true,
10603                remote_environment_lease: false,
10604                readonly_output: None,
10605                local_history_tools: None,
10606                local_history_read_only: false,
10607                strict_history_output: None,
10608                plain_input: Some(&activation),
10609                canonical_activation: None,
10610                canonical_placement_ref: None,
10611                actor_event_observer: None,
10612                plain_run: None,
10613                first_frame_timeout: Some(Duration::from_secs(1)),
10614            })
10615            .await;
10616            let admitted = !matches!(cut, "no_terminal" | "invalid_terminal");
10617            assert_eq!(outcome.is_ok(), admitted, "{cut}: {outcome:?}");
10618            assert_eq!(
10619                activation.completion_router.lock().unwrap().is_some(),
10620                admitted,
10621                "{cut}"
10622            );
10623            while rx.try_recv().is_ok() {}
10624            let authority = temp
10625                .path()
10626                .join(store.resolve_rel_path(&child.id).await.unwrap())
10627                .join("actor-authority.json");
10628            let observer = CommittedObserver {
10629                authority: authority.clone(),
10630                events: Default::default(),
10631            };
10632            if cut == "append_failure" {
10633                child.add_message(bamboo_agent_core::Message::user("uncommitted-prefix"));
10634                assert!(activation
10635                    .append_reply(&mut child, "PRIVATE_REPLY".into(), &tx)
10636                    .await
10637                    .is_err());
10638                activation
10639                    .finish(ActorActivationFinish::Failed)
10640                    .await
10641                    .unwrap();
10642                assert!(observer.events.lock().unwrap().is_empty());
10643                continue;
10644            }
10645            activation
10646                .append_reply(&mut child, "PRIVATE_REPLY".into(), &tx)
10647                .await
10648                .unwrap();
10649            assert!(
10650                rx.try_recv().is_ok(),
10651                "legacy content notification retained"
10652            );
10653            assert!(
10654                observer.events.lock().unwrap().is_empty(),
10655                "reply alone must not publish"
10656            );
10657            if cut == "stale" {
10658                store
10659                    .retire_actor(&child.id, chrono::Utc::now())
10660                    .await
10661                    .unwrap();
10662            } else if cut == "storage_failure" {
10663                // Make the canonical write path unreadable, rather than mocking
10664                // the store or allowing completion to fall back to ordinary save.
10665                std::fs::remove_file(&authority).unwrap();
10666                std::fs::create_dir(&authority).unwrap();
10667            }
10668            let result = activation
10669                .finish_with_observer(ActorActivationFinish::Succeeded, Some(&observer))
10670                .await;
10671            assert_eq!(
10672                result.is_ok(),
10673                matches!(cut, "success" | "no_terminal" | "invalid_terminal"),
10674                "{cut}"
10675            );
10676            let events = observer.events.lock().unwrap().clone();
10677            assert_eq!(events.len(), usize::from(cut == "success"), "{cut}");
10678            if let Some(event) = events.first() {
10679                assert_eq!(event.actor_id, child.id);
10680                assert_eq!(event.root_actor_id, root.id);
10681                assert_eq!(event.parent_actor_id, Some(root.id.clone()));
10682                assert_eq!(event.source_order.sequence, 3);
10683                let json = serde_json::to_string(event).unwrap();
10684                assert!(!json.contains("PRIVATE_") && !json.contains("notify-run"));
10685                let persisted = store.load_session(&child.id).await.unwrap().unwrap();
10686                assert_eq!(persisted.messages.last().unwrap().content, "PRIVATE_REPLY");
10687            }
10688            assert_eq!(
10689                activation
10690                    .finish_with_observer(ActorActivationFinish::Succeeded, Some(&observer))
10691                    .await
10692                    .is_ok(),
10693                result.is_ok(),
10694                "{cut}: exact finish retries its committed outcome"
10695            );
10696            assert_eq!(
10697                observer.events.lock().unwrap().len(),
10698                events.len(),
10699                "repeat finish cannot publish"
10700            );
10701        }
10702    }
10703
10704    async fn bind_local_control_plane(
10705        store: &bamboo_storage::SessionStoreV2,
10706        actor_id: &str,
10707        policy: &bamboo_tools::permission::PermissionConfig,
10708    ) {
10709        store
10710            .bind_control_plane(
10711                actor_id,
10712                bamboo_domain::ActorControlPlaneBinding {
10713                    policy_revision: Some(policy.policy_revision()),
10714                    placement_intent: Some(bamboo_domain::ActorPlacementIntent::Local),
10715                },
10716            )
10717            .await
10718            .unwrap();
10719    }
10720
10721    fn local_tool_sample(
10722        host: &Session,
10723        denied_write: bool,
10724    ) -> (LocalToolCollector, Vec<bamboo_agent_core::Message>) {
10725        local_tool_sample_with_reasoning(host, denied_write, None)
10726    }
10727
10728    fn local_tool_sample_with_reasoning(
10729        host: &Session,
10730        denied_write: bool,
10731        reasoning: Option<&str>,
10732    ) -> (LocalToolCollector, Vec<bamboo_agent_core::Message>) {
10733        use bamboo_agent_core::{FunctionCall, Message, ToolCall};
10734        let calls = ["Read", "Write"]
10735            .map(|name| ToolCall {
10736                id: format!("local-{name}"),
10737                tool_type: "function".into(),
10738                function: FunctionCall {
10739                    name: name.into(),
10740                    arguments: "{\"file_path\":\"answer.txt\"}".into(),
10741                },
10742            })
10743            .to_vec();
10744        let mut rows = vec![Message::assistant("checking", Some(calls))];
10745        rows[0].reasoning = reasoning.map(str::to_owned);
10746        let mut collector = LocalToolCollector::default();
10747        if let Some(reasoning) = reasoning {
10748            assert!(collector
10749                .event(
10750                    &serde_json::to_value(AgentEvent::ReasoningToken {
10751                        content: reasoning.into(),
10752                    })
10753                    .unwrap()
10754                )
10755                .unwrap());
10756        }
10757        for name in ["Read", "Write"] {
10758            let denied = name == "Write" && denied_write;
10759            let id = format!("local-{name}");
10760            if !denied {
10761                collector
10762                    .event(
10763                        &serde_json::to_value(AgentEvent::ToolStart {
10764                            tool_call_id: id.clone(),
10765                            tool_name: name.into(),
10766                            arguments: serde_json::json!({"file_path":"answer.txt"}),
10767                        })
10768                        .unwrap(),
10769                    )
10770                    .unwrap();
10771            }
10772            let text = if denied {
10773                "Error: native_tool_ceiling_denied: Write"
10774            } else {
10775                "actual file result"
10776            };
10777            let mut message = Message::tool_result_with_status(id.clone(), text, !denied);
10778            message.metadata = Some(serde_json::json!({"elapsed_ms":7,"tool_name":name,
10779                "is_mutating":name == "Write","auto_approved":name != "Write","success":!denied}));
10780            rows.push(message);
10781            let event = if denied {
10782                AgentEvent::ToolError {
10783                    tool_call_id: id,
10784                    error: "native_tool_ceiling_denied: Write".into(),
10785                }
10786            } else {
10787                AgentEvent::ToolComplete {
10788                    tool_call_id: id,
10789                    result: bamboo_agent_core::tools::ToolResult::text(true, text),
10790                }
10791            };
10792            collector
10793                .event(&serde_json::to_value(event).unwrap())
10794                .unwrap();
10795        }
10796        rows.push(Message::assistant("complete exact report", None));
10797        rows.last_mut().unwrap().reasoning = reasoning.map(str::to_owned);
10798        if let Some(reasoning) = reasoning {
10799            assert!(collector
10800                .event(
10801                    &serde_json::to_value(AgentEvent::ReasoningToken {
10802                        content: reasoning.into(),
10803                    })
10804                    .unwrap()
10805                )
10806                .unwrap());
10807        }
10808        let mut messages = host.messages.clone();
10809        if let Some(system) = messages
10810            .iter_mut()
10811            .find(|message| message.role == Role::System)
10812        {
10813            system.content = "worker-private system never imported".into();
10814        }
10815        messages.extend(rows.iter().cloned());
10816        let data = bamboo_subagent::proto::LocalToolMessages::Complete {
10817            version: 1,
10818            messages: messages
10819                .iter()
10820                .map(|message| serde_json::to_value(message).unwrap())
10821                .collect(),
10822        };
10823        assert!(!collector
10824            .event(&serde_json::to_value(data).unwrap())
10825            .unwrap());
10826        (collector, rows)
10827    }
10828
10829    #[test]
10830    fn ticket_task_event_requires_exact_host_callback_receipt() {
10831        let mut collector = LocalToolCollector::default();
10832        let args = serde_json::json!({"tasks":[{"content":"own step","status":"pending"}]});
10833        let start = serde_json::to_value(AgentEvent::ToolStart {
10834            tool_call_id: "task-call".into(),
10835            tool_name: "Task".into(),
10836            arguments: args.clone(),
10837        })
10838        .unwrap();
10839        let mut receipts = HashMap::new();
10840        assert!(collector.ticket_event(&start, &receipts).unwrap());
10841        collector.event(&start).unwrap();
10842        let complete = serde_json::to_value(AgentEvent::ToolComplete {
10843            tool_call_id: "task-call".into(),
10844            result: bamboo_agent_core::tools::ToolResult::text(true, "done"),
10845        })
10846        .unwrap();
10847        assert!(collector.ticket_event(&complete, &receipts).is_err());
10848        receipts.insert("other-call".into(), args.clone());
10849        assert!(collector.ticket_event(&complete, &receipts).is_err());
10850        receipts.insert("task-call".into(), serde_json::json!({"tasks":[]}));
10851        assert!(collector.ticket_event(&complete, &receipts).is_err());
10852        receipts.insert("task-call".into(), args);
10853        assert!(collector.ticket_event(&complete, &receipts).unwrap());
10854        let forged_root = serde_json::to_value(AgentEvent::TaskListUpdated {
10855            task_list: bamboo_tools::TaskTool::task_list_from_args(
10856                &serde_json::json!({"tasks":[{"content":"forged root","status":"completed"}]}),
10857                "root",
10858            )
10859            .unwrap(),
10860            version: Some(999),
10861        })
10862        .unwrap();
10863        assert!(!collector.ticket_event(&forged_root, &receipts).unwrap());
10864    }
10865
10866    #[test]
10867    fn ticket_question_terminal_requires_exact_host_receipt_and_tool_result() {
10868        use bamboo_agent_core::{FunctionCall, Message, ToolCall};
10869        let mut host = Session::new("question-child", "model");
10870        host.messages.push(Message::user("own assignment"));
10871        let question: bamboo_tickets::PendingRequest = serde_json::from_value(serde_json::json!({
10872            "id":"own-question", "work_id":"own-work", "assignment_id":"own-assignment",
10873            "generation":1, "contract_revision":1, "prompt_revision":1,
10874            "kind":{"kind":"question"}, "prompt":"精确问题", "status":"open",
10875            "answer":null, "consumed_attempt":null, "worker_packet_hash":"a".repeat(64), "updated_seq":3
10876        })).unwrap();
10877        let args = serde_json::json!({"tasks":[{"content":"own step","status":"blocked"}], "question":{"prompt":"精确问题"}});
10878        for case in ["exact", "no_host_receipt", "forged_request", "fake_report"] {
10879            let mut returned =
10880                serde_json::json!({"status":"waiting_for_answer", "request":question});
10881            if case == "forged_request" {
10882                returned["request"]["id"] = serde_json::json!("sibling-question");
10883            }
10884            let text = returned.to_string();
10885            let rows = [
10886                Message::assistant(
10887                    "",
10888                    Some(vec![ToolCall {
10889                        id: "own-call".into(),
10890                        tool_type: "function".into(),
10891                        function: FunctionCall {
10892                            name: "Task".into(),
10893                            arguments: args.to_string(),
10894                        },
10895                    }]),
10896                ),
10897                Message::tool_result_with_status("own-call", &text, true),
10898            ];
10899            let collector = LocalToolCollector {
10900                messages: Some(host.messages.iter().cloned().chain(rows).collect()),
10901                starts: HashMap::from([("own-call".into(), ("Task".into(), args.clone()))]),
10902                outcomes: HashMap::from([("own-call".into(), (true, text))]),
10903                ticket_yield: (case != "no_host_receipt")
10904                    .then(|| ("own-call".into(), question.clone())),
10905            };
10906            let terminal = if case == "fake_report" {
10907                Some("fake delivery")
10908            } else {
10909                Some("")
10910            };
10911            assert_eq!(
10912                collector
10913                    .suffix(&host, &["Task".into()], false, terminal)
10914                    .is_ok(),
10915                case == "exact",
10916                "{case}"
10917            );
10918        }
10919    }
10920
10921    #[test]
10922    fn local_tool_suffix_preserves_full_current_prefix_and_rejects_authority_or_collisions() {
10923        let mut host = Session::new("tool-history-prefix", "model");
10924        host.messages
10925            .push(bamboo_agent_core::Message::user("assignment"));
10926        let mut confirmed = bamboo_agent_core::Message::user("confirmed during Run");
10927        confirmed.metadata = Some(
10928            serde_json::json!({"session_message":{"id":confirmed.id,"target_session_id":host.id}}),
10929        );
10930        host.messages.push(confirmed);
10931        let tools = vec!["Read".into(), "Write".into()];
10932        let (good, rows) = local_tool_sample(&host, false);
10933        assert_eq!(
10934            serde_json::to_value(
10935                good.suffix(&host, &tools, false, Some("complete exact report"))
10936                    .unwrap()
10937            )
10938            .unwrap(),
10939            serde_json::to_value(&rows).unwrap()
10940        );
10941        for case in [
10942            "missing",
10943            "changed",
10944            "metadata",
10945            "system",
10946            "user",
10947            "collision",
10948            "dangling",
10949            "result",
10950            "terminal",
10951            "tool_metadata",
10952            "grant",
10953            "start_arguments",
10954            "ledger",
10955        ] {
10956            let (mut collector, _) = local_tool_sample(&host, false);
10957            let mut current = host.clone();
10958            let messages = collector.messages.as_mut().unwrap();
10959            match case {
10960                "missing" => {
10961                    messages.remove(host.messages.len() - 1);
10962                }
10963                "changed" => messages[0].content.push('!'),
10964                "metadata" => messages[1].metadata = None,
10965                "system" => messages.last_mut().unwrap().role = Role::System,
10966                "user" => messages.last_mut().unwrap().role = Role::User,
10967                "collision" => messages.last_mut().unwrap().id = host.messages[0].id.clone(),
10968                "dangling" => {
10969                    messages[host.messages.len() + 1].tool_call_id = Some("foreign".into())
10970                }
10971                "result" => messages[host.messages.len() + 1].content.push('!'),
10972                "tool_metadata" => {
10973                    messages[host.messages.len() + 1].metadata.as_mut().unwrap()["permission"] =
10974                        true.into()
10975                }
10976                "grant" => {
10977                    messages[host.messages.len()].tool_calls.as_mut().unwrap()[0]
10978                        .function
10979                        .name = "Bash".into()
10980                }
10981                "start_arguments" => {
10982                    collector.starts.get_mut("local-Read").unwrap().1 =
10983                        serde_json::json!({"file_path":"different.txt"})
10984                }
10985                "ledger" => {
10986                    current.append_provider_transcript_group(current.messages[0].id.clone(), None, vec![
10987                        bamboo_domain::session::provider_transcript::ProviderTranscriptItem::try_from_payload(
10988                            bamboo_domain::ProviderFamily::OpenAi,
10989                            bamboo_domain::ProviderProtocol::OpenAiResponsesV1,
10990                            bamboo_domain::session::provider_transcript::ProviderTranscriptOrigin::HostToolSearch,
10991                            bamboo_domain::session::provider_transcript::ProviderTranscriptAuthor::ToolResult,
10992                            serde_json::json!({"type":"tool_search_output","execution":"client","call_id":"search","status":"completed","tools":[]}),
10993                        ).unwrap(),
10994                    ]).unwrap();
10995                }
10996                "terminal" => {}
10997                _ => unreachable!(),
10998            }
10999            let terminal = if case == "terminal" {
11000                "different"
11001            } else {
11002                "complete exact report"
11003            };
11004            assert!(
11005                collector
11006                    .suffix(&current, &tools, false, Some(terminal))
11007                    .is_err(),
11008                "{case}"
11009            );
11010        }
11011        let (denied, _) = local_tool_sample(&host, true);
11012        assert!(denied
11013            .suffix(&host, &["Read".into()], true, Some("complete exact report"))
11014            .is_ok());
11015        let (mut contradicted, _) = local_tool_sample(&host, true);
11016        contradicted.starts.insert(
11017            "local-Write".into(),
11018            ("Read".into(), serde_json::json!({"file_path":"answer.txt"})),
11019        );
11020        assert!(contradicted
11021            .suffix(&host, &["Read".into()], true, Some("complete exact report"))
11022            .is_err());
11023        let (successful, _) = local_tool_sample(&host, false);
11024        assert!(successful
11025            .suffix(&host, &["Read".into()], true, Some("complete exact report"))
11026            .is_err());
11027        let mut empty = LocalToolCollector::default();
11028        assert!(empty
11029            .event(
11030                &serde_json::to_value(AgentEvent::ReasoningToken {
11031                    content: "private".into()
11032                })
11033                .unwrap()
11034            )
11035            .unwrap());
11036        assert!(
11037            empty
11038                .suffix(&host, &tools, false, Some("complete exact report"))
11039                .is_err(),
11040            "stream observations alone cannot commit history"
11041        );
11042        let (mut completed, _) = local_tool_sample(&host, false);
11043        assert!(completed.event(&serde_json::json!({"type":"local_client_tool_messages_v1","version":1,"messages":[]})).is_err());
11044    }
11045
11046    struct LocalToolConcurrentCheckpoint {
11047        real: SessionInboxRuntimeBinding,
11048        claim: SessionInboxClaim,
11049    }
11050    #[async_trait]
11051    impl RuntimeSessionPersistence for LocalToolConcurrentCheckpoint {
11052        async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
11053            self.real.persistence.save_runtime_session(session).await
11054        }
11055        async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
11056            // A real canonical admission wins after validation, before the
11057            // final append-safe checkpoint acquires its serialization lock.
11058            let mut latest = self.real.storage.load_session(&session.id).await?.unwrap();
11059            checkpoint_and_ack_canonical_claim(&self.real, &mut latest, &self.claim, None)
11060                .await
11061                .map_err(|_| std::io::Error::other("concurrent admission failed"))?;
11062            self.real
11063                .persistence
11064                .checkpoint_runtime_session(session)
11065                .await
11066        }
11067        async fn load_runtime_session(&self, id: &str) -> std::io::Result<Option<Session>> {
11068            self.real.storage.load_session(id).await
11069        }
11070    }
11071
11072    #[tokio::test]
11073    async fn local_tool_history_checkpoint_preserves_confirmed_input_and_cold_pairs_or_refuses() {
11074        for case in [
11075            "success",
11076            "reasoning",
11077            "concurrent_input",
11078            "checkpoint_error",
11079            "stale_prefix",
11080            "cancelled",
11081            "lost_owner",
11082        ] {
11083            let temp = tempfile::tempdir().unwrap();
11084            let store = Arc::new(
11085                bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
11086                    .await
11087                    .unwrap(),
11088            );
11089            let root = Session::new("local-tool-root", "model");
11090            store.save_session(&root).await.unwrap();
11091            let mut child = Session::new_child("local-tool-child", &root.id, "model", "child");
11092            let mut system = bamboo_agent_core::Message::user("Host-owned immutable System");
11093            system.role = Role::System;
11094            child.messages.push(system);
11095            child
11096                .messages
11097                .push(bamboo_agent_core::Message::user("assignment"));
11098            store.save_session(&child).await.unwrap();
11099            let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
11100                store.clone(),
11101                bamboo_domain::SessionInboxLimits::default(),
11102            ));
11103            let locked = Arc::new(bamboo_storage::LockedSessionStore::new(store.clone()));
11104            let binding = actor_binding(store.clone(), inbox.clone(), locked.clone());
11105            let mut owner = Some(
11106                binding
11107                    .router
11108                    .register_run(&child.id, "current-run")
11109                    .await
11110                    .unwrap(),
11111            );
11112            let envelope = bamboo_domain::SessionMessageEnvelope::user_input(
11113                &child.id,
11114                "current confirmed User",
11115            );
11116            let receipt = inbox.deliver(&envelope).await.unwrap();
11117            inbox
11118                .mark_activation_eligible(
11119                    &child.id,
11120                    receipt.generation,
11121                    bamboo_domain::SessionActivationPolicy::RespectSpecificWait,
11122                )
11123                .await
11124                .unwrap();
11125            let claim = inbox.claim(&child.id, 1).await.unwrap().remove(0);
11126            checkpoint_and_ack_canonical_claim(&binding, &mut child, &claim, None)
11127                .await
11128                .unwrap();
11129            assert!(inbox.was_admitted(&child.id, &envelope.id).await.unwrap());
11130            let before = serde_json::to_value(&child).unwrap();
11131            let reasoning = (case == "reasoning")
11132                .then_some("Complete plain reasoning before Read and the final reply 🪷.");
11133            let (collector, rows) = local_tool_sample_with_reasoning(&child, false, reasoning);
11134            let mut binding = binding;
11135            let late = bamboo_domain::SessionMessageEnvelope::user_input(
11136                &child.id,
11137                "concurrent confirmed User",
11138            );
11139            if case == "concurrent_input" {
11140                let receipt = inbox.deliver(&late).await.unwrap();
11141                inbox
11142                    .mark_activation_eligible(
11143                        &child.id,
11144                        receipt.generation,
11145                        bamboo_domain::SessionActivationPolicy::RespectSpecificWait,
11146                    )
11147                    .await
11148                    .unwrap();
11149                let late_claim = inbox.claim(&child.id, 1).await.unwrap().remove(0);
11150                binding.persistence = Arc::new(LocalToolConcurrentCheckpoint {
11151                    real: actor_binding(store.clone(), inbox.clone(), locked.clone()),
11152                    claim: late_claim,
11153                });
11154            }
11155            if case == "checkpoint_error" {
11156                binding.persistence = Arc::new(ActorFaultingPersistence {
11157                    inner: locked.clone(),
11158                    fail_checkpoint_once: AtomicBool::new(true),
11159                });
11160            }
11161            if case == "stale_prefix" {
11162                let mut latest = child.clone();
11163                latest
11164                    .messages
11165                    .push(bamboo_agent_core::Message::user("new confirmed prefix"));
11166                store.save_session(&latest).await.unwrap();
11167            }
11168            let cancel = CancellationToken::new();
11169            if case == "cancelled" {
11170                cancel.cancel();
11171            }
11172            if case == "lost_owner" {
11173                owner.take().unwrap().abandon().await;
11174            }
11175            let durable_before =
11176                serde_json::to_value(store.load_session(&child.id).await.unwrap()).unwrap();
11177            let result = commit_local_tool_history(
11178                collector,
11179                &mut child,
11180                &binding,
11181                "current-run",
11182                (&["Read".into(), "Write".into()], false),
11183                LocalToolTerminal::Completed(Some("complete exact report")),
11184                &cancel,
11185            )
11186            .await;
11187            let cold = bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
11188                .await
11189                .unwrap()
11190                .load_session(&child.id)
11191                .await
11192                .unwrap()
11193                .unwrap();
11194            if !matches!(case, "success" | "reasoning" | "concurrent_input") {
11195                assert!(result.is_err(), "{case}");
11196                assert_eq!(
11197                    serde_json::to_value(&child).unwrap(),
11198                    before,
11199                    "{case}: no partial mutable success"
11200                );
11201                assert_eq!(
11202                    serde_json::to_value(Some(&cold)).unwrap(),
11203                    durable_before,
11204                    "{case}: no write"
11205                );
11206                continue;
11207            }
11208            result.unwrap();
11209            assert_eq!(
11210                serde_json::to_value(&cold.messages[..3]).unwrap(),
11211                before["messages"]
11212            );
11213            let suffix_start = if case == "concurrent_input" {
11214                assert!(inbox.was_admitted(&child.id, &late.id).await.unwrap());
11215                assert_eq!(
11216                    serde_json::to_value(&cold.messages[3]).unwrap(),
11217                    serde_json::to_value(late.to_provider_message().unwrap()).unwrap()
11218                );
11219                assert!(child
11220                    .messages
11221                    .iter()
11222                    .any(|message| message.id == late.id.as_str()));
11223                4
11224            } else {
11225                3
11226            };
11227            assert_eq!(
11228                serde_json::to_value(&cold.messages[suffix_start..]).unwrap(),
11229                serde_json::to_value(rows).unwrap()
11230            );
11231            assert_eq!(
11232                cold.messages
11233                    .iter()
11234                    .filter(|message| message.id == envelope.id.as_str())
11235                    .count(),
11236                1
11237            );
11238            assert_eq!(
11239                cold.messages
11240                    .iter()
11241                    .filter(|message| message.content == "complete exact report")
11242                    .count(),
11243                1
11244            );
11245            assert_eq!(cold.metadata, child.metadata);
11246            assert!(cold.provider_transcript.is_empty());
11247        }
11248    }
11249
11250    #[tokio::test]
11251    async fn local_tool_completion_requires_current_ordered_batch_and_never_falls_back_to_text() {
11252        for case in [
11253            "legacy",
11254            "missing",
11255            "gap",
11256            "duplicate",
11257            "epoch",
11258            "source",
11259            "birth",
11260            "qos",
11261            "ticket_forged_success",
11262        ] {
11263            let mut session = Session::new_child("frame-child", "frame-root", "model", "child");
11264            session
11265                .messages
11266                .push(bamboo_agent_core::Message::user("original"));
11267            if case == "ticket_forged_success" {
11268                session.metadata.insert(
11269                    crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY.into(),
11270                    "assignment".into(),
11271                );
11272            }
11273            let before = serde_json::to_value(&session).unwrap();
11274            let creation = bamboo_subagent::proto::ChildCreationIdentity {
11275                created_at: session.created_at,
11276                spawn_depth: session.spawn_depth,
11277            };
11278            let mut messages = session.messages.clone();
11279            messages.push(bamboo_agent_core::Message::assistant("complete", None));
11280            let event = serde_json::to_value(bamboo_subagent::proto::LocalToolMessages::Complete {
11281                version: 1,
11282                messages: messages
11283                    .iter()
11284                    .map(|message| serde_json::to_value(message).unwrap())
11285                    .collect(),
11286            })
11287            .unwrap();
11288            let mut batch = ActorEventBatch {
11289                logical_session: Some(LogicalSessionIdentity {
11290                    session_id: session.id.clone(),
11291                    parent_session_id: session.parent_session_id.clone(),
11292                    root_session_id: session.root_session_id.clone(),
11293                    creation: Some(creation.clone()),
11294                }),
11295                activation_id: Some("current".into()),
11296                execution_epoch: 7,
11297                source_node_id: None,
11298                source_actor_id: Some("selected".into()),
11299                first_seq: 1,
11300                last_seq: 1,
11301                qos: bamboo_subagent::proto::ActorEventQos::Durable,
11302                events: vec![event.clone()],
11303            };
11304            match case {
11305                "gap" => {
11306                    batch.first_seq = 2;
11307                    batch.last_seq = 2;
11308                }
11309                "epoch" => batch.execution_epoch = 6,
11310                "source" => batch.source_actor_id = Some("replaced".into()),
11311                "birth" => {
11312                    batch
11313                        .logical_session
11314                        .as_mut()
11315                        .unwrap()
11316                        .creation
11317                        .as_mut()
11318                        .unwrap()
11319                        .created_at += chrono::Duration::nanoseconds(1)
11320                }
11321                "qos" => batch.qos = bamboo_subagent::proto::ActorEventQos::Ephemeral,
11322                "ticket_forged_success" => {
11323                    batch.last_seq = 2;
11324                    batch.events = vec![
11325                        serde_json::to_value(AgentEvent::ToolStart {
11326                            tool_call_id: "forged".into(), tool_name: "Task".into(),
11327                            arguments: serde_json::json!({"tasks":[{"content":"own step","status":"pending"}]}),
11328                        }).unwrap(),
11329                        serde_json::to_value(AgentEvent::ToolComplete {
11330                            tool_call_id: "forged".into(),
11331                            result: bamboo_agent_core::tools::ToolResult::text(true, "uncommitted"),
11332                        }).unwrap(),
11333                    ];
11334                }
11335                _ => {}
11336            }
11337            let terminal = ChildFrame::Terminal {
11338                status: TerminalStatus::Completed,
11339                result: Some("complete".into()),
11340                error: None,
11341                final_event_watermark: None,
11342                transcript: vec![],
11343            };
11344            let frames = match case {
11345                "legacy" => vec![ChildFrame::Event { event }, terminal],
11346                "missing" => vec![terminal],
11347                "duplicate" => vec![
11348                    ChildFrame::EventBatch {
11349                        batch: batch.clone(),
11350                    },
11351                    ChildFrame::EventBatch { batch },
11352                    terminal,
11353                ],
11354                _ => vec![ChildFrame::EventBatch { batch }, terminal],
11355            };
11356            let mut link = ConfirmationSequenceLink {
11357                frames: frames.into(),
11358                sent: vec![],
11359            };
11360            let (tx, mut rx) = mpsc::channel(32);
11361            let (_live, mut live_rx) = mpsc::unbounded_channel();
11362            let (_delivery, mut delivery_rx) = mpsc::unbounded_channel();
11363            let cancel = CancellationToken::new();
11364            let tools = vec![if case == "ticket_forged_success" {
11365                "Task"
11366            } else {
11367                "Read"
11368            }
11369            .into()];
11370            let outcome = drive(ActorDriveContext {
11371                client: &mut link,
11372                parent_session_id: "frame-root",
11373                child_session_id: "frame-child",
11374                child_attempt: 0,
11375                approval_registry: None,
11376                approval_decider: None,
11377                approval_reviewer: None,
11378                escalation_bridge: None,
11379                event_tx: &tx,
11380                cancel_token: &cancel,
11381                live_rx: &mut live_rx,
11382                delivery_rx: &mut delivery_rx,
11383                logical_session: &mut session,
11384                expected_permission_posture: None,
11385                expected_creation: Some(&creation),
11386                session_inbox_runtime: None,
11387                actor_directory_store: None,
11388                canonical_subagent_tool: None,
11389                ticket_service: None,
11390                activation_run_id: Some("current"),
11391                execution_epoch: 7,
11392                expected_source_actor_id: "selected",
11393                initial_inflight_claims: vec![].into(),
11394                plain_actor: false,
11395                remote_environment_lease: false,
11396                readonly_output: None,
11397                local_history_tools: Some(&tools),
11398                local_history_read_only: true,
11399                strict_history_output: None,
11400                plain_input: None,
11401                canonical_activation: None,
11402                canonical_placement_ref: None,
11403                actor_event_observer: None,
11404                plain_run: None,
11405                first_frame_timeout: Some(Duration::from_secs(1)),
11406            })
11407            .await;
11408            assert!(outcome.is_err(), "{case}: no last-text success fallback");
11409            if case == "ticket_forged_success" {
11410                assert!(outcome
11411                    .unwrap_err()
11412                    .to_string()
11413                    .contains("no exact Host receipt"));
11414                assert!(matches!(
11415                    rx.try_recv().unwrap(),
11416                    AgentEvent::ToolStart { .. }
11417                ));
11418            }
11419            assert_eq!(serde_json::to_value(&session).unwrap(), before, "{case}");
11420            assert!(link.sent.is_empty());
11421            assert!(rx.try_recv().is_err(), "DATA must never enter public feed");
11422        }
11423    }
11424
11425    struct HistoryTerminalProbe {
11426        frames: VecDeque<ChildFrame>,
11427        accepted: Option<TerminalStatus>,
11428        acks: usize,
11429    }
11430
11431    #[async_trait]
11432    impl bamboo_subagent::ChildLink for HistoryTerminalProbe {
11433        async fn send(&mut self, _frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
11434            Ok(())
11435        }
11436        async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
11437            Ok(self.frames.pop_front())
11438        }
11439        fn accept_durable_terminal(&mut self, status: TerminalStatus) {
11440            self.accepted = Some(status);
11441        }
11442        fn durable_delivery_receipt(&self) -> Option<bamboo_subagent::DurableChildDeliveryReceipt> {
11443            Some(bamboo_subagent::DurableChildDeliveryReceipt {
11444                broker_identity: "fixture".into(),
11445                parent_mailbox: "parent".into(),
11446                correlation_id: "current".into(),
11447                message_ids: vec!["tail".into(), "terminal".into()],
11448                terminal_status: self.accepted?,
11449            })
11450        }
11451        async fn acknowledge_durable_frames(&mut self) -> bamboo_subagent::TransportResult<()> {
11452            self.acks += 1;
11453            Ok(())
11454        }
11455    }
11456
11457    struct CancelConcurrentInputCheckpoint {
11458        real: SessionInboxRuntimeBinding,
11459        envelope: bamboo_domain::SessionMessageEnvelope,
11460    }
11461    #[async_trait]
11462    impl RuntimeSessionPersistence for CancelConcurrentInputCheckpoint {
11463        async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
11464            self.real.persistence.save_runtime_session(session).await
11465        }
11466        async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
11467            let receipt = self
11468                .real
11469                .inbox
11470                .deliver(&self.envelope)
11471                .await
11472                .map_err(|_| std::io::Error::other("concurrent delivery failed"))?;
11473            self.real
11474                .inbox
11475                .mark_activation_eligible(
11476                    &session.id,
11477                    receipt.generation,
11478                    bamboo_domain::SessionActivationPolicy::RespectSpecificWait,
11479                )
11480                .await
11481                .map_err(|_| std::io::Error::other("concurrent activation failed"))?;
11482            let claim = self
11483                .real
11484                .inbox
11485                .claim(&session.id, 1)
11486                .await
11487                .map_err(|_| std::io::Error::other("concurrent claim failed"))?
11488                .remove(0);
11489            let mut latest = self.real.storage.load_session(&session.id).await?.unwrap();
11490            checkpoint_and_ack_canonical_claim(&self.real, &mut latest, &claim, None)
11491                .await
11492                .map_err(|_| std::io::Error::other("concurrent admission failed"))?;
11493            self.real
11494                .persistence
11495                .checkpoint_runtime_session(session)
11496                .await
11497        }
11498        async fn load_runtime_session(&self, id: &str) -> std::io::Result<Option<Session>> {
11499            self.real.storage.load_session(id).await
11500        }
11501    }
11502
11503    struct CancelHistoryProbe {
11504        inner: HistoryTerminalProbe,
11505        cancel_requested: bool,
11506    }
11507
11508    #[async_trait]
11509    impl bamboo_subagent::ChildLink for CancelHistoryProbe {
11510        async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
11511            self.cancel_requested |= matches!(frame, ParentFrame::Cancel);
11512            Ok(())
11513        }
11514        async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
11515            if !self.cancel_requested {
11516                std::future::pending().await
11517            } else {
11518                self.inner.next_frame().await
11519            }
11520        }
11521        fn accept_durable_terminal(&mut self, status: TerminalStatus) {
11522            self.inner.accept_durable_terminal(status);
11523        }
11524        fn durable_delivery_receipt(&self) -> Option<bamboo_subagent::DurableChildDeliveryReceipt> {
11525            self.inner.durable_delivery_receipt()
11526        }
11527        async fn acknowledge_durable_frames(&mut self) -> bamboo_subagent::TransportResult<()> {
11528            self.inner.acknowledge_durable_frames().await
11529        }
11530    }
11531
11532    #[tokio::test]
11533    async fn selected_cancelled_read_history_requires_checkpointed_complete_prefix() {
11534        use bamboo_subagent::ChildLink;
11535        for case in [
11536            "success",
11537            "empty",
11538            "missing",
11539            "unclosed",
11540            "unobserved",
11541            "prefix",
11542            "metadata",
11543            "write",
11544            "not_read_only",
11545            "watermark",
11546            "epoch",
11547            "birth",
11548            "permission",
11549            "inbox",
11550            "pending",
11551            "pending_eligible",
11552            "claimed",
11553            "inspect_error",
11554            "checkpoint_error",
11555            "lost_owner",
11556            "concurrent_input",
11557        ] {
11558            let temp = tempfile::tempdir().unwrap();
11559            let store = Arc::new(
11560                bamboo_storage::SessionStoreV2::new(temp.path().into())
11561                    .await
11562                    .unwrap(),
11563            );
11564            let root = Session::new("cancel-history-root", "model");
11565            store.save_session(&root).await.unwrap();
11566            let mut child = Session::new_child("cancel-history-child", &root.id, "model", "child");
11567            child
11568                .messages
11569                .push(bamboo_agent_core::Message::user("exact assignment"));
11570            child.metadata.insert(
11571                bamboo_subagent::proto::WORKFLOW_USAGE_REQUESTED_KEY.into(),
11572                "true".into(),
11573            );
11574            store.save_session(&child).await.unwrap();
11575            let before = serde_json::to_value(&child.messages).unwrap();
11576            let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
11577                store.clone(),
11578                bamboo_domain::SessionInboxLimits::default(),
11579            ));
11580            let locked = Arc::new(bamboo_storage::LockedSessionStore::new(store.clone()));
11581            let mut binding = actor_binding(store.clone(), inbox.clone(), locked.clone());
11582            let mut owner = Some(
11583                binding
11584                    .router
11585                    .register_run(&child.id, "current")
11586                    .await
11587                    .unwrap(),
11588            );
11589            let creation = bamboo_subagent::proto::ChildCreationIdentity {
11590                created_at: child.created_at,
11591                spawn_depth: child.spawn_depth,
11592            };
11593            let identity = actor_event_logical_identity(&child, &root.id, Some(&creation));
11594            let tool = if case == "write" { "Write" } else { "Read" };
11595            let arguments = serde_json::json!({"file_path":"assigned.txt"});
11596            let call = bamboo_agent_core::ToolCall {
11597                id: "cancel-read".into(),
11598                tool_type: "function".into(),
11599                function: bamboo_agent_core::FunctionCall {
11600                    name: tool.into(),
11601                    arguments: arguments.to_string(),
11602                },
11603            };
11604            let mut rows = child.messages.clone();
11605            let mut events = Vec::new();
11606            if case != "empty" {
11607                rows.push(bamboo_agent_core::Message::assistant("", Some(vec![call])));
11608                events.push(
11609                    serde_json::to_value(AgentEvent::ToolStart {
11610                        tool_call_id: "cancel-read".into(),
11611                        tool_name: tool.into(),
11612                        arguments,
11613                    })
11614                    .unwrap(),
11615                );
11616                if case != "unclosed" {
11617                    let mut result = bamboo_agent_core::Message::tool_result_with_status(
11618                        "cancel-read",
11619                        "exact Read result",
11620                        true,
11621                    );
11622                    if case == "metadata" {
11623                        result.metadata = Some(serde_json::json!({"permission":true}));
11624                    }
11625                    rows.push(result);
11626                    events.push(
11627                        serde_json::to_value(AgentEvent::ToolComplete {
11628                            tool_call_id: "cancel-read".into(),
11629                            result: bamboo_agent_core::tools::ToolResult::text(
11630                                true,
11631                                "exact Read result",
11632                            ),
11633                        })
11634                        .unwrap(),
11635                    );
11636                }
11637            }
11638            if case == "unobserved" {
11639                events.clear();
11640            }
11641            if case == "prefix" {
11642                rows[0].content.push('!');
11643            }
11644            events.push(
11645                serde_json::to_value(bamboo_subagent::proto::LocalToolMessages::Complete {
11646                    version: 1,
11647                    messages: rows
11648                        .iter()
11649                        .map(|row| serde_json::to_value(row).unwrap())
11650                        .collect(),
11651                })
11652                .unwrap(),
11653            );
11654            if case == "missing" {
11655                events.clear();
11656            }
11657            let final_seq = events.len() as u64;
11658            let mut watermark = bamboo_subagent::ActorEventWatermark {
11659                version: 1,
11660                logical_session: Some(identity.clone()),
11661                activation_id: Some("current".into()),
11662                execution_epoch: 7,
11663                final_seq,
11664            };
11665            match case {
11666                "watermark" => watermark.final_seq += 1,
11667                "epoch" => watermark.execution_epoch -= 1,
11668                "birth" => {
11669                    watermark
11670                        .logical_session
11671                        .as_mut()
11672                        .unwrap()
11673                        .creation
11674                        .as_mut()
11675                        .unwrap()
11676                        .created_at += chrono::Duration::nanoseconds(1)
11677                }
11678                _ => {}
11679            }
11680            let mut frames = VecDeque::new();
11681            if !events.is_empty() {
11682                frames.push_back(ChildFrame::EventBatch {
11683                    batch: ActorEventBatch {
11684                        logical_session: Some(identity),
11685                        activation_id: Some("current".into()),
11686                        execution_epoch: 7,
11687                        source_node_id: None,
11688                        source_actor_id: Some("selected".into()),
11689                        first_seq: 1,
11690                        last_seq: final_seq,
11691                        qos: bamboo_subagent::ActorEventQos::Durable,
11692                        events,
11693                    },
11694                });
11695            }
11696            frames.push_back(ChildFrame::Terminal {
11697                status: TerminalStatus::Cancelled,
11698                result: None,
11699                error: None,
11700                transcript: vec![],
11701                final_event_watermark: Some(watermark),
11702            });
11703            let mut claims = VecDeque::new();
11704            let pending_input =
11705                bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "new canonical input");
11706            if matches!(case, "inbox" | "pending" | "pending_eligible" | "claimed") {
11707                let receipt = inbox.deliver(&pending_input).await.unwrap();
11708                if case != "pending" {
11709                    inbox
11710                        .mark_activation_eligible(
11711                            &child.id,
11712                            receipt.generation,
11713                            bamboo_domain::SessionActivationPolicy::RespectSpecificWait,
11714                        )
11715                        .await
11716                        .unwrap();
11717                }
11718                if matches!(case, "inbox" | "claimed") {
11719                    let claim = inbox.claim(&child.id, 1).await.unwrap().remove(0);
11720                    if case == "inbox" {
11721                        claims.push_back(claim);
11722                    }
11723                }
11724            }
11725            if case == "concurrent_input" {
11726                binding.persistence = Arc::new(CancelConcurrentInputCheckpoint {
11727                    real: actor_binding(store.clone(), inbox.clone(), locked.clone()),
11728                    envelope: pending_input.clone(),
11729                });
11730            }
11731            if case == "inspect_error" {
11732                let rel = store.resolve_rel_path(&child.id).await.unwrap();
11733                let dir = store.bamboo_home_dir().join(rel).join("inbox");
11734                std::fs::create_dir_all(&dir).unwrap();
11735                std::fs::create_dir(dir.join(".session-inbox.lock")).unwrap();
11736                assert!(inbox.inspect(&child.id).await.is_err());
11737            }
11738            let backlog_before =
11739                if matches!(case, "inbox" | "pending" | "pending_eligible" | "claimed") {
11740                    Some(inbox.inspect(&child.id).await.unwrap())
11741                } else {
11742                    None
11743                };
11744            if case == "checkpoint_error" {
11745                binding.persistence = Arc::new(ActorFaultingPersistence {
11746                    inner: locked.clone(),
11747                    fail_checkpoint_once: AtomicBool::new(true),
11748                });
11749            }
11750            if case == "lost_owner" {
11751                owner.take().unwrap().abandon().await;
11752            }
11753            let mut link = CancelHistoryProbe {
11754                inner: HistoryTerminalProbe {
11755                    frames,
11756                    accepted: None,
11757                    acks: 0,
11758                },
11759                cancel_requested: false,
11760            };
11761            let (tx, mut rx) = mpsc::channel(32);
11762            let (_live, mut live_rx) = mpsc::unbounded_channel();
11763            let (_delivery, mut delivery_rx) = mpsc::unbounded_channel();
11764            let cancel = CancellationToken::new();
11765            cancel.cancel();
11766            let tools = vec!["Read".into(), "Glob".into()];
11767            let mut strict_history = None;
11768            let outcome = drive(ActorDriveContext {
11769                client: &mut link,
11770                parent_session_id: &root.id,
11771                child_session_id: &child.id.clone(),
11772                child_attempt: 0,
11773                approval_registry: None,
11774                approval_decider: None,
11775                approval_reviewer: None,
11776                escalation_bridge: None,
11777                event_tx: &tx,
11778                cancel_token: &cancel,
11779                live_rx: &mut live_rx,
11780                delivery_rx: &mut delivery_rx,
11781                logical_session: &mut child,
11782                expected_permission_posture: (case == "permission")
11783                    .then(|| expected_default_permission_posture(7)),
11784                expected_creation: Some(&creation),
11785                session_inbox_runtime: Some(&binding),
11786                actor_directory_store: None,
11787                canonical_subagent_tool: None,
11788                ticket_service: None,
11789                activation_run_id: Some("current"),
11790                execution_epoch: 7,
11791                expected_source_actor_id: "selected",
11792                initial_inflight_claims: claims,
11793                plain_actor: false,
11794                remote_environment_lease: false,
11795                readonly_output: None,
11796                local_history_tools: Some(&tools),
11797                local_history_read_only: case != "not_read_only",
11798                strict_history_output: Some(&mut strict_history),
11799                plain_input: None,
11800                canonical_activation: None,
11801                canonical_placement_ref: None,
11802                actor_event_observer: None,
11803                plain_run: None,
11804                first_frame_timeout: None,
11805            })
11806            .await;
11807            let proven = matches!(case, "success" | "empty");
11808            assert_eq!(strict_history.is_some(), proven, "{case}");
11809            assert_eq!(link.durable_delivery_receipt().is_some(), proven, "{case}");
11810            assert_eq!(
11811                link.inner.acks, 0,
11812                "{case}: pump cannot ACK before SDK save/proof"
11813            );
11814            let cold = bamboo_storage::SessionStoreV2::new(temp.path().into())
11815                .await
11816                .unwrap()
11817                .load_session(&child.id)
11818                .await
11819                .unwrap()
11820                .unwrap();
11821            if proven {
11822                assert!(matches!(outcome, Err(AgentError::Cancelled)), "{case}");
11823                assert_eq!(link.inner.accepted, Some(TerminalStatus::Cancelled));
11824                let history = strict_history.unwrap();
11825                assert_eq!(history.proof.execution_epoch(), 7);
11826                assert_eq!(history.proof.contiguous_applied_seq(), final_seq);
11827                assert_eq!(
11828                    serde_json::to_value(&cold.messages).unwrap(),
11829                    serde_json::to_value(&rows).unwrap()
11830                );
11831            } else {
11832                assert!(outcome.is_err(), "{case}");
11833                if case == "concurrent_input" {
11834                    assert!(
11835                        cold.messages
11836                            .iter()
11837                            .any(|message| message.id == pending_input.id.as_str()),
11838                        "new input must survive refusal"
11839                    );
11840                } else {
11841                    assert_eq!(
11842                        serde_json::to_value(&cold.messages).unwrap(),
11843                        before,
11844                        "{case}: no unvalidated history write"
11845                    );
11846                }
11847            }
11848            if let Some(backlog) = backlog_before {
11849                assert!(!inbox
11850                    .was_admitted(&child.id, &pending_input.id)
11851                    .await
11852                    .unwrap());
11853                assert_eq!(
11854                    inbox.inspect(&child.id).await.unwrap(),
11855                    backlog,
11856                    "{case}: proof refusal must neither claim nor ACK the input"
11857                );
11858            }
11859            while let Ok(event) = rx.try_recv() {
11860                assert!(
11861                    !matches!(event, AgentEvent::SessionHistoryCommitted { .. }),
11862                    "private DATA is not a canonical public barrier"
11863                );
11864            }
11865        }
11866    }
11867
11868    #[tokio::test]
11869    async fn strict_terminal_watermark_gates_history_commit_and_durable_acceptance() {
11870        use bamboo_subagent::ChildLink;
11871        for case in [
11872            "success",
11873            "tail",
11874            "short",
11875            "missing",
11876            "version",
11877            "activation",
11878            "epoch",
11879            "birth",
11880            "duplicate",
11881            "overlap",
11882            "reorder",
11883            "midgap",
11884            "qos",
11885            "zero_missing",
11886            "zero_valid",
11887            "error_tail",
11888            "cancelled_tail",
11889            "cancelled_valid",
11890            "cancelled_zero_valid",
11891            "error_valid",
11892            "error_zero_valid",
11893            "ordinary_lossy",
11894        ] {
11895            let temp = tempfile::tempdir().unwrap();
11896            let store = Arc::new(
11897                bamboo_storage::SessionStoreV2::new(temp.path().into())
11898                    .await
11899                    .unwrap(),
11900            );
11901            let root = Session::new("watermark-root", "model");
11902            store.save_session(&root).await.unwrap();
11903            let mut child = Session::new_child("watermark-child", &root.id, "model", "child");
11904            child
11905                .messages
11906                .push(bamboo_agent_core::Message::user("assignment"));
11907            store.save_session(&child).await.unwrap();
11908            let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
11909                store.clone(),
11910                bamboo_domain::SessionInboxLimits::default(),
11911            ));
11912            let locked = Arc::new(bamboo_storage::LockedSessionStore::new(store.clone()));
11913            let binding = actor_binding(store.clone(), inbox, locked);
11914            let _owner = binding
11915                .router
11916                .register_run(&child.id, "current")
11917                .await
11918                .unwrap();
11919            let before = serde_json::to_value(&child).unwrap();
11920            let creation = bamboo_subagent::proto::ChildCreationIdentity {
11921                created_at: child.created_at,
11922                spawn_depth: child.spawn_depth,
11923            };
11924            let identity = actor_event_logical_identity(&child, &root.id, Some(&creation));
11925            let mut rows = child.messages.clone();
11926            rows.push(bamboo_agent_core::Message::assistant("complete", None));
11927            let data = serde_json::to_value(bamboo_subagent::proto::LocalToolMessages::Complete {
11928                version: 1,
11929                messages: rows
11930                    .iter()
11931                    .map(|row| serde_json::to_value(row).unwrap())
11932                    .collect(),
11933            })
11934            .unwrap();
11935            let batch = |seq, event, qos| ActorEventBatch {
11936                logical_session: Some(identity.clone()),
11937                activation_id: Some("current".into()),
11938                execution_epoch: 7,
11939                source_node_id: None,
11940                source_actor_id: Some("selected".into()),
11941                first_seq: seq,
11942                last_seq: seq,
11943                qos,
11944                events: vec![event],
11945            };
11946            let durable = bamboo_subagent::ActorEventQos::Durable;
11947            let token = serde_json::json!({"type":"token", "content":"observation"});
11948            let mut frames = vec![ChildFrame::EventBatch {
11949                batch: batch(1, data.clone(), durable),
11950            }];
11951            let mut marker = bamboo_subagent::ActorEventWatermark {
11952                version: 1,
11953                logical_session: Some(identity.clone()),
11954                activation_id: Some("current".into()),
11955                execution_epoch: 7,
11956                final_seq: 1,
11957            };
11958            match case {
11959                "tail" | "error_tail" | "cancelled_tail" => marker.final_seq = 2,
11960                "short" => marker.final_seq = 0,
11961                "version" => marker.version = 99,
11962                "activation" => marker.activation_id = Some("old".into()),
11963                "epoch" => marker.execution_epoch = 6,
11964                "birth" => {
11965                    marker
11966                        .logical_session
11967                        .as_mut()
11968                        .unwrap()
11969                        .creation
11970                        .as_mut()
11971                        .unwrap()
11972                        .created_at += chrono::Duration::nanoseconds(1)
11973                }
11974                "duplicate" => frames.push(frames[0].clone()),
11975                "overlap" | "midgap" | "qos" => {
11976                    frames = vec![
11977                        ChildFrame::EventBatch {
11978                            batch: batch(
11979                                1,
11980                                token.clone(),
11981                                if case == "qos" {
11982                                    bamboo_subagent::ActorEventQos::Ephemeral
11983                                } else {
11984                                    durable
11985                                },
11986                            ),
11987                        },
11988                        ChildFrame::EventBatch {
11989                            batch: batch(
11990                                if case == "overlap" {
11991                                    1
11992                                } else if case == "midgap" {
11993                                    3
11994                                } else {
11995                                    2
11996                                },
11997                                data,
11998                                durable,
11999                            ),
12000                        },
12001                    ];
12002                    marker.final_seq = 2;
12003                }
12004                "reorder" => {
12005                    frames = vec![
12006                        ChildFrame::EventBatch {
12007                            batch: batch(2, data, durable),
12008                        },
12009                        ChildFrame::EventBatch {
12010                            batch: batch(1, token, durable),
12011                        },
12012                    ]
12013                }
12014                "zero_missing" | "zero_valid" | "error_zero_valid" | "cancelled_zero_valid" => {
12015                    frames.clear();
12016                    marker.final_seq = 0;
12017                }
12018                "ordinary_lossy" => {
12019                    frames = vec![ChildFrame::EventBatch {
12020                        batch: batch(3, token, bamboo_subagent::ActorEventQos::Ephemeral),
12021                    }]
12022                }
12023                _ => {}
12024            }
12025            let status = match case {
12026                "error_tail" | "error_valid" | "error_zero_valid" => TerminalStatus::Error,
12027                "cancelled_tail" | "cancelled_valid" | "cancelled_zero_valid" => {
12028                    TerminalStatus::Cancelled
12029                }
12030                _ => TerminalStatus::Completed,
12031            };
12032            frames.push(ChildFrame::Terminal {
12033                status,
12034                result: Some("complete".into()),
12035                error: Some("worker failed".into()),
12036                transcript: vec![],
12037                final_event_watermark: (!matches!(
12038                    case,
12039                    "missing" | "zero_missing" | "ordinary_lossy"
12040                ))
12041                .then_some(marker),
12042            });
12043            let mut link = HistoryTerminalProbe {
12044                frames: frames.into(),
12045                accepted: None,
12046                acks: 0,
12047            };
12048            let (tx, mut rx) = mpsc::channel(32);
12049            let (_live, mut live_rx) = mpsc::unbounded_channel();
12050            let (_delivery, mut delivery_rx) = mpsc::unbounded_channel();
12051            let cancel = CancellationToken::new();
12052            let tools = vec!["Read".into()];
12053            let mut strict_history = None;
12054            let outcome = drive(ActorDriveContext {
12055                client: &mut link,
12056                parent_session_id: &root.id,
12057                child_session_id: "watermark-child",
12058                child_attempt: 0,
12059                approval_registry: None,
12060                approval_decider: None,
12061                approval_reviewer: None,
12062                escalation_bridge: None,
12063                event_tx: &tx,
12064                cancel_token: &cancel,
12065                live_rx: &mut live_rx,
12066                delivery_rx: &mut delivery_rx,
12067                logical_session: &mut child,
12068                expected_permission_posture: None,
12069                expected_creation: Some(&creation),
12070                session_inbox_runtime: Some(&binding),
12071                actor_directory_store: None,
12072                canonical_subagent_tool: None,
12073                ticket_service: None,
12074                activation_run_id: Some("current"),
12075                execution_epoch: 7,
12076                expected_source_actor_id: "selected",
12077                initial_inflight_claims: vec![].into(),
12078                plain_actor: false,
12079                remote_environment_lease: false,
12080                readonly_output: None,
12081                local_history_tools: (case != "ordinary_lossy").then_some(&tools),
12082                local_history_read_only: true,
12083                strict_history_output: Some(&mut strict_history),
12084                plain_input: None,
12085                canonical_activation: None,
12086                canonical_placement_ref: None,
12087                actor_event_observer: None,
12088                plain_run: None,
12089                first_frame_timeout: Some(Duration::from_secs(1)),
12090            })
12091            .await;
12092            assert_eq!(strict_history.is_some(), case == "success", "{case}");
12093            if let Some(history) = strict_history {
12094                assert_eq!(history.proof.execution_epoch(), 7);
12095                assert_eq!(history.proof.contiguous_applied_seq(), 1);
12096                assert_eq!(history.watermark.final_seq, 1);
12097            }
12098            let cold = store.load_session(&child.id).await.unwrap().unwrap();
12099            if matches!(case, "success" | "ordinary_lossy") {
12100                assert_eq!(outcome.unwrap().as_deref(), Some("complete"), "{case}");
12101            } else {
12102                let error = outcome.unwrap_err();
12103                if matches!(case, "cancelled_valid" | "cancelled_zero_valid") {
12104                    assert!(matches!(&error, AgentError::Cancelled), "{case}: {error}");
12105                }
12106                let error = error.to_string();
12107                if !matches!(
12108                    case,
12109                    "duplicate"
12110                        | "overlap"
12111                        | "reorder"
12112                        | "midgap"
12113                        | "qos"
12114                        | "zero_valid"
12115                        | "error_valid"
12116                        | "error_zero_valid"
12117                        | "cancelled_valid"
12118                        | "cancelled_zero_valid"
12119                ) {
12120                    assert!(
12121                        error.contains("actor_history_tail_incomplete"),
12122                        "{case}: {error}"
12123                    );
12124                }
12125            }
12126            if case == "success" {
12127                assert_eq!(
12128                    serde_json::to_value(&cold.messages).unwrap(),
12129                    serde_json::to_value(rows).unwrap()
12130                );
12131            } else {
12132                assert_eq!(
12133                    serde_json::to_value(&cold).unwrap(),
12134                    before,
12135                    "{case}: no cold history commit"
12136                );
12137                assert_eq!(
12138                    serde_json::to_value(&child).unwrap(),
12139                    before,
12140                    "{case}: no mutable history commit"
12141                );
12142            }
12143            let accepted = matches!(case, "success" | "ordinary_lossy");
12144            assert_eq!(link.accepted.is_some(), accepted, "{case}");
12145            assert_eq!(
12146                link.durable_delivery_receipt().is_some(),
12147                accepted,
12148                "{case}"
12149            );
12150            assert_eq!(link.acks, 0, "frame pump never ACKs uncheckpointed history");
12151            while let Ok(event) = rx.try_recv() {
12152                assert!(
12153                    matches!(event, AgentEvent::Token { .. }),
12154                    "typed DATA cannot leak"
12155                );
12156            }
12157        }
12158    }
12159
12160    fn processless_pool_worker(mailbox_id: &str) -> PooledWorker {
12161        PooledWorker {
12162            worker: SpawnedChild::remote(AgentRecord {
12163                agent_id: mailbox_id.to_string(),
12164                role: "test".to_string(),
12165                labels: Vec::new(),
12166                endpoint: "ws://127.0.0.1:1".to_string(),
12167                pid: 0,
12168                version: String::new(),
12169                started_at: chrono::Utc::now(),
12170                lease_expires_at: chrono::Utc::now(),
12171            }),
12172            mailbox_id: mailbox_id.to_string(),
12173            parked_at: None,
12174        }
12175    }
12176
12177    #[test]
12178    fn parked_worker_deadline_is_inclusive_and_ignores_checked_out_workers() {
12179        let now = Instant::now();
12180        let timeout = Duration::from_secs(300);
12181        assert!(!parked_worker_expired(None, now, timeout));
12182        assert!(!parked_worker_expired(
12183            Some(now - timeout + Duration::from_millis(1)),
12184            now,
12185            timeout
12186        ));
12187        assert!(parked_worker_expired(Some(now - timeout), now, timeout));
12188    }
12189
12190    #[test]
12191    fn idle_pool_global_cap_bounds_distinct_fingerprints() {
12192        let mut pool = WorkerPool::new();
12193        pool.insert(
12194            "fingerprint-a".to_string(),
12195            vec![processless_pool_worker("a")],
12196        );
12197        pool.insert(
12198            "fingerprint-b".to_string(),
12199            vec![processless_pool_worker("b")],
12200        );
12201
12202        assert_eq!(
12203            idle_pool_limit_reason(&pool, "fingerprint-c", 4, 2),
12204            Some("pool_global_limit")
12205        );
12206        assert_eq!(idle_pool_limit_reason(&pool, "fingerprint-c", 4, 3), None);
12207        assert_eq!(
12208            idle_pool_limit_reason(&pool, "fingerprint-a", 1, 8),
12209            Some("pool_per_key_limit")
12210        );
12211    }
12212
12213    #[test]
12214    fn pool_sweep_removes_dead_workers_and_empty_fingerprint_buckets() {
12215        let mut pool = WorkerPool::new();
12216        pool.insert(
12217            "fingerprint-a".to_string(),
12218            vec![processless_pool_worker("dead")],
12219        );
12220
12221        let reaped = take_reapable_workers(
12222            &mut pool,
12223            Instant::now(),
12224            Duration::from_secs(POOLED_IDLE_TIMEOUT_SECS),
12225        );
12226
12227        assert!(pool.is_empty());
12228        assert_eq!(reaped.len(), 1);
12229        assert_eq!(reaped[0].1, PoolReapReason::ProcessExited);
12230    }
12231
12232    #[test]
12233    fn actor_preflight_counts_only_current_session_scoped_denies() {
12234        let config = bamboo_tools::permission::PermissionConfig::new();
12235        let secret_matcher = "TOP_SECRET_ACTOR_DENY_MATCHER";
12236        config.deny_scoped_session_permission(
12237            "target-session",
12238            bamboo_tools::permission::PermissionType::ExecuteCommand,
12239            secret_matcher,
12240        );
12241        config.deny_scoped_session_permission(
12242            "other-session",
12243            bamboo_tools::permission::PermissionType::WriteFile,
12244            "/other/**",
12245        );
12246
12247        assert_eq!(
12248            active_scoped_session_deny_count(&config, "target-session"),
12249            1
12250        );
12251        assert_eq!(
12252            active_scoped_session_deny_count(&config, "clean-session"),
12253            0
12254        );
12255        let error = ensure_no_active_scoped_session_denies(&config, "target-session")
12256            .unwrap_err()
12257            .to_string();
12258        assert!(!error.contains(secret_matcher));
12259        ensure_no_active_scoped_session_denies(&config, "clean-session")
12260            .expect("another session's deny must not block this activation");
12261    }
12262
12263    #[test]
12264    fn permission_posture_event_rejects_oversized_executor_mapping() {
12265        let session = Session::new("mapping-bound", "model");
12266        let event = AgentEvent::PermissionPostureActivated {
12267            session_id: session.id.clone(),
12268            policy_revision: 1,
12269            requested_mode: "default".to_string(),
12270            effective_mode: "default".to_string(),
12271            executor_mapping: "x".repeat(bamboo_domain::MAX_PERMISSION_EXECUTOR_MAPPING_CHARS + 1),
12272        };
12273
12274        assert!(permission_posture_seed_from_event(&session, &event)
12275            .unwrap_err()
12276            .contains("executor mapping"));
12277    }
12278
12279    #[test]
12280    fn remote_audit_revision_and_timestamp_fields_cannot_poison_host_audit() {
12281        let mut session = Session::new("host-resigns-audit", "model");
12282        let hostile_timestamp = "9".repeat(1024);
12283        let event: AgentEvent = serde_json::from_value(serde_json::json!({
12284            "type": "permission_posture_activated",
12285            "session_id": session.id.clone(),
12286            "policy_revision": 31,
12287            "requested_mode": "default",
12288            "effective_mode": "default",
12289            "executor_mapping": "codex_exec:approval_policy=never",
12290            "audit_revision": u64::MAX,
12291            "transitioned_at": hostile_timestamp,
12292        }))
12293        .expect("unknown remote audit fields are ignored by the typed event");
12294        let seed = permission_posture_seed_from_event(&session, &event)
12295            .unwrap()
12296            .expect("permission event");
12297
12298        let host_revision =
12299            bamboo_domain::record_permission_audit(&mut session.metadata, &seed, None).unwrap();
12300        let host_audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
12301            .expect("host-generated complete audit");
12302        assert_eq!(host_audit.audit_revision, host_revision);
12303        assert!(host_audit.audit_revision < bamboo_domain::MAX_PERMISSION_AUDIT_REVISION);
12304        assert_ne!(host_audit.transitioned_at, hostile_timestamp);
12305        assert!(chrono::DateTime::parse_from_rfc3339(&host_audit.transitioned_at).is_ok());
12306    }
12307
12308    struct ActorFaultingPersistence {
12309        inner: Arc<bamboo_storage::LockedSessionStore>,
12310        fail_checkpoint_once: std::sync::atomic::AtomicBool,
12311    }
12312
12313    #[async_trait]
12314    impl RuntimeSessionPersistence for ActorFaultingPersistence {
12315        async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
12316            self.inner.merge_save_runtime(session).await
12317        }
12318
12319        async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
12320            if self
12321                .fail_checkpoint_once
12322                .swap(false, std::sync::atomic::Ordering::SeqCst)
12323            {
12324                return Err(std::io::Error::other("injected actor checkpoint failure"));
12325            }
12326            self.inner.checkpoint_runtime_session(session).await
12327        }
12328
12329        async fn load_runtime_session(&self, session_id: &str) -> std::io::Result<Option<Session>> {
12330            self.inner.storage().load_session(session_id).await
12331        }
12332    }
12333
12334    struct ActorFailBeforeAckInbox {
12335        inner: Arc<dyn SessionInboxPort>,
12336        fail_once: std::sync::atomic::AtomicBool,
12337    }
12338
12339    #[async_trait]
12340    impl SessionInboxPort for ActorFailBeforeAckInbox {
12341        async fn deliver(
12342            &self,
12343            envelope: &bamboo_domain::SessionMessageEnvelope,
12344        ) -> Result<bamboo_domain::SessionInboxReceipt, bamboo_domain::SessionInboxError> {
12345            self.inner.deliver(envelope).await
12346        }
12347
12348        async fn mark_activation_eligible(
12349            &self,
12350            target_session_id: &str,
12351            generation: u64,
12352            policy: bamboo_domain::SessionActivationPolicy,
12353        ) -> Result<(), bamboo_domain::SessionInboxError> {
12354            self.inner
12355                .mark_activation_eligible(target_session_id, generation, policy)
12356                .await
12357        }
12358
12359        async fn claim(
12360            &self,
12361            target_session_id: &str,
12362            limit: usize,
12363        ) -> Result<Vec<SessionInboxClaim>, bamboo_domain::SessionInboxError> {
12364            self.inner.claim(target_session_id, limit).await
12365        }
12366
12367        async fn was_admitted(
12368            &self,
12369            target_session_id: &str,
12370            id: &bamboo_domain::SessionMessageId,
12371        ) -> Result<bool, bamboo_domain::SessionInboxError> {
12372            self.inner.was_admitted(target_session_id, id).await
12373        }
12374
12375        async fn ack(
12376            &self,
12377            target_session_id: &str,
12378            claim: &SessionInboxClaim,
12379        ) -> Result<(), bamboo_domain::SessionInboxError> {
12380            if self
12381                .fail_once
12382                .swap(false, std::sync::atomic::Ordering::SeqCst)
12383            {
12384                return Err(bamboo_domain::SessionInboxError::Storage(
12385                    "injected actor pre-ack failure".to_string(),
12386                ));
12387            }
12388            self.inner.ack(target_session_id, claim).await
12389        }
12390
12391        async fn inspect(
12392            &self,
12393            target_session_id: &str,
12394        ) -> Result<bamboo_domain::SessionInboxBacklog, bamboo_domain::SessionInboxError> {
12395            self.inner.inspect(target_session_id).await
12396        }
12397    }
12398
12399    async fn actor_inbox_fixture(
12400        session_id: &str,
12401    ) -> (
12402        tempfile::TempDir,
12403        Arc<bamboo_storage::SessionStoreV2>,
12404        Arc<bamboo_storage::LockedSessionStore>,
12405        Arc<dyn SessionInboxPort>,
12406        Session,
12407        SessionInboxClaim,
12408    ) {
12409        let temp = tempfile::tempdir().unwrap();
12410        let store = Arc::new(
12411            bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
12412                .await
12413                .unwrap(),
12414        );
12415        let storage: Arc<dyn Storage> = store.clone();
12416        let locked = Arc::new(bamboo_storage::LockedSessionStore::new(storage));
12417        let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12418            store.clone(),
12419            bamboo_domain::SessionInboxLimits::default(),
12420        ));
12421        let session = Session::new(session_id, "model");
12422        store.save_session(&session).await.unwrap();
12423        let mut envelope =
12424            bamboo_domain::SessionMessageEnvelope::user_input(session_id, "actor follow-up");
12425        envelope.id =
12426            bamboo_domain::SessionMessageId::parse(format!("{session_id}-message")).unwrap();
12427        let receipt = inbox.deliver(&envelope).await.unwrap();
12428        inbox
12429            .mark_activation_eligible(
12430                session_id,
12431                receipt.generation,
12432                bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
12433            )
12434            .await
12435            .unwrap();
12436        let claim = inbox.claim(session_id, 1).await.unwrap().remove(0);
12437        (temp, store, locked, inbox, session, claim)
12438    }
12439
12440    fn actor_binding(
12441        store: Arc<bamboo_storage::SessionStoreV2>,
12442        inbox: Arc<dyn SessionInboxPort>,
12443        persistence: Arc<dyn RuntimeSessionPersistence>,
12444    ) -> SessionInboxRuntimeBinding {
12445        let storage: Arc<dyn Storage> = store;
12446        SessionInboxRuntimeBinding {
12447            router: SessionActivationRouter::new(),
12448            inbox,
12449            storage,
12450            persistence,
12451            parent_question_lock: None,
12452        }
12453    }
12454
12455    #[tokio::test]
12456    async fn remote_claim_seed_rejects_unsupported_inbox_before_dispatch() {
12457        let (_temp, store, locked, inbox, mut session, claim) =
12458            actor_inbox_fixture("unsupported-remote-inbox").await;
12459        let wrapped: Arc<dyn SessionInboxPort> = Arc::new(ActorFailBeforeAckInbox {
12460            inner: inbox.clone(),
12461            fail_once: std::sync::atomic::AtomicBool::new(false),
12462        });
12463        let binding = actor_binding(store.clone(), wrapped, locked);
12464        let fence = ActorActivationFence {
12465            schema_version: 1,
12466            actor_id: session.id.clone(),
12467            activation_id: "unsupported-inbox-activation".into(),
12468            attempt: 1,
12469            run_id: "unsupported-inbox-run".into(),
12470            lease_owner: "unsupported-inbox-host".into(),
12471            lease_epoch: 1,
12472        };
12473        let before = session.clone();
12474
12475        let error = checkpoint_claim_context_before_dispatch(
12476            &binding,
12477            &mut session,
12478            &[claim],
12479            Some((store.as_ref(), &fence)),
12480        )
12481        .await
12482        .unwrap_err();
12483        assert!(error
12484            .to_string()
12485            .contains("requires canonical FileSessionInbox"));
12486        assert_eq!(
12487            serde_json::to_value(&session.messages).unwrap(),
12488            serde_json::to_value(&before.messages).unwrap()
12489        );
12490        assert_eq!(
12491            serde_json::to_value(
12492                &store
12493                    .load_session(&session.id)
12494                    .await
12495                    .unwrap()
12496                    .unwrap()
12497                    .messages
12498            )
12499            .unwrap(),
12500            serde_json::to_value(&before.messages).unwrap()
12501        );
12502        assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12503    }
12504
12505    #[tokio::test]
12506    async fn remote_broker_lineage_rejects_missing_parent_and_project_drift() {
12507        let (_temp, store, locked, inbox, parent, _claim) =
12508            actor_inbox_fixture("remote-parent").await;
12509        let child = Session::new_child_of("remote-child", &parent, "model", "child");
12510        store.save_session(&child).await.unwrap();
12511        let binding = actor_binding(store.clone(), inbox, locked);
12512        let first = remote_canonical_lineage(&binding, &child).await.unwrap();
12513        assert_eq!(first.len(), 2);
12514        let mut changed = child.clone();
12515        changed.set_project_id_meta("different-project");
12516        assert!(remote_canonical_lineage(&binding, &changed).await.is_err());
12517        store.delete_session(&parent.id).await.unwrap();
12518        assert!(remote_canonical_lineage(&binding, &child).await.is_err());
12519    }
12520
12521    #[tokio::test]
12522    async fn spawn_depth_remote_lineage_accepts_existing_tree_above_default() {
12523        let (_temp, store, locked, inbox, root, _claim) =
12524            actor_inbox_fixture("deep-remote-root").await;
12525        let mut parent = root;
12526        for depth in 1..=6 {
12527            let child =
12528                Session::new_child_of(format!("remote-depth-{depth}"), &parent, "model", "child");
12529            store.save_session(&child).await.unwrap();
12530            parent = child;
12531        }
12532        let binding = actor_binding(store, inbox, locked);
12533        let chain = remote_canonical_lineage(&binding, &parent).await.unwrap();
12534        assert_eq!(chain.len(), 7);
12535        assert_eq!(chain.last().unwrap().spawn_depth, 0);
12536    }
12537
12538    #[tokio::test]
12539    async fn child_creation_requires_canonical_host_lifetime_not_cached_candidate() {
12540        let (_temp, store, locked, inbox, parent, _claim) =
12541            actor_inbox_fixture("creation-parent").await;
12542        let mut child = Session::new_child_of("creation-child", &parent, "model", "child");
12543        child.set_project_id_meta("creation-project");
12544        store.save_session(&child).await.unwrap();
12545        let binding = actor_binding(store.clone(), inbox, locked);
12546        let job = SpawnJob {
12547            parent_session_id: parent.id.clone(),
12548            child_session_id: child.id.clone(),
12549            model: "model".into(),
12550            disabled_tools: None,
12551        };
12552        let creation = canonical_child_creation(&binding, &child, &job)
12553            .await
12554            .unwrap();
12555        assert_eq!(creation.created_at, child.created_at);
12556        assert_eq!(creation.spawn_depth, child.spawn_depth);
12557        for damage in 0..7 {
12558            let mut held = child.clone();
12559            match damage {
12560                0 => held.created_at += chrono::Duration::nanoseconds(1),
12561                1 => held.parent_session_id = Some("foreign-parent".into()),
12562                2 => held.root_session_id = "foreign-root".into(),
12563                3 => held.spawn_depth += 1,
12564                4 => held.set_project_id_meta("foreign-project"),
12565                5 => held.kind = bamboo_domain::SessionKind::Root,
12566                _ => held.id = " creation-child ".into(),
12567            }
12568            assert!(
12569                canonical_child_creation(&binding, &held, &job)
12570                    .await
12571                    .is_err(),
12572                "damage {damage}"
12573            );
12574        }
12575        store.delete_session(&child.id).await.unwrap();
12576        assert!(canonical_child_creation(&binding, &child, &job)
12577            .await
12578            .is_err());
12579        let mut replacement = Session::new_child_of(&child.id, &parent, "model", "replacement");
12580        replacement.set_project_id_meta("creation-project");
12581        assert_ne!(replacement.created_at, child.created_at);
12582        store.save_session(&replacement).await.unwrap();
12583        assert!(
12584            canonical_child_creation(&binding, &child, &job)
12585                .await
12586                .is_err(),
12587            "old held spec cannot adopt replacement birth"
12588        );
12589        canonical_child_creation(&binding, &replacement, &job)
12590            .await
12591            .unwrap();
12592    }
12593
12594    #[tokio::test]
12595    async fn actor_immediate_claims_keep_exact_policy_across_mixed_order_ack_and_restart() {
12596        use bamboo_domain::{SessionActivationPolicy, SessionInboxLimits, SessionMessageEnvelope};
12597        for policies in [
12598            [
12599                SessionActivationPolicy::RespectSpecificWait,
12600                SessionActivationPolicy::InterruptSpecificWait,
12601            ],
12602            [
12603                SessionActivationPolicy::InterruptSpecificWait,
12604                SessionActivationPolicy::RespectSpecificWait,
12605            ],
12606        ] {
12607            let temp = tempfile::tempdir().unwrap();
12608            let store = Arc::new(
12609                bamboo_storage::SessionStoreV2::new(temp.path().into())
12610                    .await
12611                    .unwrap(),
12612            );
12613            let session_id = "actor-mixed-intent-policy";
12614            store
12615                .save_session(&Session::new(session_id, "model"))
12616                .await
12617                .unwrap();
12618            let inbox = bamboo_storage::FileSessionInbox::new(store, SessionInboxLimits::default());
12619            let staged = SessionMessageEnvelope::user_input(session_id, "staged sibling");
12620            let staged_receipt = inbox.deliver(&staged).await.unwrap();
12621            let mut sent = Vec::new();
12622            for policy in policies {
12623                let envelope = SessionMessageEnvelope::user_input(session_id, "immediate");
12624                let receipt = inbox
12625                    .deliver_with_activation_intent(&envelope, policy, None)
12626                    .await
12627                    .unwrap();
12628                sent.push((envelope, policy, receipt));
12629            }
12630            let restarted = Arc::new(
12631                bamboo_storage::SessionStoreV2::new(temp.path().into())
12632                    .await
12633                    .unwrap(),
12634            );
12635            let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12636                restarted.clone(),
12637                SessionInboxLimits::default(),
12638            ));
12639            let binding = actor_binding(
12640                restarted.clone(),
12641                inbox.clone(),
12642                Arc::new(bamboo_storage::LockedSessionStore::new(restarted.clone())),
12643            );
12644            let mut session = restarted.load_session(session_id).await.unwrap().unwrap();
12645            let pairs =
12646                claim_canonical_deliveries(&binding, &mut session, "run-1", usize::MAX, None)
12647                    .await
12648                    .unwrap();
12649            assert_eq!(pairs.len(), 2, "the staged sibling has no permission");
12650            for ((claim, delivery), (envelope, policy, receipt)) in pairs.iter().zip(&sent) {
12651                assert_eq!(claim.envelope.id, envelope.id);
12652                assert_eq!(claim.generation, receipt.generation);
12653                assert_eq!(claim.activation_policy, *policy);
12654                assert_eq!(delivery.activation_policy, *policy);
12655                checkpoint_and_ack_canonical_claim(&binding, &mut session, claim, None)
12656                    .await
12657                    .unwrap();
12658            }
12659            let restarted = Arc::new(
12660                bamboo_storage::SessionStoreV2::new(temp.path().into())
12661                    .await
12662                    .unwrap(),
12663            );
12664            let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12665                restarted.clone(),
12666                SessionInboxLimits::default(),
12667            ));
12668            let binding = actor_binding(
12669                restarted.clone(),
12670                inbox.clone(),
12671                Arc::new(bamboo_storage::LockedSessionStore::new(restarted.clone())),
12672            );
12673            session = restarted.load_session(session_id).await.unwrap().unwrap();
12674            for (envelope, policy, receipt) in &sent {
12675                assert_eq!(
12676                    inbox
12677                        .deliver_with_activation_intent(envelope, *policy, None)
12678                        .await
12679                        .unwrap(),
12680                    *receipt
12681                );
12682            }
12683            assert!(
12684                claim_canonical_deliveries(&binding, &mut session, "run-2", usize::MAX, None)
12685                    .await
12686                    .unwrap()
12687                    .is_empty()
12688            );
12689            inbox
12690                .mark_activation_eligible(
12691                    session_id,
12692                    staged_receipt.generation,
12693                    SessionActivationPolicy::RespectSpecificWait,
12694                )
12695                .await
12696                .unwrap();
12697            let pairs =
12698                claim_canonical_deliveries(&binding, &mut session, "run-2", usize::MAX, None)
12699                    .await
12700                    .unwrap();
12701            assert_eq!(pairs.len(), 1);
12702            assert_eq!(pairs[0].0.envelope.id, staged.id);
12703            assert_eq!(
12704                pairs[0].1.activation_policy,
12705                SessionActivationPolicy::RespectSpecificWait
12706            );
12707        }
12708    }
12709
12710    #[tokio::test]
12711    async fn actor_mismatched_typed_marker_id_collision_never_acks() {
12712        let (_temp, store, locked, inbox, mut session, claim) =
12713            actor_inbox_fixture("actor-live-id-collision").await;
12714        let mut forged = claim.envelope.to_provider_message().unwrap();
12715        forged.metadata = Some(serde_json::json!({
12716            "session_message": {
12717                "id": claim.envelope.id,
12718                "target_session_id": "different-session"
12719            }
12720        }));
12721        session.add_message(forged);
12722        let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12723        let binding = actor_binding(store, inbox.clone(), persistence);
12724
12725        assert!(
12726            checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12727                .await
12728                .is_err()
12729        );
12730        assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12731        assert!(!inbox
12732            .was_admitted(&session.id, &claim.envelope.id)
12733            .await
12734            .unwrap());
12735    }
12736
12737    #[tokio::test]
12738    async fn actor_concurrent_durable_id_collision_after_claim_never_acks() {
12739        let (_temp, store, locked, inbox, mut session, claim) =
12740            actor_inbox_fixture("actor-durable-id-collision").await;
12741        let mut concurrent = store.load_session(&session.id).await.unwrap().unwrap();
12742        let mut forged = bamboo_agent_core::Message::user("concurrent actor collision");
12743        forged.id = claim.envelope.id.to_string();
12744        concurrent.add_message(forged);
12745        store.save_session(&concurrent).await.unwrap();
12746        let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12747        let binding = actor_binding(store.clone(), inbox.clone(), persistence);
12748
12749        assert!(
12750            checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12751                .await
12752                .is_err()
12753        );
12754        assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12755        assert!(!inbox
12756            .was_admitted(&session.id, &claim.envelope.id)
12757            .await
12758            .unwrap());
12759        let durable = store.load_session(&session.id).await.unwrap().unwrap();
12760        assert!(!durable.messages.iter().any(|message| {
12761            bamboo_domain::is_matching_session_message(message, &claim.envelope)
12762        }));
12763    }
12764
12765    #[tokio::test]
12766    async fn actor_concurrent_durable_typed_body_mismatch_never_acks() {
12767        let (_temp, store, locked, inbox, mut session, claim) =
12768            actor_inbox_fixture("actor-durable-typed-body-collision").await;
12769        let mut different = claim.envelope.clone();
12770        different.body = bamboo_domain::SessionMessageBody::Content(
12771            bamboo_domain::SessionMessageContent::text("forged actor body"),
12772        );
12773        let mut concurrent = store.load_session(&session.id).await.unwrap().unwrap();
12774        concurrent.add_message(different.to_provider_message().unwrap());
12775        store.save_session(&concurrent).await.unwrap();
12776        let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12777        let binding = actor_binding(store.clone(), inbox.clone(), persistence);
12778
12779        assert!(
12780            checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12781                .await
12782                .is_err()
12783        );
12784        assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12785        assert!(!inbox
12786            .was_admitted(&session.id, &claim.envelope.id)
12787            .await
12788            .unwrap());
12789        let durable = store.load_session(&session.id).await.unwrap().unwrap();
12790        assert!(!durable
12791            .messages
12792            .iter()
12793            .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope)));
12794    }
12795
12796    #[tokio::test]
12797    async fn actor_checkpoint_failure_rolls_back_and_restart_admits_once() {
12798        let (_temp, store, locked, inbox, mut session, claim) =
12799            actor_inbox_fixture("actor-checkpoint-failure").await;
12800        let envelope_id = claim.envelope.id.clone();
12801        let fault: Arc<dyn RuntimeSessionPersistence> = Arc::new(ActorFaultingPersistence {
12802            inner: locked.clone(),
12803            fail_checkpoint_once: std::sync::atomic::AtomicBool::new(true),
12804        });
12805        let binding = actor_binding(store.clone(), inbox.clone(), fault);
12806
12807        assert!(
12808            checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12809                .await
12810                .is_err()
12811        );
12812        assert!(!session
12813            .messages
12814            .iter()
12815            .any(|message| message.id == envelope_id.as_str()));
12816        assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12817        assert!(!inbox.was_admitted(&session.id, &envelope_id).await.unwrap());
12818
12819        let reopened: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12820            store.clone(),
12821            bamboo_domain::SessionInboxLimits::default(),
12822        ));
12823        let recovered = reopened.claim(&session.id, 1).await.unwrap().remove(0);
12824        let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12825        let binding = actor_binding(store.clone(), reopened.clone(), persistence);
12826        let mut restarted = store.load_session(&session.id).await.unwrap().unwrap();
12827        checkpoint_and_ack_canonical_claim(&binding, &mut restarted, &recovered, None)
12828            .await
12829            .unwrap();
12830        assert_eq!(
12831            restarted
12832                .messages
12833                .iter()
12834                .filter(|message| message.id == envelope_id.as_str())
12835                .count(),
12836            1
12837        );
12838        assert!(reopened
12839            .was_admitted(&session.id, &envelope_id)
12840            .await
12841            .unwrap());
12842        let backlog = reopened.inspect(&session.id).await.unwrap();
12843        assert_eq!(backlog.pending + backlog.claimed, 0);
12844    }
12845
12846    #[tokio::test]
12847    async fn actor_checkpoint_success_pre_ack_failure_recovers_without_duplicate() {
12848        let (_temp, store, locked, real_inbox, mut session, claim) =
12849            actor_inbox_fixture("actor-pre-ack-failure").await;
12850        let envelope_id = claim.envelope.id.clone();
12851        let faulted: Arc<dyn SessionInboxPort> = Arc::new(ActorFailBeforeAckInbox {
12852            inner: real_inbox.clone(),
12853            fail_once: std::sync::atomic::AtomicBool::new(true),
12854        });
12855        let persistence: Arc<dyn RuntimeSessionPersistence> = locked.clone();
12856        let binding = actor_binding(store.clone(), faulted, persistence);
12857
12858        assert!(
12859            checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12860                .await
12861                .is_err()
12862        );
12863        let durable = store.load_session(&session.id).await.unwrap().unwrap();
12864        assert_eq!(
12865            durable
12866                .messages
12867                .iter()
12868                .filter(|message| message.id == envelope_id.as_str())
12869                .count(),
12870            1
12871        );
12872        assert_eq!(real_inbox.inspect(&session.id).await.unwrap().claimed, 1);
12873        assert!(!real_inbox
12874            .was_admitted(&session.id, &envelope_id)
12875            .await
12876            .unwrap());
12877
12878        let reopened: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12879            store.clone(),
12880            bamboo_domain::SessionInboxLimits::default(),
12881        ));
12882        let recovered = reopened.claim(&session.id, 1).await.unwrap().remove(0);
12883        let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12884        let binding = actor_binding(store.clone(), reopened.clone(), persistence);
12885        let mut restarted = durable;
12886        checkpoint_and_ack_canonical_claim(&binding, &mut restarted, &recovered, None)
12887            .await
12888            .unwrap();
12889        assert_eq!(
12890            restarted
12891                .messages
12892                .iter()
12893                .filter(|message| message.id == envelope_id.as_str())
12894                .count(),
12895            1
12896        );
12897        assert!(reopened
12898            .was_admitted(&session.id, &envelope_id)
12899            .await
12900            .unwrap());
12901        let backlog = reopened.inspect(&session.id).await.unwrap();
12902        assert_eq!(backlog.pending + backlog.claimed, 0);
12903    }
12904
12905    struct ConfirmationSequenceLink {
12906        frames: VecDeque<ChildFrame>,
12907        sent: Vec<ParentFrame>,
12908    }
12909
12910    #[async_trait]
12911    impl bamboo_subagent::ChildLink for ConfirmationSequenceLink {
12912        async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
12913            self.sent.push(frame);
12914            Ok(())
12915        }
12916
12917        async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
12918            match self.frames.pop_front() {
12919                Some(frame) => Ok(Some(frame)),
12920                None => std::future::pending().await,
12921            }
12922        }
12923    }
12924
12925    #[tokio::test]
12926    async fn failed_actor_retry_checkpoints_new_input_and_rejects_old_or_stale_state() {
12927        for case in [
12928            "accepted",
12929            "readonly-refusal",
12930            "prefix-refusal",
12931            "old-claim",
12932            "old-checkpoint",
12933            "succeeded",
12934            "changed-birth",
12935            "oversize",
12936        ] {
12937            let temp = tempfile::tempdir().unwrap();
12938            let store = Arc::new(
12939                bamboo_storage::SessionStoreV2::new(temp.path().into())
12940                    .await
12941                    .unwrap(),
12942            );
12943            let parent = Session::new("retry-parent", "model");
12944            store.save_session(&parent).await.unwrap();
12945            let mut child =
12946                Session::new_child_of("retry-child", &parent, "model", "original assignment");
12947            child.add_message(bamboo_agent_core::Message::user("original assignment"));
12948            store.save_session(&child).await.unwrap();
12949            let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
12950            bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
12951            let locked = Arc::new(bamboo_storage::LockedSessionStore::new(store.clone()));
12952            let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12953                store.clone(),
12954                bamboo_domain::SessionInboxLimits::default(),
12955            ));
12956            let binding = actor_binding(store.clone(), inbox, locked);
12957            let registration = binding
12958                .router
12959                .register_run(&child.id, "first-run")
12960                .await
12961                .unwrap();
12962            let first = PlainActorActivation::start(
12963                store.clone(),
12964                &child,
12965                &binding,
12966                Some("first-run"),
12967                Some(policy.clone()),
12968                true,
12969                None,
12970            )
12971            .await
12972            .unwrap();
12973            if matches!(case, "old-claim" | "old-checkpoint") {
12974                let old = bamboo_domain::SessionMessageEnvelope::user_input(
12975                    &child.id,
12976                    "unconfirmed old input",
12977                );
12978                let receipt = first.input_inbox.deliver(&old).await.unwrap();
12979                first
12980                    .input_inbox
12981                    .mark_activation_eligible(
12982                        &child.id,
12983                        receipt.generation,
12984                        bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
12985                    )
12986                    .await
12987                    .unwrap();
12988                let claim = first
12989                    .claim_input(&binding, &child, "first-run")
12990                    .await
12991                    .unwrap()
12992                    .unwrap();
12993                if case == "old-checkpoint" {
12994                    first
12995                        .checkpoint_input(&mut child, "first-run", &claim)
12996                        .await
12997                        .unwrap();
12998                }
12999            }
13000            first
13001                .finish(if case == "succeeded" {
13002                    ActorActivationFinish::Succeeded
13003                } else {
13004                    ActorActivationFinish::Failed
13005                })
13006                .await
13007                .unwrap();
13008            // Drop schedules cleanup; the retry needs the old owner released first.
13009            registration.abandon().await;
13010            assert!(
13011                !binding.router.owns_run(&child.id, "first-run").await,
13012                "{case}: abandoned first run must release its owner before retry"
13013            );
13014            let envelope = bamboo_domain::SessionMessageEnvelope::user_input(
13015                &child.id,
13016                if case == "oversize" {
13017                    "x".repeat(8193)
13018                } else {
13019                    "NEW_CORRECTION".into()
13020                },
13021            );
13022            let receipt = first.input_inbox.deliver(&envelope).await.unwrap();
13023            first
13024                .input_inbox
13025                .mark_activation_eligible(
13026                    &child.id,
13027                    receipt.generation,
13028                    bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
13029                )
13030                .await
13031                .unwrap();
13032            let before = store.load_session(&child.id).await.unwrap().unwrap();
13033            let expected_messages = serde_json::to_value(&before.messages).unwrap();
13034            child = before.clone();
13035            if case == "prefix-refusal" {
13036                child.messages.last_mut().unwrap().content = "stale cached prefix".into();
13037            }
13038            if case == "changed-birth" {
13039                child.created_at += chrono::Duration::milliseconds(1);
13040            }
13041            let _retry_registration = binding
13042                .router
13043                .register_run(&child.id, "retry-run")
13044                .await
13045                .unwrap();
13046            let result = PlainActorActivation::start_and_prepare(
13047                store.clone(),
13048                &mut child,
13049                &binding,
13050                Some("retry-run"),
13051                Some(policy.clone()),
13052                case != "readonly-refusal",
13053                None,
13054            )
13055            .await;
13056            if case != "accepted" {
13057                assert!(result.is_err(), "{case}: no retry Run may be dispatched");
13058                let actual = store.load_session(&before.id).await.unwrap().unwrap();
13059                assert_eq!(
13060                    serde_json::to_value(&actual.messages).unwrap(),
13061                    expected_messages,
13062                    "{case}: failed preparation preserves Main history"
13063                );
13064                assert!(
13065                    !first
13066                        .input_inbox
13067                        .was_admitted(&before.id, &envelope.id)
13068                        .await
13069                        .unwrap(),
13070                    "{case}: no ACK"
13071                );
13072                if case == "readonly-refusal" {
13073                    let entry = store.inspect_actor(&before.id).await.unwrap();
13074                    assert_eq!(entry.actor.current_attempt, 1);
13075                    assert_eq!(
13076                        entry.activation.unwrap().activation_id,
13077                        first.fence.activation_id
13078                    );
13079                    let backlog = first.input_inbox.inspect(&before.id).await.unwrap();
13080                    assert_eq!((backlog.pending, backlog.claimed), (1, 0));
13081                }
13082                if matches!(case, "prefix-refusal" | "changed-birth" | "oversize") {
13083                    assert_eq!(
13084                        first.input_inbox.inspect(&before.id).await.unwrap().claimed,
13085                        1
13086                    );
13087                    assert_eq!(
13088                        store
13089                            .inspect_actor(&before.id)
13090                            .await
13091                            .unwrap()
13092                            .activation
13093                            .unwrap()
13094                            .status,
13095                        bamboo_domain::ActorActivationStatus::Failed
13096                    );
13097                }
13098                if case == "oversize" {
13099                    let reopened = Arc::new(
13100                        bamboo_storage::SessionStoreV2::new(temp.path().into())
13101                            .await
13102                            .unwrap(),
13103                    );
13104                    let cold_inbox = bamboo_storage::FileSessionInbox::new(
13105                        reopened,
13106                        bamboo_domain::SessionInboxLimits::default(),
13107                    );
13108                    let now = chrono::Utc::now();
13109                    let leases = cold_inbox
13110                        .inspect_owned_leases(&before.id, 2, now)
13111                        .await
13112                        .unwrap();
13113                    assert_eq!(leases.len(), 1);
13114                    assert_eq!(leases[0].failure_count, 1);
13115                    assert_eq!(
13116                        leases[0].last_error_code.as_deref(),
13117                        Some("unsupported_actor_input")
13118                    );
13119                    let retry_after = leases[0].retry_after.unwrap();
13120                    assert!(retry_after > now);
13121                    let readiness = cold_inbox
13122                        .inspect_wake_readiness(&before.id, now)
13123                        .await
13124                        .unwrap();
13125                    assert!(
13126                        readiness.ready.is_none(),
13127                        "failed correction must not hot-loop"
13128                    );
13129                    assert_eq!(readiness.next_due_at, Some(retry_after));
13130                }
13131                continue;
13132            }
13133            let (retry, prepared) = result.unwrap();
13134            let (prefix, mut deliveries) = prepared.unwrap();
13135            assert_eq!(deliveries.len(), 1);
13136            let delivery = deliveries.pop().unwrap();
13137            assert_eq!(
13138                prefix,
13139                serde_json::to_value(&before.messages)
13140                    .unwrap()
13141                    .as_array()
13142                    .unwrap()
13143                    .clone()
13144            );
13145            assert_eq!(delivery.envelope, envelope);
13146            assert_eq!(delivery.activation_run_id, "retry-run");
13147            assert_eq!(delivery.canonical_claim_generation, receipt.generation);
13148            assert!(store
13149                .validate_fence(&first.fence, chrono::Utc::now())
13150                .await
13151                .is_err());
13152            let running = store.inspect_actor(&child.id).await.unwrap();
13153            assert_eq!(running.actor.current_attempt, 2);
13154            assert_eq!(
13155                running.activation.as_ref().unwrap().status,
13156                bamboo_domain::ActorActivationStatus::Running
13157            );
13158            assert!(running.activation.unwrap().lease_epoch > first.fence.lease_epoch);
13159            let claim = retry.initial_inputs.first().unwrap();
13160            assert!(child
13161                .session_inbox_admission()
13162                .unwrap()
13163                .contains(&envelope.id));
13164            assert!(
13165                !retry
13166                    .input_inbox
13167                    .was_admitted(&child.id, &envelope.id)
13168                    .await
13169                    .unwrap(),
13170                "checkpoint is not startup confirmation"
13171            );
13172            let mut confirmation = admission_confirmation(&child.id, &claim.claim, "retry-run");
13173            confirmation.activation_run_id = "first-run".into();
13174            assert!(retry
13175                .confirm_input(&binding, &child, "retry-run", claim, &confirmation)
13176                .await
13177                .is_err());
13178            assert!(!retry
13179                .input_inbox
13180                .was_admitted(&child.id, &envelope.id)
13181                .await
13182                .unwrap());
13183            confirmation.activation_run_id = "retry-run".into();
13184            retry
13185                .confirm_input(&binding, &child, "retry-run", claim, &confirmation)
13186                .await
13187                .unwrap();
13188            let (tx, mut rx) = mpsc::channel(1);
13189            retry
13190                .append_reply(&mut child, "RETRY_REPLY".into(), &tx)
13191                .await
13192                .unwrap();
13193            rx.recv().await.unwrap();
13194            retry
13195                .finish(ActorActivationFinish::Succeeded)
13196                .await
13197                .unwrap();
13198            let reopened = Arc::new(
13199                bamboo_storage::SessionStoreV2::new(temp.path().into())
13200                    .await
13201                    .unwrap(),
13202            );
13203            let cold = reopened.load_session(&child.id).await.unwrap().unwrap();
13204            assert_eq!(cold.created_at, before.created_at);
13205            assert_eq!(cold.messages.len(), before.messages.len() + 2);
13206            assert_eq!(
13207                cold.messages[before.messages.len()].id,
13208                envelope.id.as_str()
13209            );
13210            assert_eq!(cold.messages.last().unwrap().content, "RETRY_REPLY");
13211            assert_eq!(
13212                cold.messages
13213                    .iter()
13214                    .filter(|m| m.id == envelope.id.as_str())
13215                    .count(),
13216                1
13217            );
13218            let cold_inbox = bamboo_storage::FileSessionInbox::new(
13219                reopened,
13220                bamboo_domain::SessionInboxLimits::default(),
13221            );
13222            assert!(cold_inbox
13223                .was_admitted(&cold.id, &envelope.id)
13224                .await
13225                .unwrap());
13226            assert_eq!(cold_inbox.inspect(&cold.id).await.unwrap().claimed, 0);
13227        }
13228    }
13229
13230    #[tokio::test]
13231    async fn pre_ack_owned_recovery_requires_expiry_provenance_and_verified_already() {
13232        for case in [
13233            "accepted",
13234            "actor-live",
13235            "claim-live",
13236            "legacy",
13237            "ack",
13238            "readonly",
13239            "prefix",
13240            "duplicate",
13241            "missing-marker",
13242            "mixed",
13243            "project",
13244            "birth",
13245        ] {
13246            let temp = tempfile::tempdir().unwrap();
13247            let store = Arc::new(
13248                bamboo_storage::SessionStoreV2::new(temp.path().into())
13249                    .await
13250                    .unwrap(),
13251            );
13252            let parent = Session::new("recovery-parent", "model");
13253            store.save_session(&parent).await.unwrap();
13254            let mut child =
13255                Session::new_child_of("recovery-child", &parent, "model", "original task");
13256            child.add_message(bamboo_agent_core::Message::user("original task"));
13257            store.save_session(&child).await.unwrap();
13258            let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
13259            bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
13260            let inbox = bamboo_storage::FileSessionInbox::new(
13261                store.clone(),
13262                bamboo_domain::SessionInboxLimits::default(),
13263            );
13264            // Complete unfenced persistence before starting the setup lease window.
13265            let envelope =
13266                bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "RECOVER_SAME_INPUT");
13267            let receipt = inbox.deliver(&envelope).await.unwrap();
13268            inbox
13269                .mark_activation_eligible(
13270                    &child.id,
13271                    receipt.generation,
13272                    bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
13273                )
13274                .await
13275                .unwrap();
13276            let now = chrono::Utc::now();
13277            let cutoff = now + chrono::Duration::seconds(10);
13278            let consumer = SessionInboxConsumerId::new();
13279            let old = store
13280                .claim_activation(&ActorActivationClaim {
13281                    actor_id: child.id.clone(),
13282                    run_id: "old-run".into(),
13283                    lease_owner: consumer.as_str().into(),
13284                    lease_expires_at: if case == "actor-live" {
13285                        now + chrono::Duration::minutes(5)
13286                    } else {
13287                        cutoff
13288                    },
13289                    inbox_generation: 0,
13290                    placement_ref: if case == "legacy" {
13291                        None
13292                    } else {
13293                        Some(bamboo_domain::ActorPlacementRef {
13294                            class: bamboo_domain::ActorPlacementClass::Local,
13295                            lease_id: "owned-initial-release-v1:actual-old-worker".into(),
13296                            slot_epoch: None,
13297                        })
13298                    },
13299                    now,
13300                })
13301                .await
13302                .unwrap();
13303            store
13304                .start_activation(&old.fence(), chrono::Utc::now())
13305                .await
13306                .unwrap();
13307            let claim_now = chrono::Utc::now();
13308            let claim = inbox
13309                .claim_owned(
13310                    &child.id,
13311                    1,
13312                    Some("old-run"),
13313                    &SessionInboxLeaseRequest {
13314                        consumer,
13315                        now: claim_now,
13316                        duration: if case == "claim-live" {
13317                            chrono::Duration::minutes(5)
13318                        } else {
13319                            cutoff - claim_now
13320                        },
13321                    },
13322                )
13323                .await
13324                .unwrap()
13325                .pop()
13326                .unwrap();
13327            child = inbox
13328                .checkpoint_actor_input(bamboo_storage::ActorInputCheckpoint {
13329                    fence: old.fence(),
13330                    expected_created_at: child.created_at,
13331                    expected_conversation_generation: child.conversation_generation_observation(),
13332                    claim: claim.clone(),
13333                    expected_messages: child.messages.clone(),
13334                    expected_provider_transcript: child.provider_transcript.clone(),
13335                    expected_admission: None,
13336                })
13337                .await
13338                .unwrap()
13339                .session;
13340            // The real checkpoint must succeed under the original live authority.
13341            store
13342                .validate_fence(&old.fence(), chrono::Utc::now())
13343                .await
13344                .unwrap_or_else(|error| panic!("{case}: setup fence: {error:?}"));
13345            let setup_leases = inbox
13346                .inspect_owned_leases(&child.id, 2, chrono::Utc::now())
13347                .await
13348                .unwrap();
13349            assert_eq!(setup_leases.len(), 1, "{case}: original setup claim");
13350            assert_eq!(setup_leases[0].epoch, claim.lease.epoch, "{case}");
13351            assert_eq!(setup_leases[0].expires_at, claim.lease.expires_at, "{case}");
13352            assert!(!setup_leases[0].expired, "{case}: setup claim must be live");
13353            let setup_at = chrono::Utc::now();
13354            assert!(
13355                setup_at < old.lease_expires_at,
13356                "{case}: setup Actor expired"
13357            );
13358            assert!(
13359                setup_at < claim.lease.expires_at,
13360                "{case}: setup claim expired"
13361            );
13362            eprintln!("pre-ACK {case}: setup live at {setup_at}, cutoff {cutoff}");
13363            if case == "ack" {
13364                inbox
13365                    .ack_owned(&child.id, &claim, chrono::Utc::now())
13366                    .await
13367                    .unwrap();
13368            }
13369            if case == "mixed" {
13370                inbox
13371                    .deliver(&bamboo_domain::SessionMessageEnvelope::user_input(
13372                        &child.id,
13373                        "different input",
13374                    ))
13375                    .await
13376                    .unwrap();
13377            }
13378            let directory = store
13379                .bamboo_home_dir()
13380                .join(store.resolve_rel_path(&child.id).await.unwrap());
13381            let main = std::fs::read(directory.join("session.json")).unwrap();
13382            let canonical = child.clone();
13383            match case {
13384                "prefix" => child.messages[0].content.push_str(" stale"),
13385                "duplicate" => child.messages.push(child.messages.last().unwrap().clone()),
13386                "missing-marker" => {
13387                    child
13388                        .messages
13389                        .last_mut()
13390                        .unwrap()
13391                        .metadata
13392                        .as_mut()
13393                        .unwrap()
13394                        .as_object_mut()
13395                        .unwrap()
13396                        .remove("_bamboo_owned_input_checkpoint");
13397                }
13398                "project" => child.set_project_id_meta(bamboo_domain::ProjectId::new().to_string()),
13399                "birth" => child.created_at += chrono::Duration::nanoseconds(1),
13400                _ => {}
13401            }
13402            // One shared short deadline per case; never wait for the live controls.
13403            // No reclaim, renewal or authority-file edit occurs between these stages.
13404            let delay = (cutoff - chrono::Utc::now()).to_std().unwrap_or_default();
13405            tokio::time::sleep(delay + Duration::from_millis(10)).await;
13406            let recovery_at = chrono::Utc::now();
13407            assert!(recovery_at >= cutoff, "{case}: real expiry is required");
13408            let before_recovery = store.inspect_actor(&child.id).await.unwrap();
13409            let original = before_recovery.activation.unwrap();
13410            assert_eq!(original.fence(), old.fence(), "{case}: same authority");
13411            assert_eq!(original.lease_expires_at, old.lease_expires_at, "{case}");
13412            assert_eq!(
13413                store
13414                    .validate_fence(&old.fence(), recovery_at)
13415                    .await
13416                    .is_ok(),
13417                case == "actor-live",
13418                "{case}: Actor expiry control"
13419            );
13420            let recovery_leases = inbox
13421                .inspect_owned_leases(&child.id, 2, recovery_at)
13422                .await
13423                .unwrap();
13424            if case == "ack" {
13425                assert!(recovery_leases.is_empty(), "{case}: terminal ACK");
13426            } else {
13427                assert_eq!(recovery_leases.len(), 1, "{case}: original recovery claim");
13428                assert_eq!(recovery_leases[0].epoch, claim.lease.epoch, "{case}");
13429                assert_eq!(
13430                    recovery_leases[0].expires_at, claim.lease.expires_at,
13431                    "{case}"
13432                );
13433                assert_eq!(recovery_leases[0].expired, case != "claim-live", "{case}");
13434            }
13435            eprintln!("pre-ACK {case}: recovery expiry control at {recovery_at}");
13436            let second = Arc::new(
13437                bamboo_storage::SessionStoreV2::new(temp.path().into())
13438                    .await
13439                    .unwrap(),
13440            );
13441            let binding = actor_binding(
13442                second.clone(),
13443                Arc::new(bamboo_storage::FileSessionInbox::new(
13444                    second.clone(),
13445                    bamboo_domain::SessionInboxLimits::default(),
13446                )),
13447                Arc::new(bamboo_storage::LockedSessionStore::new(second.clone())),
13448            );
13449            let _owner = binding
13450                .router
13451                .register_run(&child.id, "replacement-run")
13452                .await
13453                .unwrap();
13454            let prepared = PlainActorActivation::start_and_prepare(
13455                second.clone(),
13456                &mut child,
13457                &binding,
13458                Some("replacement-run"),
13459                Some(policy.clone()),
13460                case != "readonly",
13461                Some("probed-new-worker"),
13462            )
13463            .await;
13464            assert_eq!(
13465                std::fs::read(directory.join("session.json")).unwrap(),
13466                main,
13467                "{case}: Already or refusal never rewrites Main"
13468            );
13469            if case != "accepted" {
13470                assert!(prepared.is_err(), "{case}");
13471                assert_eq!(
13472                    inbox.was_admitted(&child.id, &envelope.id).await.unwrap(),
13473                    case == "ack"
13474                );
13475                continue;
13476            }
13477            let (replacement, delivery) = prepared.unwrap();
13478            assert!(replacement.recovering_pre_ack);
13479            let (messages, mut deliveries) = delivery.unwrap();
13480            assert_eq!(deliveries.len(), 1);
13481            let delivery = deliveries.pop().unwrap();
13482            let new_claim = replacement.initial_inputs.first().unwrap();
13483            assert_eq!(new_claim.claim.envelope, claim.claim.envelope);
13484            assert_eq!(new_claim.claim.generation, claim.claim.generation);
13485            assert_eq!(
13486                new_claim.claim.activation_policy,
13487                claim.claim.activation_policy
13488            );
13489            assert!(new_claim.lease.epoch > claim.lease.epoch);
13490            assert_ne!(new_claim.lease.incarnation, claim.lease.incarnation);
13491            assert_eq!(
13492                replacement.inbox_generation, 1,
13493                "old Actor generation was zero"
13494            );
13495            assert_eq!(
13496                second
13497                    .inspect_actor(&child.id)
13498                    .await
13499                    .unwrap()
13500                    .actor
13501                    .current_attempt,
13502                2
13503            );
13504            assert!(second
13505                .validate_fence(&old.fence(), chrono::Utc::now())
13506                .await
13507                .is_err());
13508            assert!(inbox
13509                .ack_owned(&child.id, &claim, chrono::Utc::now())
13510                .await
13511                .is_err());
13512            // The original Store/claim survives the handoff. Even with the
13513            // current exact prefix and dedupe cursor, its delayed checkpoint
13514            // must fail on authority before AlreadyCheckpointed can succeed.
13515            let authority = second.inspect_actor(&child.id).await.unwrap();
13516            let leases = inbox
13517                .inspect_owned_leases(&child.id, 2, chrono::Utc::now())
13518                .await
13519                .unwrap();
13520            let delayed = inbox
13521                .checkpoint_actor_input(bamboo_storage::ActorInputCheckpoint {
13522                    fence: old.fence(),
13523                    expected_created_at: child.created_at,
13524                    expected_conversation_generation: child.conversation_generation_observation(),
13525                    claim: claim.clone(),
13526                    expected_messages: child.messages.clone(),
13527                    expected_provider_transcript: child.provider_transcript.clone(),
13528                    expected_admission: child.session_inbox_admission().cloned(),
13529                })
13530                .await;
13531            assert!(matches!(
13532                delayed,
13533                Err(bamboo_storage::ActorInputCheckpointError::Actor(
13534                    bamboo_domain::ActorDirectoryError::StaleFence
13535                ))
13536            ));
13537            assert_eq!(std::fs::read(directory.join("session.json")).unwrap(), main);
13538            assert_eq!(second.inspect_actor(&child.id).await.unwrap(), authority);
13539            assert_eq!(
13540                inbox
13541                    .inspect_owned_leases(&child.id, 2, chrono::Utc::now())
13542                    .await
13543                    .unwrap(),
13544                leases
13545            );
13546            assert!(!inbox.was_admitted(&child.id, &envelope.id).await.unwrap());
13547            assert_eq!(
13548                serde_json::to_value(&child.messages).unwrap(),
13549                serde_json::to_value(&canonical.messages).unwrap()
13550            );
13551            let mut worker: Vec<bamboo_agent_core::Message> = messages
13552                .iter()
13553                .cloned()
13554                .map(|v| serde_json::from_value(v).unwrap())
13555                .collect();
13556            let target = worker.last_mut().unwrap();
13557            assert!(target
13558                .metadata
13559                .as_ref()
13560                .unwrap()
13561                .get("_bamboo_owned_input_checkpoint")
13562                .is_none());
13563            assert!(bamboo_domain::is_matching_session_message(
13564                target, &envelope
13565            ));
13566            let mut wanted = canonical.messages.last().unwrap().clone();
13567            wanted
13568                .metadata
13569                .as_mut()
13570                .unwrap()
13571                .as_object_mut()
13572                .unwrap()
13573                .remove("_bamboo_owned_input_checkpoint");
13574            assert_eq!(
13575                serde_json::to_value(target).unwrap(),
13576                serde_json::to_value(wanted).unwrap()
13577            );
13578            let run = RunSpec {
13579                assignment: "original task".into(),
13580                logical_session: Some(LogicalSessionIdentity {
13581                    session_id: child.id.clone(),
13582                    parent_session_id: child.parent_session_id.clone(),
13583                    root_session_id: child.root_session_id.clone(),
13584                    creation: Some(bamboo_subagent::proto::ChildCreationIdentity {
13585                        created_at: child.created_at,
13586                        spawn_depth: child.spawn_depth,
13587                    }),
13588                }),
13589                project_id: None,
13590                reasoning_effort: None,
13591                permission_policy: Some(PermissionPolicyContext {
13592                    revision: policy.policy_revision(),
13593                    requested_mode: "default".into(),
13594                    effective_mode: "default".into(),
13595                    bypass_permissions: false,
13596                    auto_approve_permissions: false,
13597                    session_id: child.id.clone(),
13598                    workspace_path: child.workspace.clone(),
13599                    environment_lease: None,
13600                    inherit_session_grants: false,
13601                    policy: serde_json::to_value(policy.to_serializable()).unwrap(),
13602                }),
13603                messages,
13604                activation_run_id: Some("replacement-run".into()),
13605                execution_epoch: 2,
13606                initial_session_messages: vec![delivery.clone()],
13607                secrets: Default::default(),
13608            };
13609            let policy_revision = policy.policy_revision();
13610            let mut expected = Some(expected_default_permission_posture(policy_revision));
13611            let ChildFrame::Event { event } = permission_posture_frame(&child.id, policy_revision)
13612            else {
13613                unreachable!()
13614            };
13615            let (events, mut rx) = mpsc::channel(1);
13616            process_actor_event(
13617                event,
13618                true,
13619                &mut PermissionPostureHandshake::Awaiting,
13620                expected.as_ref(),
13621                Some(&binding),
13622                &mut child,
13623                &events,
13624                &mut ActorEventDisplay::default(),
13625            )
13626            .await
13627            .unwrap();
13628            rx.recv().await.unwrap();
13629            let request = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
13630                &run,
13631                &delivery,
13632                uuid::Uuid::new_v4().to_string(),
13633            )
13634            .unwrap();
13635            replacement
13636                .release_initial_input(
13637                    &binding,
13638                    &child,
13639                    &run,
13640                    2,
13641                    new_claim,
13642                    &request,
13643                    &mut expected,
13644                    None,
13645                )
13646                .await
13647                .unwrap();
13648            assert!(inbox.was_admitted(&child.id, &envelope.id).await.unwrap());
13649            replacement
13650                .append_reply(&mut child, "RECOVERY_REPLY".into(), &events)
13651                .await
13652                .unwrap();
13653            rx.recv().await.unwrap();
13654            replacement
13655                .finish(ActorActivationFinish::Succeeded)
13656                .await
13657                .unwrap();
13658            let cold = second.load_session(&child.id).await.unwrap().unwrap();
13659            assert_eq!(cold.created_at, canonical.created_at);
13660            assert_eq!(
13661                serde_json::to_value(&cold.messages[..canonical.messages.len()]).unwrap(),
13662                serde_json::to_value(&canonical.messages).unwrap()
13663            );
13664            assert_eq!(cold.messages.last().unwrap().content, "RECOVERY_REPLY");
13665            assert_eq!(
13666                cold.messages
13667                    .iter()
13668                    .filter(|m| m.id == envelope.id.as_str())
13669                    .count(),
13670                1
13671            );
13672        }
13673    }
13674
13675    #[cfg(unix)]
13676    #[tokio::test]
13677    async fn owned_initial_release_missing_only_new_capability_rejects_before_spawn_or_run() {
13678        use bamboo_subagent::provision::*;
13679        use std::os::unix::fs::PermissionsExt;
13680        let temp = tempfile::tempdir().unwrap();
13681        let worker = temp.path().join("mixed-worker");
13682        let spawned = temp.path().join("spawned");
13683        let run = temp.path().join("run");
13684        let probes = temp.path().join("probes");
13685        let fabric = temp.path().join("fabric");
13686        let mut report = WorkerCapabilityReport::current();
13687        report
13688            .capabilities
13689            .retain(|name| name != INITIAL_INPUT_RELEASE_WORKER_CAPABILITY);
13690        assert!(!report.supports(INITIAL_INPUT_RELEASE_WORKER_CAPABILITY));
13691        let report = serde_json::to_string(&report).unwrap();
13692        std::fs::write(&worker, format!(
13693            "#!/bin/sh\nfor arg in \"$@\"; do last=\"$arg\"; done\nif [ \"$last\" = --print-capabilities ]; then\nprintf '%s\\n' '{report}'\nprintf 'probe\\n' >> \"$3\"\nelse\n: > \"$1\"\n: > \"$2\"\ncat >/dev/null\nfi\n"
13694        )).unwrap();
13695        std::fs::set_permissions(&worker, std::fs::Permissions::from_mode(0o700)).unwrap();
13696        let args = vec![
13697            spawned.to_str().unwrap().into(),
13698            run.to_str().unwrap().into(),
13699            probes.to_str().unwrap().into(),
13700        ];
13701        // Real subprocess probes prove the rejection cannot be an earlier missing bit.
13702        for capability in [
13703            REQUIRED_CHILD_CONTEXT_WORKER_CAPABILITY,
13704            CHILD_CREATION_IDENTITY_WORKER_CAPABILITY,
13705            NATIVE_TOOL_CEILING_WORKER_CAPABILITY,
13706            TYPED_READ_ONLY_WORKER_CAPABILITY,
13707        ] {
13708            bamboo_subagent::fleet::require_worker_capability(&worker, &args, capability)
13709                .await
13710                .unwrap();
13711        }
13712        let runner = ActorChildRunner::new(
13713            "mixed".into(),
13714            worker,
13715            args,
13716            fabric.clone(),
13717            ExecutorSpec::BambooRuntime,
13718            vec![],
13719            "openai".into(),
13720            1,
13721        );
13722        // The actual Host method remains mandatory before claim/provision/Run.
13723        let error = runner
13724            .require_initial_input_release_worker()
13725            .await
13726            .unwrap_err();
13727        assert!(error
13728            .to_string()
13729            .contains("owned initial release worker capability unconfirmed"));
13730        assert_eq!(std::fs::read_to_string(probes).unwrap().lines().count(), 5);
13731        assert!(!spawned.exists() && !run.exists() && !fabric.exists());
13732        assert!(runner.pool.lock().await.is_empty());
13733    }
13734
13735    #[tokio::test]
13736    async fn ordinary_glob_only_observes_own_initialization_signals() {
13737        let temp = tempfile::tempdir().unwrap();
13738        let home = temp.path().canonicalize().unwrap();
13739        let store = bamboo_storage::SessionStoreV2::new(home.clone())
13740            .await
13741            .unwrap();
13742        let parent = Session::new("glob-standard-parent", "model");
13743        store.save_session(&parent).await.unwrap();
13744        let child = Session::new_child_of("glob-standard-child", &parent, "model", "one Glob");
13745        store.save_session(&child).await.unwrap();
13746        let directory = home.join(store.resolve_rel_path(&child.id).await.unwrap());
13747        // An unrelated bad child would reject a full tree snapshot, but is not
13748        // part of this own-session no-replay observation.
13749        let unrelated = directory.parent().unwrap().join("unrelated");
13750        std::fs::create_dir(&unrelated).unwrap();
13751        std::fs::write(unrelated.join("session.json"), b"invalid").unwrap();
13752        require_unowned_glob(&store, &child).await.unwrap();
13753        for name in ["actor-authority.json", "actor-authority.initialized.json"] {
13754            let path = directory.join(name);
13755            std::fs::write(&path, b"invalid prior signal").unwrap();
13756            assert!(require_unowned_glob(&store, &child).await.is_err());
13757            std::fs::remove_file(&path).unwrap();
13758        }
13759        #[cfg(unix)]
13760        {
13761            let marker = directory.join("actor-authority.initialized.json");
13762            std::os::unix::fs::symlink(directory.join("missing"), &marker).unwrap();
13763            assert!(require_unowned_glob(&store, &child).await.is_err());
13764        }
13765    }
13766
13767    #[test]
13768    fn owned_glob_collector_requires_actual_ordered_tool_trace_and_typed_tail() {
13769        let call = bamboo_domain::ToolCall {
13770            id: "glob".into(),
13771            tool_type: "function".into(),
13772            function: bamboo_domain::FunctionCall {
13773                name: "Glob".into(),
13774                arguments: r#"{"pattern":"marker.txt"}"#.into(),
13775            },
13776        };
13777        let messages = [
13778            bamboo_agent_core::Message::assistant("", Some(vec![call])),
13779            bamboo_agent_core::Message::tool_result("glob", "marker.txt"),
13780            bamboo_agent_core::Message::assistant("done", None),
13781        ];
13782        let start = serde_json::to_value(AgentEvent::ToolStart {
13783            tool_call_id: "glob".into(),
13784            tool_name: "Glob".into(),
13785            arguments: serde_json::json!({"pattern":"marker.txt"}),
13786        })
13787        .unwrap();
13788        let complete = serde_json::to_value(AgentEvent::ToolComplete {
13789            tool_call_id: "glob".into(),
13790            result: bamboo_domain::ToolResult::text(true, "marker.txt"),
13791        })
13792        .unwrap();
13793        let tail =
13794            serde_json::to_value(bamboo_subagent::proto::ReadOnlyActorTranscript::Complete {
13795                messages: messages
13796                    .iter()
13797                    .map(serde_json::to_value)
13798                    .collect::<Result<_, _>>()
13799                    .unwrap(),
13800            })
13801            .unwrap();
13802        let mut ordered = ReadOnlyActorCollector::default();
13803        assert!(ordered.event(&start).unwrap());
13804        assert!(ordered.event(&complete).unwrap());
13805        assert!(!ordered.event(&tail).unwrap());
13806        assert!(ordered.event(&tail).is_err(), "duplicate suffix");
13807        assert_eq!(ordered.finish(Some("done")).unwrap().len(), 3);
13808        assert!(ReadOnlyActorCollector::default()
13809            .finish(Some("done"))
13810            .is_err());
13811        let mut unfinished = ReadOnlyActorCollector::default();
13812        unfinished.event(&start).unwrap();
13813        unfinished.event(&complete).unwrap();
13814        assert!(unfinished.finish(Some("done")).is_err(), "missing suffix");
13815        let mut closed = tail.clone();
13816        closed["grant"] = true.into();
13817        assert!(
13818            serde_json::from_value::<bamboo_subagent::proto::ReadOnlyActorTranscript>(closed)
13819                .is_err()
13820        );
13821        let mut oversized = tail.clone();
13822        oversized["messages"][2]["content"] = "x".repeat(64 * 1024).into();
13823        assert!(
13824            serde_json::from_value::<bamboo_subagent::proto::ReadOnlyActorTranscript>(oversized)
13825                .unwrap()
13826                .validate()
13827                .is_err()
13828        );
13829        assert!(ReadOnlyActorCollector::default().event(&complete).is_err());
13830        assert!(ReadOnlyActorCollector::default().event(&tail).is_err());
13831        let mut bad = start.clone();
13832        bad["tool_name"] = "Bash".into();
13833        assert!(ReadOnlyActorCollector::default().event(&bad).is_err());
13834        let mut duplicate = ReadOnlyActorCollector::default();
13835        duplicate.event(&start).unwrap();
13836        assert!(duplicate.event(&start).is_err());
13837        let hidden = serde_json::to_value(AgentEvent::ReasoningToken {
13838            content: "hidden".into(),
13839        })
13840        .unwrap();
13841        assert!(ReadOnlyActorCollector::default().event(&hidden).is_err());
13842        let mut mismatched = ReadOnlyActorCollector::default();
13843        mismatched.event(&start).unwrap();
13844        let mut bad = complete.clone();
13845        bad["tool_call_id"] = "foreign".into();
13846        assert!(mismatched.event(&bad).is_err());
13847        mismatched.event(&complete).unwrap();
13848        let mut changed = tail.clone();
13849        changed["messages"][1]["content"] = "fabricated".into();
13850        assert!(mismatched.event(&changed).is_err());
13851    }
13852
13853    #[cfg(unix)]
13854    #[tokio::test]
13855    async fn owned_initial_release_requires_current_prefix_ack_and_same_receipt() {
13856        let temp = tempfile::tempdir().unwrap();
13857        let store = Arc::new(
13858            bamboo_storage::SessionStoreV2::new(temp.path().into())
13859                .await
13860                .unwrap(),
13861        );
13862        let parent = Session::new("release-host-parent", "model");
13863        store.save_session(&parent).await.unwrap();
13864        let mut child = Session::new_child_of("release-host-child", &parent, "model", "assignment");
13865        store.save_session(&child).await.unwrap();
13866        let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
13867            store.clone(),
13868            bamboo_domain::SessionInboxLimits::default(),
13869        ));
13870        let binding = actor_binding(
13871            store.clone(),
13872            inbox,
13873            Arc::new(bamboo_storage::LockedSessionStore::new(store.clone())),
13874        );
13875        let _owner = binding
13876            .router
13877            .register_run(&child.id, "release-host-run")
13878            .await
13879            .unwrap();
13880        let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
13881        let revision = policy.policy_revision();
13882        bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
13883        let activation = PlainActorActivation::start(
13884            store.clone(),
13885            &child,
13886            &binding,
13887            Some("release-host-run"),
13888            Some(policy.clone()),
13889            true,
13890            Some("probed-physical-worker"),
13891        )
13892        .await
13893        .unwrap();
13894        let envelope = bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "RELEASE_ONCE");
13895        let receipt = activation.input_inbox.deliver(&envelope).await.unwrap();
13896        activation
13897            .input_inbox
13898            .mark_activation_eligible(
13899                &child.id,
13900                receipt.generation,
13901                bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
13902            )
13903            .await
13904            .unwrap();
13905        let claim = activation
13906            .claim_input(&binding, &child, "release-host-run")
13907            .await
13908            .unwrap()
13909            .unwrap();
13910        let (prefix, delivery) = activation
13911            .checkpoint_input(&mut child, "release-host-run", &claim)
13912            .await
13913            .unwrap();
13914        let mut expected = Some(expected_default_permission_posture(revision));
13915        let ChildFrame::Event { event } = permission_posture_frame(&child.id, revision) else {
13916            unreachable!()
13917        };
13918        let (events, mut events_rx) = mpsc::channel(1);
13919        process_actor_event(
13920            event,
13921            true,
13922            &mut PermissionPostureHandshake::Awaiting,
13923            expected.as_ref(),
13924            Some(&binding),
13925            &mut child,
13926            &events,
13927            &mut ActorEventDisplay::default(),
13928        )
13929        .await
13930        .unwrap();
13931        events_rx.recv().await.unwrap();
13932        let run = RunSpec {
13933            assignment: "assignment".into(),
13934            logical_session: Some(LogicalSessionIdentity {
13935                session_id: child.id.clone(),
13936                parent_session_id: child.parent_session_id.clone(),
13937                root_session_id: child.root_session_id.clone(),
13938                creation: Some(bamboo_subagent::proto::ChildCreationIdentity {
13939                    created_at: child.created_at,
13940                    spawn_depth: child.spawn_depth,
13941                }),
13942            }),
13943            project_id: None,
13944            reasoning_effort: None,
13945            permission_policy: Some(bamboo_subagent::proto::PermissionPolicyContext {
13946                revision,
13947                requested_mode: "default".into(),
13948                effective_mode: "default".into(),
13949                bypass_permissions: false,
13950                auto_approve_permissions: false,
13951                session_id: child.id.clone(),
13952                workspace_path: child.workspace.clone(),
13953                environment_lease: None,
13954                inherit_session_grants: false,
13955                policy: serde_json::to_value(policy.to_serializable()).unwrap(),
13956            }),
13957            messages: prefix,
13958            activation_run_id: Some("release-host-run".into()),
13959            execution_epoch: 9,
13960            initial_session_messages: vec![delivery.clone()],
13961            secrets: Default::default(),
13962        };
13963        let request = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
13964            &run,
13965            &delivery,
13966            uuid::Uuid::new_v4().to_string(),
13967        )
13968        .unwrap();
13969        let mut stale = child.clone();
13970        stale.add_message(bamboo_agent_core::Message::user("not canonical"));
13971        assert!(activation
13972            .release_initial_input(
13973                &binding,
13974                &stale,
13975                &run,
13976                9,
13977                &claim,
13978                &request,
13979                &mut expected,
13980                None
13981            )
13982            .await
13983            .is_err());
13984        assert!(!activation
13985            .input_inbox
13986            .was_admitted(&child.id, &envelope.id)
13987            .await
13988            .unwrap());
13989        // Keep receipt lookup readable so Already reaches the real ACK write.
13990        // Unix root can bypass these modes; this FS fault needs an unprivileged user.
13991        use std::os::unix::fs::PermissionsExt;
13992        struct RestorePermissions(std::path::PathBuf, std::fs::Permissions);
13993        impl Drop for RestorePermissions {
13994            fn drop(&mut self) {
13995                let _ = std::fs::set_permissions(&self.0, self.1.clone());
13996            }
13997        }
13998        let admitted = store
13999            .bamboo_home_dir()
14000            .join(store.resolve_rel_path(&child.id).await.unwrap())
14001            .join("inbox/admitted");
14002        std::fs::create_dir_all(&admitted).unwrap();
14003        let restore = RestorePermissions(
14004            admitted.clone(),
14005            std::fs::metadata(&admitted).unwrap().permissions(),
14006        );
14007        std::fs::set_permissions(&admitted, std::fs::Permissions::from_mode(0o555)).unwrap();
14008        let verified = activation
14009            .input_inbox
14010            .checkpoint_actor_input(bamboo_storage::ActorInputCheckpoint {
14011                fence: activation.fence.clone(),
14012                expected_created_at: child.created_at,
14013                expected_conversation_generation: child.conversation_generation_observation(),
14014                claim: claim.clone(),
14015                expected_messages: child.messages.clone(),
14016                expected_provider_transcript: child.provider_transcript.clone(),
14017                expected_admission: child.session_inbox_admission().cloned(),
14018            })
14019            .await
14020            .unwrap();
14021        assert_eq!(
14022            verified.status,
14023            bamboo_storage::ActorInputCheckpointStatus::AlreadyCheckpointed
14024        );
14025        let error = activation
14026            .release_initial_input(
14027                &binding,
14028                &child,
14029                &run,
14030                9,
14031                &claim,
14032                &request,
14033                &mut expected,
14034                None,
14035            )
14036            .await
14037            .unwrap_err();
14038        std::fs::set_permissions(&admitted, restore.1.clone()).unwrap();
14039        drop(restore);
14040        assert!(
14041            error.to_string().contains("ACK unresolved"),
14042            "actual release error (<=512B): {}",
14043            error.to_string().chars().take(128).collect::<String>()
14044        );
14045        let release = activation
14046            .release_initial_input(
14047                &binding,
14048                &child,
14049                &run,
14050                9,
14051                &claim,
14052                &request,
14053                &mut expected,
14054                None,
14055            )
14056            .await
14057            .unwrap();
14058        assert!(activation
14059            .input_inbox
14060            .was_admitted(&child.id, &envelope.id)
14061            .await
14062            .unwrap());
14063        assert_eq!(
14064            release.expires_at,
14065            activation
14066                .actor_deadline
14067                .min(claim.lease.expires_at)
14068                .min(activation.input_deadline)
14069        );
14070        assert_eq!(
14071            activation
14072                .release_initial_input(
14073                    &binding,
14074                    &child,
14075                    &run,
14076                    9,
14077                    &claim,
14078                    &request,
14079                    &mut expected,
14080                    Some(&release)
14081                )
14082                .await
14083                .unwrap(),
14084            release
14085        );
14086        let mut wrong = request.clone();
14087        wrong.nonce = uuid::Uuid::new_v4().to_string();
14088        assert!(activation
14089            .release_initial_input(
14090                &binding,
14091                &child,
14092                &run,
14093                9,
14094                &claim,
14095                &wrong,
14096                &mut expected,
14097                Some(&release)
14098            )
14099            .await
14100            .is_err());
14101        assert!(activation
14102            .release_initial_input(
14103                &binding,
14104                &child,
14105                &run,
14106                10,
14107                &claim,
14108                &request,
14109                &mut expected,
14110                Some(&release)
14111            )
14112            .await
14113            .is_err());
14114        // A second real checkpoint/release reuses the activation, not its old epoch or nonce.
14115        let deadlines = (
14116            activation.fence.clone(),
14117            activation.actor_deadline,
14118            activation.input_deadline,
14119        );
14120        let second = bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "RELEASE_TWICE");
14121        let receipt = activation.input_inbox.deliver(&second).await.unwrap();
14122        activation
14123            .input_inbox
14124            .mark_activation_eligible(
14125                &child.id,
14126                receipt.generation,
14127                bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
14128            )
14129            .await
14130            .unwrap();
14131        let mut link = ConfirmationSequenceLink {
14132            frames: VecDeque::new(),
14133            sent: Vec::new(),
14134        };
14135        let epochs = AtomicU64::new(9);
14136        let (claim2, epoch2) = activation
14137            .continue_input(
14138                &mut link,
14139                &binding,
14140                &mut child,
14141                "release-host-run",
14142                Some((&run, &epochs)),
14143                &mut expected,
14144                "SECOND_REPLY".into(),
14145                &events,
14146            )
14147            .await
14148            .unwrap()
14149            .unwrap();
14150        events_rx.recv().await.unwrap();
14151        assert_eq!(epoch2, 10);
14152        let ParentFrame::Run(next) = &link.sent[0] else {
14153            panic!("second correlated Run")
14154        };
14155        assert_eq!(next.logical_session, run.logical_session);
14156        assert_eq!(next.activation_run_id, run.activation_run_id);
14157        assert_eq!(next.initial_session_messages.len(), 1);
14158        assert!(next
14159            .messages
14160            .iter()
14161            .any(|m| m["id"] == envelope.id.as_str()));
14162        assert!(next.messages.iter().any(|m| m["content"] == "SECOND_REPLY"));
14163        assert!(!next.messages.iter().any(|m| m["id"] == second.id.as_str()));
14164        let request2 = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
14165            next,
14166            &next.initial_session_messages[0],
14167            uuid::Uuid::new_v4().to_string(),
14168        )
14169        .unwrap();
14170        assert_ne!(request2.nonce, request.nonce);
14171        macro_rules! release_second {
14172            ($candidate:expr, $request:expr, $epoch:expr, $previous:expr) => {
14173                activation
14174                    .release_initial_input(
14175                        &binding,
14176                        $candidate,
14177                        next,
14178                        $epoch,
14179                        &claim2,
14180                        $request,
14181                        &mut expected,
14182                        $previous,
14183                    )
14184                    .await
14185            };
14186        }
14187        assert!(release_second!(&child, &request, epoch2, None).is_err());
14188        assert!(release_second!(&child, &request2, 9, None).is_err());
14189        let mut stale2 = child.clone();
14190        stale2.add_message(bamboo_agent_core::Message::user("stale second prefix"));
14191        assert!(release_second!(&stale2, &request2, epoch2, None).is_err());
14192        assert!(!activation
14193            .input_inbox
14194            .was_admitted(&child.id, &second.id)
14195            .await
14196            .unwrap());
14197        let release2 = release_second!(&child, &request2, epoch2, None).unwrap();
14198        assert_eq!(
14199            release_second!(&child, &request2, epoch2, Some(&release2)).unwrap(),
14200            release2
14201        );
14202        let mut wrong2 = request2.clone();
14203        wrong2.nonce = request.nonce.clone();
14204        assert!(release_second!(&child, &wrong2, epoch2, Some(&release2)).is_err());
14205        assert!(activation
14206            .input_inbox
14207            .was_admitted(&child.id, &second.id)
14208            .await
14209            .unwrap());
14210        let expired = bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "EXPIRED_INPUT");
14211        let receipt = activation.input_inbox.deliver(&expired).await.unwrap();
14212        activation
14213            .input_inbox
14214            .mark_activation_eligible(
14215                &child.id,
14216                receipt.generation,
14217                bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
14218            )
14219            .await
14220            .unwrap();
14221        let now = chrono::Utc::now();
14222        let expired_claim = activation
14223            .input_inbox
14224            .claim_owned(
14225                &child.id,
14226                1,
14227                Some("release-host-run"),
14228                &SessionInboxLeaseRequest {
14229                    consumer: activation.input_consumer.clone(),
14230                    now,
14231                    duration: chrono::Duration::seconds(1),
14232                },
14233            )
14234            .await
14235            .unwrap()
14236            .pop()
14237            .unwrap();
14238        let (prefix, delivery3) = activation
14239            .checkpoint_input(&mut child, "release-host-run", &expired_claim)
14240            .await
14241            .unwrap();
14242        let mut expired_run = next.clone();
14243        expired_run.execution_epoch = 11;
14244        expired_run.messages = prefix;
14245        expired_run.initial_session_messages = vec![delivery3.clone()];
14246        let expired_request = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
14247            &expired_run,
14248            &delivery3,
14249            uuid::Uuid::new_v4().to_string(),
14250        )
14251        .unwrap();
14252        tokio::time::sleep(
14253            (expired_claim.lease.expires_at - chrono::Utc::now())
14254                .to_std()
14255                .unwrap_or_default(),
14256        )
14257        .await;
14258        assert!(chrono::Utc::now() >= expired_claim.lease.expires_at);
14259        assert!(activation
14260            .release_initial_input(
14261                &binding,
14262                &child,
14263                &expired_run,
14264                11,
14265                &expired_claim,
14266                &expired_request,
14267                &mut expected,
14268                None
14269            )
14270            .await
14271            .is_err());
14272        assert!(!activation
14273            .input_inbox
14274            .was_admitted(&child.id, &expired.id)
14275            .await
14276            .unwrap());
14277        assert_eq!(
14278            (
14279                activation.fence.clone(),
14280                activation.actor_deadline,
14281                activation.input_deadline
14282            ),
14283            deadlines
14284        );
14285        assert_eq!(
14286            link.sent.len(),
14287            1,
14288            "no dispatch from rejected release checks"
14289        );
14290        let cold = store.load_session(&child.id).await.unwrap().unwrap();
14291        for id in [&envelope.id, &second.id] {
14292            assert_eq!(
14293                cold.messages.iter().filter(|m| m.id == id.as_str()).count(),
14294                1
14295            );
14296            assert!(cold.session_inbox_admission().unwrap().contains(id));
14297        }
14298        // Mutate the actual captured policy Arc; cached ACK/release is not a fresh grant.
14299        policy.deny_scoped_session_permission(
14300            &child.id,
14301            bamboo_tools::permission::PermissionType::ExecuteCommand,
14302            "blocked-action",
14303        );
14304        assert!(release_second!(&child, &request2, epoch2, Some(&release2)).is_err());
14305        assert!(activation
14306            .input_inbox
14307            .was_admitted(&child.id, &second.id)
14308            .await
14309            .unwrap());
14310        activation
14311            .finish(ActorActivationFinish::Failed)
14312            .await
14313            .unwrap();
14314        // Terminal ACK can be idempotent; stopped/expired Actor cannot grant.
14315        activation
14316            .input_inbox
14317            .ack_owned(&child.id, &claim, chrono::Utc::now())
14318            .await
14319            .unwrap();
14320        assert!(activation
14321            .release_initial_input(
14322                &binding,
14323                &child,
14324                &run,
14325                9,
14326                &claim,
14327                &request,
14328                &mut expected,
14329                Some(&release)
14330            )
14331            .await
14332            .is_err());
14333        let cold = bamboo_storage::SessionStoreV2::new(temp.path().into())
14334            .await
14335            .unwrap()
14336            .load_session(&child.id)
14337            .await
14338            .unwrap()
14339            .unwrap();
14340        assert_eq!(
14341            cold.messages
14342                .iter()
14343                .filter(|m| m.id == envelope.id.as_str())
14344                .count(),
14345            1
14346        );
14347    }
14348
14349    #[tokio::test]
14350    async fn owned_actor_input_checkpoint_precedes_dispatch_and_exact_ack() {
14351        for reject_checkpoint in [false, true] {
14352            let temp = tempfile::tempdir().unwrap();
14353            let store = Arc::new(
14354                bamboo_storage::SessionStoreV2::new(temp.path().into())
14355                    .await
14356                    .unwrap(),
14357            );
14358            let parent = Session::new("owned-input-parent", "model");
14359            store.save_session(&parent).await.unwrap();
14360            let mut child =
14361                Session::new_child_of("owned-input-child", &parent, "model", "one plain task");
14362            store.save_session(&child).await.unwrap();
14363            let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
14364            let raw_storage: Arc<dyn Storage> = store.clone();
14365            let locked = Arc::new(bamboo_storage::LockedSessionStore::new(raw_storage));
14366            let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
14367                store.clone(),
14368                bamboo_domain::SessionInboxLimits::default(),
14369            ));
14370            let binding = actor_binding(store.clone(), inbox, locked);
14371            let run_id = "owned-input-run";
14372            let _registration = binding
14373                .router
14374                .register_run(&child.id, run_id)
14375                .await
14376                .unwrap();
14377            assert!(PlainActorActivation::start(
14378                store.clone(),
14379                &child,
14380                &binding,
14381                Some(run_id),
14382                None,
14383                true,
14384                None,
14385            )
14386            .await
14387            .is_err());
14388            assert!(PlainActorActivation::start(
14389                store.clone(),
14390                &child,
14391                &binding,
14392                Some(run_id),
14393                Some(policy.clone()),
14394                true,
14395                None,
14396            )
14397            .await
14398            .is_err());
14399            assert_eq!(
14400                store
14401                    .inspect_actor(&child.id)
14402                    .await
14403                    .unwrap()
14404                    .actor
14405                    .current_attempt,
14406                0
14407            );
14408            bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
14409            let activation = PlainActorActivation::start(
14410                store.clone(),
14411                &child,
14412                &binding,
14413                Some(run_id),
14414                Some(policy.clone()),
14415                true,
14416                None,
14417            )
14418            .await
14419            .unwrap();
14420            let envelope =
14421                bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "CORRECTION_ONCE");
14422            let receipt = activation.input_inbox.deliver(&envelope).await.unwrap();
14423            activation
14424                .input_inbox
14425                .mark_activation_eligible(
14426                    &child.id,
14427                    receipt.generation,
14428                    bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
14429                )
14430                .await
14431                .unwrap();
14432            let mut link = ConfirmationSequenceLink {
14433                frames: VecDeque::new(),
14434                sent: Vec::new(),
14435            };
14436            let policy_revision = policy.policy_revision();
14437            let run = RunSpec {
14438                assignment: "one plain task".into(),
14439                logical_session: None,
14440                project_id: None,
14441                reasoning_effort: None,
14442                permission_policy: Some(PermissionPolicyContext {
14443                    revision: policy_revision,
14444                    requested_mode: "default".into(),
14445                    effective_mode: "default".into(),
14446                    bypass_permissions: false,
14447                    auto_approve_permissions: false,
14448                    session_id: child.id.clone(),
14449                    workspace_path: child.workspace.clone(),
14450                    environment_lease: None,
14451                    inherit_session_grants: false,
14452                    policy: serde_json::to_value(policy.to_serializable()).unwrap(),
14453                }),
14454                messages: vec![],
14455                activation_run_id: Some(run_id.into()),
14456                execution_epoch: 1,
14457                initial_session_messages: vec![],
14458                secrets: Default::default(),
14459            };
14460            let epochs = AtomicU64::new(1);
14461            let (tx, mut rx) = mpsc::channel(1);
14462            let mut expected = Some(expected_default_permission_posture(policy_revision));
14463            let ChildFrame::Event { event } = permission_posture_frame(&child.id, policy_revision)
14464            else {
14465                unreachable!()
14466            };
14467            process_actor_event(
14468                event,
14469                true,
14470                &mut PermissionPostureHandshake::Awaiting,
14471                expected.as_ref(),
14472                Some(&binding),
14473                &mut child,
14474                &tx,
14475                &mut ActorEventDisplay::default(),
14476            )
14477            .await
14478            .unwrap();
14479            rx.recv().await.unwrap();
14480            let first_audit =
14481                bamboo_domain::PermissionAuditSnapshot::from_metadata(&child.metadata)
14482                    .unwrap()
14483                    .audit_revision;
14484            if reject_checkpoint {
14485                // A real bounded publication channel blocks AFTER first-reply
14486                // commit, so change actual Actor authority before checkpoint.
14487                tx.send(AgentEvent::Complete {
14488                    usage: Default::default(),
14489                })
14490                .await
14491                .unwrap();
14492            }
14493            let mut operation = activation.continue_input(
14494                &mut link,
14495                &binding,
14496                &mut child,
14497                run_id,
14498                Some((&run, &epochs)),
14499                &mut expected,
14500                "FIRST_PLAIN_REPLY".into(),
14501                &tx,
14502            );
14503            if reject_checkpoint {
14504                tokio::select! {
14505                    result = &mut operation => panic!("publication barrier released early: {result:?}"),
14506                    _ = async {
14507                        tokio::time::timeout(Duration::from_secs(5), async {
14508                            loop {
14509                                let actual = store.load_session("owned-input-child").await.unwrap().unwrap();
14510                                if actual.messages.iter().any(|m| m.content == "FIRST_PLAIN_REPLY") { break; }
14511                                tokio::task::yield_now().await;
14512                            }
14513                        }).await.unwrap();
14514                        activation.finish(ActorActivationFinish::Failed).await.unwrap();
14515                        rx.recv().await.unwrap();
14516                    } => {}
14517                }
14518            }
14519            let result = operation.await;
14520            if reject_checkpoint {
14521                assert!(result
14522                    .unwrap_err()
14523                    .to_string()
14524                    .contains("checkpoint unconfirmed"));
14525                assert!(
14526                    link.sent.is_empty(),
14527                    "zero worker/provider correction dispatch"
14528                );
14529                let durable = store.load_session(&child.id).await.unwrap().unwrap();
14530                assert!(durable
14531                    .messages
14532                    .iter()
14533                    .any(|m| m.content == "FIRST_PLAIN_REPLY"));
14534                assert!(!durable
14535                    .messages
14536                    .iter()
14537                    .any(|m| m.id == envelope.id.as_str()));
14538                assert!(!activation
14539                    .input_inbox
14540                    .was_admitted(&child.id, &envelope.id)
14541                    .await
14542                    .unwrap());
14543                assert_eq!(
14544                    activation
14545                        .input_inbox
14546                        .inspect(&child.id)
14547                        .await
14548                        .unwrap()
14549                        .claimed,
14550                    1
14551                );
14552                continue;
14553            }
14554            let (claim, epoch) = result.unwrap().unwrap();
14555            rx.recv().await.unwrap(); // Actual first-reply publication.
14556            assert_eq!(epoch, 2);
14557            assert_eq!(link.sent.len(), 1);
14558            let ParentFrame::Run(next) = &link.sent[0] else {
14559                panic!("correlated continuation Run")
14560            };
14561            assert_eq!(next.execution_epoch, 2);
14562            assert_eq!(next.activation_run_id.as_deref(), Some(run_id));
14563            assert!(next
14564                .messages
14565                .iter()
14566                .any(|m| m["content"] == "FIRST_PLAIN_REPLY"));
14567            assert!(!next
14568                .messages
14569                .iter()
14570                .any(|m| m["id"] == envelope.id.as_str()));
14571            let delivery = &next.initial_session_messages[0];
14572            assert_eq!(delivery.envelope, envelope);
14573            let durable = store.load_session(&child.id).await.unwrap().unwrap();
14574            assert!(durable
14575                .session_inbox_admission()
14576                .unwrap()
14577                .contains(&envelope.id));
14578            assert_eq!(
14579                durable
14580                    .messages
14581                    .iter()
14582                    .filter(|m| m.id == envelope.id.as_str())
14583                    .count(),
14584                1
14585            );
14586            assert!(
14587                !activation
14588                    .input_inbox
14589                    .was_admitted(&child.id, &envelope.id)
14590                    .await
14591                    .unwrap(),
14592                "checkpoint is not a worker admission receipt"
14593            );
14594            let mut confirmation = admission_confirmation(&child.id, &claim.claim, run_id);
14595            confirmation.canonical_claim_generation += 1;
14596            assert!(activation
14597                .confirm_input(&binding, &child, run_id, &claim, &confirmation)
14598                .await
14599                .is_err());
14600            assert!(!activation
14601                .input_inbox
14602                .was_admitted(&child.id, &envelope.id)
14603                .await
14604                .unwrap());
14605            confirmation.canonical_claim_generation = claim.claim.generation;
14606            activation
14607                .confirm_input(&binding, &child, run_id, &claim, &confirmation)
14608                .await
14609                .unwrap();
14610            assert!(activation
14611                .input_inbox
14612                .was_admitted(&child.id, &envelope.id)
14613                .await
14614                .unwrap());
14615            assert_eq!(
14616                expected.as_ref().unwrap().expected_audit_revision,
14617                Some(first_audit)
14618            );
14619            let ChildFrame::Event { event } = permission_posture_frame(&child.id, policy_revision)
14620            else {
14621                unreachable!()
14622            };
14623            process_actor_event(
14624                event,
14625                true,
14626                &mut PermissionPostureHandshake::Awaiting,
14627                expected.as_ref(),
14628                Some(&binding),
14629                &mut child,
14630                &tx,
14631                &mut ActorEventDisplay::default(),
14632            )
14633            .await
14634            .unwrap();
14635            rx.recv().await.unwrap();
14636            assert!(
14637                bamboo_domain::PermissionAuditSnapshot::from_metadata(&child.metadata)
14638                    .unwrap()
14639                    .audit_revision
14640                    > first_audit
14641            );
14642            let reopened = bamboo_storage::SessionStoreV2::new(temp.path().into())
14643                .await
14644                .unwrap();
14645            let cold = reopened.load_session(&child.id).await.unwrap().unwrap();
14646            assert_eq!(
14647                cold.messages
14648                    .iter()
14649                    .filter(|m| m.id == envelope.id.as_str())
14650                    .count(),
14651                1
14652            );
14653            assert!(cold
14654                .session_inbox_admission()
14655                .unwrap()
14656                .contains(&envelope.id));
14657        }
14658    }
14659
14660    fn admission_confirmation(
14661        session_id: &str,
14662        claim: &SessionInboxClaim,
14663        run_id: &str,
14664    ) -> bamboo_subagent::proto::SessionMessageAdmissionConfirmation {
14665        bamboo_subagent::proto::SessionMessageAdmissionConfirmation {
14666            target_session_id: session_id.to_string(),
14667            envelope_id: claim.envelope.id.to_string(),
14668            canonical_claim_generation: claim.generation,
14669            activation_run_id: run_id.to_string(),
14670        }
14671    }
14672
14673    fn expected_default_permission_posture(policy_revision: u64) -> ExpectedPermissionPosture {
14674        ExpectedPermissionPosture {
14675            policy_revision,
14676            resolution: bamboo_domain::PermissionModeResolution {
14677                requested: bamboo_domain::SessionPermissionMode::Default,
14678                effective: bamboo_domain::PermissionMode::Default,
14679            },
14680            expected_audit_revision: None,
14681            executor_mapping: "test_actor:permission_mode=default".to_string(),
14682        }
14683    }
14684
14685    fn permission_posture_frame(session_id: &str, policy_revision: u64) -> ChildFrame {
14686        ChildFrame::Event {
14687            event: serde_json::to_value(AgentEvent::PermissionPostureActivated {
14688                session_id: session_id.to_string(),
14689                policy_revision,
14690                requested_mode: "default".to_string(),
14691                effective_mode: "default".to_string(),
14692                executor_mapping: "test_actor:permission_mode=default".to_string(),
14693            })
14694            .expect("serialize permission posture event"),
14695        }
14696    }
14697
14698    fn actor_event_frame(event: AgentEvent) -> ChildFrame {
14699        ChildFrame::Event {
14700            event: serde_json::to_value(event).expect("serialize actor event"),
14701        }
14702    }
14703
14704    #[test]
14705    fn actor_event_batch_is_fenced_by_session_activation_and_epoch() {
14706        let session = Session::new_child("logical-child", "logical-parent", "model", "child");
14707        let mut batch = ActorEventBatch {
14708            logical_session: Some(LogicalSessionIdentity {
14709                creation: None,
14710                session_id: session.id.clone(),
14711                parent_session_id: session.parent_session_id.clone(),
14712                root_session_id: session.root_session_id.clone(),
14713            }),
14714            activation_id: Some("activation-7".into()),
14715            execution_epoch: 11,
14716            source_node_id: Some("node-a".into()),
14717            source_actor_id: Some("worker-a".into()),
14718            first_seq: 1,
14719            last_seq: 1,
14720            qos: bamboo_subagent::ActorEventQos::Ephemeral,
14721            events: vec![serde_json::json!({"type":"token","content":"ok"})],
14722        };
14723        validate_actor_event_batch(
14724            &batch,
14725            &session,
14726            "logical-parent",
14727            Some("activation-7"),
14728            11,
14729            "worker-a",
14730            None,
14731        )
14732        .unwrap();
14733
14734        let creation = bamboo_subagent::proto::ChildCreationIdentity {
14735            created_at: session.created_at,
14736            spawn_depth: session.spawn_depth,
14737        };
14738        assert!(validate_actor_event_batch(
14739            &batch,
14740            &session,
14741            "logical-parent",
14742            Some("activation-7"),
14743            11,
14744            "worker-a",
14745            Some(&creation)
14746        )
14747        .is_err());
14748        batch.logical_session.as_mut().unwrap().creation = Some(creation.clone());
14749        validate_actor_event_batch(
14750            &batch,
14751            &session,
14752            "logical-parent",
14753            Some("activation-7"),
14754            11,
14755            "worker-a",
14756            Some(&creation),
14757        )
14758        .unwrap();
14759        batch
14760            .logical_session
14761            .as_mut()
14762            .unwrap()
14763            .creation
14764            .as_mut()
14765            .unwrap()
14766            .created_at += chrono::Duration::nanoseconds(1);
14767        assert!(validate_actor_event_batch(
14768            &batch,
14769            &session,
14770            "logical-parent",
14771            Some("activation-7"),
14772            11,
14773            "worker-a",
14774            Some(&creation)
14775        )
14776        .is_err());
14777        batch.logical_session.as_mut().unwrap().creation = None;
14778
14779        batch.execution_epoch = 10;
14780        assert!(validate_actor_event_batch(
14781            &batch,
14782            &session,
14783            "logical-parent",
14784            Some("activation-7"),
14785            11,
14786            "worker-a",
14787            None,
14788        )
14789        .unwrap_err()
14790        .to_string()
14791        .contains("execution epoch"));
14792        batch.execution_epoch = 11;
14793        batch.activation_id = Some("replaced-activation".into());
14794        assert!(validate_actor_event_batch(
14795            &batch,
14796            &session,
14797            "logical-parent",
14798            Some("activation-7"),
14799            11,
14800            "worker-a",
14801            None,
14802        )
14803        .unwrap_err()
14804        .to_string()
14805        .contains("stale activation"));
14806        batch.activation_id = Some("activation-7".into());
14807        batch
14808            .logical_session
14809            .as_mut()
14810            .expect("logical identity")
14811            .session_id = "foreign-child".into();
14812        assert!(validate_actor_event_batch(
14813            &batch,
14814            &session,
14815            "logical-parent",
14816            Some("activation-7"),
14817            11,
14818            "worker-a",
14819            None,
14820        )
14821        .unwrap_err()
14822        .to_string()
14823        .contains("different logical session"));
14824        batch
14825            .logical_session
14826            .as_mut()
14827            .expect("logical identity")
14828            .session_id = session.id.clone();
14829        batch.source_actor_id = Some("worker-b".into());
14830        assert!(validate_actor_event_batch(
14831            &batch,
14832            &session,
14833            "logical-parent",
14834            Some("activation-7"),
14835            11,
14836            "worker-a",
14837            None,
14838        )
14839        .unwrap_err()
14840        .to_string()
14841        .contains("different physical actor"));
14842    }
14843
14844    #[tokio::test]
14845    async fn directory_event_ingress_rejects_old_attempt_and_forged_identity() {
14846        use bamboo_domain::Storage;
14847
14848        let temp = tempfile::tempdir().unwrap();
14849        let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
14850            .await
14851            .unwrap();
14852        let parent = Session::new("event-parent", "model");
14853        let child = Session::new_child_of("event-child", &parent, "model", "task");
14854        store.save_session(&parent).await.unwrap();
14855        store.save_session(&child).await.unwrap();
14856        store.ensure_actor(&child.id).await.unwrap();
14857        let now = chrono::Utc::now();
14858        let claim = |run_id: &str| ActorActivationClaim {
14859            actor_id: child.id.clone(),
14860            run_id: run_id.into(),
14861            lease_owner: "event-owner".into(),
14862            lease_expires_at: now + chrono::Duration::minutes(2),
14863            inbox_generation: 0,
14864            placement_ref: None,
14865            now,
14866        };
14867        let first = store.claim_activation(&claim("run-one")).await.unwrap();
14868        store.start_activation(&first.fence(), now).await.unwrap();
14869        let first_fence = first.fence();
14870        validate_directory_actor_event(&store, &first_fence, &child, Some("run-one"))
14871            .await
14872            .unwrap();
14873        validate_directory_actor_event(&store, &first_fence, &child, None)
14874            .await
14875            .unwrap(); // rolling-upgrade frame uses the same owner
14876        assert!(
14877            validate_directory_actor_event(&store, &first_fence, &child, Some("forged-run"))
14878                .await
14879                .is_err()
14880        );
14881        let mut forged = child.clone();
14882        forged.root_session_id = "foreign-root".into();
14883        assert!(
14884            validate_directory_actor_event(&store, &first_fence, &forged, Some("run-one"))
14885                .await
14886                .is_err()
14887        );
14888
14889        store
14890            .finish_activation(&first_fence, now, ActorActivationFinish::Failed)
14891            .await
14892            .unwrap();
14893        let second = store.claim_activation(&claim("run-two")).await.unwrap();
14894        store.start_activation(&second.fence(), now).await.unwrap();
14895        assert!(
14896            validate_directory_actor_event(&store, &first_fence, &child, Some("run-one"))
14897                .await
14898                .is_err()
14899        );
14900        assert!(
14901            validate_directory_actor_event(&store, &first_fence, &child, None)
14902                .await
14903                .is_err()
14904        );
14905        validate_directory_actor_event(&store, &second.fence(), &child, Some("run-two"))
14906            .await
14907            .unwrap();
14908    }
14909
14910    fn completed_actor_frame() -> ChildFrame {
14911        ChildFrame::Terminal {
14912            status: TerminalStatus::Completed,
14913            result: Some("done".to_string()),
14914            error: None,
14915            final_event_watermark: None,
14916            transcript: Vec::new(),
14917        }
14918    }
14919
14920    async fn drive_permission_handshake_frames(
14921        session_id: &str,
14922        frames: impl IntoIterator<Item = ChildFrame>,
14923        expected: ExpectedPermissionPosture,
14924    ) -> (
14925        crate::runtime::runner::Result<Option<String>>,
14926        Session,
14927        Vec<AgentEvent>,
14928        Vec<ParentFrame>,
14929    ) {
14930        let mut link = ConfirmationSequenceLink {
14931            frames: frames.into_iter().collect(),
14932            sent: Vec::new(),
14933        };
14934        let (event_tx, mut event_rx) = mpsc::channel(128);
14935        let cancel = CancellationToken::new();
14936        let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
14937        let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
14938        let mut session = Session::new(session_id, "model");
14939        let result = drive(ActorDriveContext {
14940            expected_creation: None,
14941            client: &mut link,
14942            parent_session_id: "permission-parent",
14943            child_session_id: session_id,
14944            child_attempt: 0,
14945            approval_registry: None,
14946            approval_decider: None,
14947            approval_reviewer: None,
14948            escalation_bridge: None,
14949            event_tx: &event_tx,
14950            cancel_token: &cancel,
14951            live_rx: &mut live_rx,
14952            delivery_rx: &mut delivery_rx,
14953            logical_session: &mut session,
14954            expected_permission_posture: Some(expected),
14955            session_inbox_runtime: None,
14956            actor_directory_store: None,
14957            canonical_subagent_tool: None,
14958            ticket_service: None,
14959            activation_run_id: None,
14960            execution_epoch: 0,
14961            expected_source_actor_id: session_id,
14962            initial_inflight_claims: VecDeque::new(),
14963            plain_actor: false,
14964            remote_environment_lease: false,
14965            readonly_output: None,
14966            local_history_tools: None,
14967            local_history_read_only: false,
14968            strict_history_output: None,
14969            plain_input: None,
14970            canonical_activation: None,
14971            canonical_placement_ref: None,
14972            actor_event_observer: None,
14973            plain_run: None,
14974            first_frame_timeout: Some(Duration::from_secs(1)),
14975        })
14976        .await;
14977        let events = std::iter::from_fn(|| event_rx.try_recv().ok()).collect();
14978        (result, session, events, link.sent)
14979    }
14980
14981    #[tokio::test]
14982    async fn actor_permission_handshake_rejects_terminal_without_posture() {
14983        let session_id = "permission-missing";
14984        let (result, session, events, _) = drive_permission_handshake_frames(
14985            session_id,
14986            [completed_actor_frame()],
14987            expected_default_permission_posture(7),
14988        )
14989        .await;
14990
14991        assert!(result
14992            .unwrap_err()
14993            .to_string()
14994            .contains("terminated before permission posture confirmation"));
14995        assert!(events.is_empty());
14996        assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
14997    }
14998
14999    #[tokio::test]
15000    async fn remote_environment_rejection_accepts_only_allowlisted_durable_terminal() {
15001        struct TerminalProofProbe {
15002            frame: Option<ChildFrame>,
15003            accepted: bool,
15004        }
15005
15006        #[async_trait]
15007        impl bamboo_subagent::ChildLink for TerminalProofProbe {
15008            async fn send(&mut self, _frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
15009                Ok(())
15010            }
15011
15012            async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
15013                Ok(self.frame.take())
15014            }
15015
15016            fn accept_durable_terminal(&mut self, status: TerminalStatus) {
15017                self.accepted = status == TerminalStatus::Error;
15018            }
15019
15020            fn has_pending_durable_terminal(&self) -> bool {
15021                self.accepted
15022            }
15023        }
15024
15025        for (status, error, expected_proof) in [
15026            (
15027                TerminalStatus::Error,
15028                Some("remote_environment_checkout_not_clean"),
15029                true,
15030            ),
15031            (TerminalStatus::Error, Some("untrusted worker error"), false),
15032            (
15033                TerminalStatus::Cancelled,
15034                Some("remote_environment_checkout_not_clean"),
15035                false,
15036            ),
15037        ] {
15038            let mut link = TerminalProofProbe {
15039                frame: Some(ChildFrame::Terminal {
15040                    status,
15041                    result: None,
15042                    error: error.map(str::to_owned),
15043                    final_event_watermark: None,
15044                    transcript: Vec::new(),
15045                }),
15046                accepted: false,
15047            };
15048            let (event_tx, _event_rx) = mpsc::channel(8);
15049            let cancel = CancellationToken::new();
15050            let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
15051            let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
15052            let mut session = Session::new("remote-terminal-proof", "model");
15053            let result = drive(ActorDriveContext {
15054                client: &mut link,
15055                parent_session_id: "proof-parent",
15056                child_session_id: "remote-terminal-proof",
15057                child_attempt: 0,
15058                approval_registry: None,
15059                approval_decider: None,
15060                approval_reviewer: None,
15061                escalation_bridge: None,
15062                event_tx: &event_tx,
15063                cancel_token: &cancel,
15064                live_rx: &mut live_rx,
15065                delivery_rx: &mut delivery_rx,
15066                logical_session: &mut session,
15067                expected_permission_posture: Some(expected_default_permission_posture(7)),
15068                expected_creation: None,
15069                session_inbox_runtime: None,
15070                actor_directory_store: None,
15071                canonical_subagent_tool: None,
15072                ticket_service: None,
15073                activation_run_id: None,
15074                execution_epoch: 0,
15075                expected_source_actor_id: "remote-terminal-proof",
15076                initial_inflight_claims: VecDeque::new(),
15077                plain_actor: false,
15078                remote_environment_lease: true,
15079                readonly_output: None,
15080                local_history_tools: None,
15081                local_history_read_only: false,
15082                strict_history_output: None,
15083                plain_input: None,
15084                canonical_activation: None,
15085                canonical_placement_ref: None,
15086                actor_event_observer: None,
15087                plain_run: None,
15088                first_frame_timeout: Some(Duration::from_secs(1)),
15089            })
15090            .await;
15091            assert!(result.is_err());
15092            assert_eq!(
15093                bamboo_subagent::ChildLink::has_pending_durable_terminal(&link),
15094                expected_proof
15095            );
15096        }
15097    }
15098
15099    #[tokio::test(start_paused = true)]
15100    async fn remote_cancel_requires_selected_worker_terminal_for_release_proof() {
15101        struct CancelOutcomeLink {
15102            cancel_sent: bool,
15103            settle: bool,
15104            accepted: bool,
15105        }
15106
15107        #[async_trait]
15108        impl bamboo_subagent::ChildLink for CancelOutcomeLink {
15109            async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
15110                self.cancel_sent = matches!(frame, ParentFrame::Cancel);
15111                Ok(())
15112            }
15113
15114            async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
15115                if !self.cancel_sent || !self.settle {
15116                    std::future::pending::<bamboo_subagent::TransportResult<Option<ChildFrame>>>()
15117                        .await
15118                } else {
15119                    self.cancel_sent = false;
15120                    Ok(Some(ChildFrame::Terminal {
15121                        status: TerminalStatus::Cancelled,
15122                        result: None,
15123                        error: None,
15124                        final_event_watermark: None,
15125                        transcript: Vec::new(),
15126                    }))
15127                }
15128            }
15129
15130            fn accept_durable_terminal(&mut self, status: TerminalStatus) {
15131                self.accepted = status == TerminalStatus::Cancelled;
15132            }
15133
15134            fn has_pending_durable_terminal(&self) -> bool {
15135                self.accepted
15136            }
15137        }
15138
15139        let temp = tempfile::tempdir().unwrap();
15140        let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
15141            .await
15142            .unwrap();
15143        let fence = ActorActivationFence {
15144            schema_version: 2,
15145            actor_id: "cancelled-remote-child".into(),
15146            activation_id: "cancelled-remote-activation".into(),
15147            attempt: 1,
15148            run_id: "cancelled-remote-run".into(),
15149            lease_owner: "cancelled-remote-host".into(),
15150            lease_epoch: 1,
15151        };
15152        for settle in [true, false] {
15153            let mut link = CancelOutcomeLink {
15154                cancel_sent: false,
15155                settle,
15156                accepted: false,
15157            };
15158            let (event_tx, _event_rx) = mpsc::channel(8);
15159            let cancel = CancellationToken::new();
15160            cancel.cancel();
15161            let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
15162            let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
15163            let mut session = Session::new("cancelled-remote-child", "model");
15164            let result = drive(ActorDriveContext {
15165                expected_creation: None,
15166                client: &mut link,
15167                parent_session_id: "cancelled-remote-parent",
15168                child_session_id: "cancelled-remote-child",
15169                child_attempt: 1,
15170                approval_registry: None,
15171                approval_decider: None,
15172                approval_reviewer: None,
15173                escalation_bridge: None,
15174                event_tx: &event_tx,
15175                cancel_token: &cancel,
15176                live_rx: &mut live_rx,
15177                delivery_rx: &mut delivery_rx,
15178                logical_session: &mut session,
15179                expected_permission_posture: Some(expected_default_permission_posture(7)),
15180                session_inbox_runtime: None,
15181                actor_directory_store: Some(&store),
15182                canonical_subagent_tool: None,
15183                ticket_service: None,
15184                activation_run_id: Some("cancelled-remote-run"),
15185                execution_epoch: 0,
15186                expected_source_actor_id: "cancelled-remote-worker",
15187                initial_inflight_claims: VecDeque::new(),
15188                plain_actor: false,
15189                remote_environment_lease: true,
15190                readonly_output: None,
15191                local_history_tools: None,
15192                local_history_read_only: false,
15193                strict_history_output: None,
15194                plain_input: None,
15195                canonical_activation: Some(&fence),
15196                canonical_placement_ref: None,
15197                actor_event_observer: None,
15198                plain_run: None,
15199                first_frame_timeout: None,
15200            })
15201            .await;
15202            assert!(matches!(result, Err(AgentError::Cancelled)));
15203            assert_eq!(
15204                link.accepted, settle,
15205                "only a consumed exact terminal can release the remote fence"
15206            );
15207            assert_eq!(
15208                bamboo_subagent::ChildLink::has_pending_durable_terminal(&link),
15209                settle
15210            );
15211        }
15212    }
15213
15214    #[tokio::test]
15215    async fn actor_permission_handshake_rejects_malformed_agent_event() {
15216        let session_id = "permission-malformed";
15217        let (result, session, events, _) = drive_permission_handshake_frames(
15218            session_id,
15219            [ChildFrame::Event {
15220                event: serde_json::json!({"type": "token", "content": 42}),
15221            }],
15222            expected_default_permission_posture(7),
15223        )
15224        .await;
15225
15226        assert!(result
15227            .unwrap_err()
15228            .to_string()
15229            .contains("malformed AgentEvent"));
15230        assert!(events.is_empty());
15231        assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
15232
15233        let (result, _, events, _) = drive_permission_handshake_frames(
15234            session_id,
15235            [
15236                permission_posture_frame(session_id, 7),
15237                ChildFrame::Event {
15238                    event: serde_json::json!({"type":"private-download-secret"}),
15239                },
15240            ],
15241            expected_default_permission_posture(7),
15242        )
15243        .await;
15244        assert!(!result
15245            .unwrap_err()
15246            .to_string()
15247            .contains("private-download-secret"));
15248        assert_eq!(
15249            events.len(),
15250            1,
15251            "only the confirmed posture may be forwarded"
15252        );
15253    }
15254
15255    #[tokio::test]
15256    async fn actor_permission_handshake_rejects_execution_event_before_posture() {
15257        let session_id = "permission-early-event";
15258        let early_events = [
15259            (
15260                "progress",
15261                AgentEvent::RunnerProgress {
15262                    session_id: session_id.to_string(),
15263                    round_count: 1,
15264                },
15265            ),
15266            (
15267                "token",
15268                AgentEvent::Token {
15269                    content: "must-not-forward".to_string(),
15270                },
15271            ),
15272            (
15273                "tool",
15274                AgentEvent::ToolStart {
15275                    tool_call_id: "early-tool".to_string(),
15276                    tool_name: "Read".to_string(),
15277                    arguments: serde_json::json!({"file_path": "README.md"}),
15278                },
15279            ),
15280        ];
15281        for (kind, event) in early_events {
15282            let (result, session, events, _) = drive_permission_handshake_frames(
15283                session_id,
15284                [
15285                    actor_event_frame(event),
15286                    permission_posture_frame(session_id, 7),
15287                    completed_actor_frame(),
15288                ],
15289                expected_default_permission_posture(7),
15290            )
15291            .await;
15292
15293            assert!(
15294                result
15295                    .unwrap_err()
15296                    .to_string()
15297                    .contains("execution event before permission posture confirmation"),
15298                "{kind} must fail closed before posture"
15299            );
15300            assert!(events.is_empty(), "{kind} must not be forwarded");
15301            assert!(
15302                bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none(),
15303                "{kind} must not advance the permission audit"
15304            );
15305        }
15306    }
15307
15308    #[tokio::test]
15309    async fn actor_permission_handshake_rejects_approval_before_posture() {
15310        let session_id = "permission-early-approval";
15311        let (result, session, events, sent) = drive_permission_handshake_frames(
15312            session_id,
15313            [ChildFrame::ApprovalRequest {
15314                id: "approval-before-posture".to_string(),
15315                body: serde_json::json!({
15316                    "tool_name": "Bash",
15317                    "permission": "execute",
15318                    "resource": "echo must-not-run"
15319                }),
15320            }],
15321            expected_default_permission_posture(7),
15322        )
15323        .await;
15324
15325        assert!(result
15326            .unwrap_err()
15327            .to_string()
15328            .contains("requested approval before permission posture confirmation"));
15329        assert!(events.is_empty());
15330        assert!(
15331            sent.is_empty(),
15332            "an unconfirmed actor must receive no approval reply"
15333        );
15334        assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
15335    }
15336
15337    #[tokio::test]
15338    async fn actor_permission_handshake_rejects_mismatched_posture() {
15339        let session_id = "permission-mismatch";
15340        let (result, session, events, _) = drive_permission_handshake_frames(
15341            session_id,
15342            [permission_posture_frame(session_id, 8)],
15343            expected_default_permission_posture(7),
15344        )
15345        .await;
15346
15347        assert!(result
15348            .unwrap_err()
15349            .to_string()
15350            .contains("does not match the host-dispatched policy"));
15351        assert!(events.is_empty());
15352        assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
15353    }
15354
15355    #[tokio::test]
15356    async fn actor_permission_handshake_rejects_untrusted_executor_mapping() {
15357        let session_id = "permission-hostile-mapping";
15358        for hostile_mapping in [
15359            "wrong_executor:permission_mode=default",
15360            "test_actor:permission_mode=default;credential=must-not-persist",
15361        ] {
15362            let frame = ChildFrame::Event {
15363                event: serde_json::to_value(AgentEvent::PermissionPostureActivated {
15364                    session_id: session_id.to_string(),
15365                    policy_revision: 7,
15366                    requested_mode: "default".to_string(),
15367                    effective_mode: "default".to_string(),
15368                    executor_mapping: hostile_mapping.to_string(),
15369                })
15370                .expect("serialize hostile posture fixture"),
15371            };
15372            let (result, session, events, _) = drive_permission_handshake_frames(
15373                session_id,
15374                [frame],
15375                expected_default_permission_posture(7),
15376            )
15377            .await;
15378
15379            let error = result.unwrap_err().to_string();
15380            assert!(error.contains("host-dispatched executor mapping"));
15381            assert!(
15382                !error.contains(hostile_mapping),
15383                "untrusted mapping must not be reflected in host errors"
15384            );
15385            assert!(events.is_empty());
15386            assert!(
15387                bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none(),
15388                "untrusted mapping must not reach durable or in-memory audit state"
15389            );
15390        }
15391    }
15392
15393    #[tokio::test]
15394    async fn actor_permission_handshake_rejects_duplicate_posture() {
15395        let session_id = "permission-duplicate";
15396        let (result, session, events, _) = drive_permission_handshake_frames(
15397            session_id,
15398            [
15399                permission_posture_frame(session_id, 7),
15400                permission_posture_frame(session_id, 7),
15401                completed_actor_frame(),
15402            ],
15403            expected_default_permission_posture(7),
15404        )
15405        .await;
15406
15407        assert!(result
15408            .unwrap_err()
15409            .to_string()
15410            .contains("duplicate permission posture activation"));
15411        assert_eq!(
15412            events.len(),
15413            1,
15414            "only the confirmed posture may be forwarded"
15415        );
15416        assert!(matches!(
15417            events[0],
15418            AgentEvent::PermissionPostureActivated { .. }
15419        ));
15420        let audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
15421            .expect("the first matching posture must be recorded");
15422        assert_eq!(audit.policy_revision, 7);
15423    }
15424
15425    #[tokio::test]
15426    async fn actor_permission_handshake_happy_path_persists_before_forwarding_execution() {
15427        let session_id = "permission-happy";
15428        let (result, session, events, _) = drive_permission_handshake_frames(
15429            session_id,
15430            [
15431                permission_posture_frame(session_id, 7),
15432                actor_event_frame(AgentEvent::RunnerProgress {
15433                    session_id: session_id.to_string(),
15434                    round_count: 1,
15435                }),
15436                actor_event_frame(AgentEvent::Token {
15437                    content: "working".to_string(),
15438                }),
15439                actor_event_frame(AgentEvent::ToolStart {
15440                    tool_call_id: "tool-1".to_string(),
15441                    tool_name: "Read".to_string(),
15442                    arguments: serde_json::json!({"file_path": "README.md"}),
15443                }),
15444                completed_actor_frame(),
15445            ],
15446            expected_default_permission_posture(7),
15447        )
15448        .await;
15449
15450        assert_eq!(result.unwrap().as_deref(), Some("done"));
15451        assert!(matches!(
15452            events.as_slice(),
15453            [
15454                AgentEvent::PermissionPostureActivated { .. },
15455                AgentEvent::RunnerProgress { .. },
15456                AgentEvent::Token { .. },
15457                AgentEvent::ToolStart { .. }
15458            ]
15459        ));
15460        let audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
15461            .expect("matching posture must be recorded before execution events are accepted");
15462        assert_eq!(audit.policy_revision, 7);
15463        assert_eq!(audit.executor_mapping, "test_actor:permission_mode=default");
15464    }
15465
15466    #[tokio::test]
15467    async fn actor_download_tool_start_is_projected_before_parent_forwarding() {
15468        for (index, tool_name) in ["browser", "default::browser", "private-namespace::browser"]
15469            .into_iter()
15470            .enumerate()
15471        {
15472            let session_id = format!("actor-download-{index}");
15473            let args = serde_json::json!({
15474                "action":"download",
15475                "selector":"a[data-secret='private-selector']",
15476                "expected_epoch":17,
15477                "data_base64":"private-extra-bytes",
15478                "extra":{"url":"https://private.test/file"},
15479            });
15480            let original = args.clone();
15481            let read_args = serde_json::json!({"file_path":"README.md"});
15482            let (result, _session, events, _) = drive_permission_handshake_frames(
15483                &session_id,
15484                [
15485                    permission_posture_frame(&session_id, 7),
15486                    actor_event_frame(AgentEvent::ToolStart {
15487                        tool_call_id: "download-call".to_string(),
15488                        tool_name: tool_name.to_string(),
15489                        arguments: args.clone(),
15490                    }),
15491                    actor_event_frame(AgentEvent::ToolStart {
15492                        tool_call_id: "read-call".to_string(),
15493                        tool_name: "Read".to_string(),
15494                        arguments: read_args.clone(),
15495                    }),
15496                    completed_actor_frame(),
15497                ],
15498                expected_default_permission_posture(7),
15499            )
15500            .await;
15501            assert_eq!(result.expect("actor completed").as_deref(), Some("done"));
15502            assert_eq!(args, original);
15503            let starts: Vec<_> = events
15504                .iter()
15505                .filter_map(|event| match event {
15506                    AgentEvent::ToolStart {
15507                        tool_name,
15508                        arguments,
15509                        ..
15510                    } => Some((tool_name.as_str(), arguments)),
15511                    _ => None,
15512                })
15513                .collect();
15514            assert_eq!(starts.len(), 2);
15515            assert_eq!(starts[0].0, "browser");
15516            assert_eq!(
15517                starts[0].1,
15518                &serde_json::json!({"action":"download","expected_epoch":17})
15519            );
15520            assert_eq!(starts[1], ("Read", &read_args));
15521            assert!(!format!("{events:?}").contains("private"));
15522        }
15523    }
15524
15525    #[tokio::test]
15526    async fn actor_hook_lifecycle_hides_every_decision_text_across_event_batch_and_nested_stream() {
15527        let session_id = "actor-hook-display";
15528        let private = "private-selector-url-filename-response";
15529        let hook_name = format!("audit-{private}");
15530        let hook = |duration_ms, phase: &str, decision| AgentEvent::HookLifecycle {
15531            hook_name: hook_name.clone(),
15532            point: bamboo_domain::AgentHookPoint::BeforeToolExecution,
15533            phase: phase.into(),
15534            duration_ms,
15535            decision,
15536        };
15537        let decisions = [
15538            HookResult::Deny {
15539                reason: private.into(),
15540            },
15541            HookResult::InjectContext {
15542                text: private.into(),
15543            },
15544            HookResult::WithContext {
15545                result: Box::new(HookResult::WithContext {
15546                    result: Box::new(HookResult::Deny {
15547                        reason: private.into(),
15548                    }),
15549                    text: private.into(),
15550                }),
15551                text: private.into(),
15552            },
15553            HookResult::Suspend {
15554                reason: private.into(),
15555            },
15556            HookResult::Abort {
15557                reason: private.into(),
15558            },
15559        ];
15560        let originals = decisions.to_vec();
15561        let batch_events = [
15562            hook(2, "completed", decisions[1].clone()),
15563            hook(3, "completed", decisions[2].clone()),
15564            hook(4, "completed", decisions[3].clone()),
15565        ]
15566        .into_iter()
15567        .map(|event| serde_json::to_value(event).unwrap())
15568        .collect::<Vec<_>>();
15569        let batch = ActorEventBatch {
15570            logical_session: Some(LogicalSessionIdentity {
15571                creation: None,
15572                session_id: session_id.into(),
15573                parent_session_id: Some("permission-parent".into()),
15574                root_session_id: session_id.into(),
15575            }),
15576            activation_id: None,
15577            execution_epoch: 0,
15578            source_node_id: None,
15579            source_actor_id: Some(session_id.into()),
15580            first_seq: 1,
15581            last_seq: batch_events.len() as u64,
15582            qos: bamboo_subagent::ActorEventQos::classify(&batch_events[0]),
15583            events: batch_events,
15584        };
15585        let nested = AgentEvent::SubAgentEvent {
15586            parent_session_id: "parent".into(),
15587            child_session_id: "child".into(),
15588            event: Box::new(hook(5, "completed", decisions[4].clone())),
15589        };
15590        let raw = serde_json::to_string(&originals).unwrap();
15591        assert!(raw.contains(private));
15592
15593        let (outcome, _session, forwarded, _) = drive_permission_handshake_frames(
15594            session_id,
15595            [
15596                permission_posture_frame(session_id, 7),
15597                actor_event_frame(hook(1, private, decisions[0].clone())),
15598                ChildFrame::EventBatch { batch },
15599                actor_event_frame(nested),
15600                completed_actor_frame(),
15601            ],
15602            expected_default_permission_posture(7),
15603        )
15604        .await;
15605        assert_eq!(outcome.unwrap().as_deref(), Some("done"));
15606        let wire = serde_json::to_string(&forwarded).unwrap();
15607        assert!(
15608            !wire.contains(private),
15609            "private hook text reached SSE: {wire}"
15610        );
15611
15612        let hooks = forwarded
15613            .iter()
15614            .filter_map(|event| match event {
15615                AgentEvent::HookLifecycle {
15616                    hook_name,
15617                    point,
15618                    phase,
15619                    duration_ms,
15620                    decision,
15621                } => Some((hook_name, point, phase, duration_ms, decision)),
15622                _ => None,
15623            })
15624            .collect::<Vec<_>>();
15625        assert_eq!(hooks.len(), 4);
15626        assert!(hooks.iter().all(|(name, point, _, _, _)| {
15627            name == &&"actor-hook-1".to_string()
15628                && **point == bamboo_domain::AgentHookPoint::BeforeToolExecution
15629        }));
15630        assert_eq!(hooks[0].2, "event");
15631        assert_eq!(hooks[1].2, "completed");
15632        assert_eq!(*hooks[0].3, 1);
15633        assert_eq!(*hooks[3].3, 4);
15634        assert!(matches!(hooks[0].4, HookResult::Deny { reason } if reason == "Hook denied"));
15635        assert!(
15636            matches!(hooks[1].4, HookResult::InjectContext { text } if text == "Hook context hidden")
15637        );
15638        assert!(
15639            matches!(hooks[2].4, HookResult::WithContext { result, text }
15640            if text == "Hook context hidden" && matches!(result.as_ref(),
15641                HookResult::WithContext { result, text }
15642                    if text == "Hook context hidden" && matches!(result.as_ref(),
15643                        HookResult::Deny { reason } if reason == "Hook denied")))
15644        );
15645        assert!(matches!(hooks[3].4, HookResult::Suspend { reason } if reason == "Hook suspended"));
15646        assert!(forwarded.iter().any(|event| matches!(event,
15647            AgentEvent::SubAgentEvent { event, .. }
15648                if matches!(event.as_ref(), AgentEvent::HookLifecycle {
15649                    hook_name, duration_ms: 5,
15650                    decision: HookResult::Abort { reason }, ..
15651                } if hook_name == "actor-hook-1" && reason == "Hook aborted")
15652        )));
15653    }
15654
15655    #[test]
15656    fn actor_hook_display_depth_and_identity_limits_fail_closed() {
15657        let mut display = ActorEventDisplay::default();
15658        let mut deep = HookResult::Allow;
15659        for _ in 0..=MAX_DISPLAY_HOOK_RESULT_DEPTH {
15660            deep = HookResult::WithContext {
15661                result: Box::new(deep),
15662                text: "private-download-bytes".into(),
15663            };
15664        }
15665        let event = |hook_name: String, decision| AgentEvent::HookLifecycle {
15666            hook_name,
15667            point: bamboo_domain::AgentHookPoint::BeforeToolExecution,
15668            phase: "completed".into(),
15669            duration_ms: 7,
15670            decision,
15671        };
15672        assert!(display.project(event("deep".into(), deep), 0).is_none());
15673        assert!(display.overflowed);
15674        assert!(display
15675            .project(
15676                event(
15677                    "private-download-bytes".into(),
15678                    HookResult::Deny {
15679                        reason: "private-download-bytes".into(),
15680                    },
15681                ),
15682                0,
15683            )
15684            .is_none());
15685
15686        let mut oversized = ActorEventDisplay::default();
15687        assert!(oversized
15688            .project(
15689                event("x".repeat(MAX_DISPLAY_ID_BYTES + 1), HookResult::Allow),
15690                0,
15691            )
15692            .is_none());
15693        assert!(oversized.overflowed);
15694
15695        // A new actor connection gets a fresh opaque alias, never the raw
15696        // hook name. Repeated names keep the same alias until the shared
15697        // identity budget is exhausted, then the stream fails closed.
15698        let mut reconnected = ActorEventDisplay::default();
15699        let private = "private-download-bytes";
15700        for _ in 0..2 {
15701            let projected = reconnected
15702                .project(event(private.into(), HookResult::Allow), 0)
15703                .unwrap();
15704            assert!(
15705                matches!(projected, AgentEvent::HookLifecycle { hook_name, .. }
15706                if hook_name == "actor-hook-1")
15707            );
15708        }
15709        assert_eq!(reconnected.identities.load(Ordering::Relaxed), 1);
15710        for index in 1..MAX_DISPLAY_CALLS {
15711            assert!(reconnected
15712                .project(event(format!("hook-{index}"), HookResult::Allow), 0)
15713                .is_some());
15714        }
15715        assert!(reconnected
15716            .project(event("overflow-private".into(), HookResult::Allow), 0)
15717            .is_none());
15718        assert!(reconnected.overflowed);
15719        assert!(reconnected.hook_names.is_empty());
15720    }
15721
15722    #[test]
15723    fn actor_hook_display_preserves_nontext_decision_kinds() {
15724        for result in [
15725            HookResult::Continue,
15726            HookResult::Mutated,
15727            HookResult::Allow,
15728            HookResult::Ask,
15729        ] {
15730            assert_eq!(hook_result_for_display(result.clone(), 0), Some(result));
15731        }
15732    }
15733
15734    #[tokio::test]
15735    async fn malformed_actor_hook_events_never_fall_back_to_raw_display() {
15736        let session_id = "actor-malformed-hook";
15737        let private = "private-download-selector";
15738        let original = AgentEvent::HookLifecycle {
15739            hook_name: "audit".into(),
15740            point: bamboo_domain::AgentHookPoint::BeforeToolExecution,
15741            phase: "completed".into(),
15742            duration_ms: 9,
15743            decision: HookResult::Deny {
15744                reason: private.into(),
15745            },
15746        };
15747        let mut invalid = serde_json::to_value(&original).unwrap();
15748        invalid["decision"]["type"] = serde_json::json!("unknown_decision");
15749        let nested = AgentEvent::SubAgentEvent {
15750            parent_session_id: "parent".into(),
15751            child_session_id: "child".into(),
15752            event: Box::new(original),
15753        };
15754        let mut invalid_nested = serde_json::to_value(nested).unwrap();
15755        invalid_nested["event"]["decision"]["type"] = serde_json::json!("unknown_decision");
15756        let batch = ActorEventBatch {
15757            logical_session: Some(LogicalSessionIdentity {
15758                creation: None,
15759                session_id: session_id.into(),
15760                parent_session_id: Some("permission-parent".into()),
15761                root_session_id: session_id.into(),
15762            }),
15763            activation_id: None,
15764            execution_epoch: 0,
15765            source_node_id: None,
15766            source_actor_id: Some(session_id.into()),
15767            first_seq: 1,
15768            last_seq: 1,
15769            qos: bamboo_subagent::ActorEventQos::classify(&invalid),
15770            events: vec![invalid.clone()],
15771        };
15772        for frame in [
15773            ChildFrame::Event {
15774                event: invalid.clone(),
15775            },
15776            ChildFrame::EventBatch { batch },
15777            ChildFrame::Event {
15778                event: invalid_nested,
15779            },
15780        ] {
15781            let (outcome, _session, forwarded, _) = drive_permission_handshake_frames(
15782                session_id,
15783                [permission_posture_frame(session_id, 7), frame],
15784                expected_default_permission_posture(7),
15785            )
15786            .await;
15787            let error = outcome.unwrap_err().to_string();
15788            assert!(error.contains("malformed AgentEvent"), "{error}");
15789            assert!(!error.contains(private));
15790            assert!(!serde_json::to_string(&forwarded).unwrap().contains(private));
15791        }
15792
15793        let (event_tx, mut event_rx) = mpsc::channel(8);
15794        let mut session = Session::new("legacy-hook", "model");
15795        let mut handshake = PermissionPostureHandshake::NotRequired;
15796        let mut display = ActorEventDisplay::default();
15797        process_actor_event(
15798            invalid,
15799            false,
15800            &mut handshake,
15801            None,
15802            None,
15803            &mut session,
15804            &event_tx,
15805            &mut display,
15806        )
15807        .await
15808        .unwrap();
15809        assert!(display.overflowed);
15810        assert!(event_rx.try_recv().is_err());
15811    }
15812
15813    #[tokio::test]
15814    async fn actor_download_event_batch_hides_output_and_preserves_read_output() {
15815        let session_id = "actor-download-batch";
15816        let private = "private-download-bytes-and-url";
15817        let events = vec![
15818            AgentEvent::ToolStart {
15819                tool_call_id: "download-call".into(),
15820                tool_name: "default::browser".into(),
15821                arguments: serde_json::json!({
15822                    "action":"download", "selector": private, "expected_epoch": 17,
15823                }),
15824            },
15825            AgentEvent::ToolApprovalRequested {
15826                tool_call_id: "download-call".into(),
15827                tool_name: format!("{private}::browser"),
15828                parameters: serde_json::json!({
15829                    "action":"download", "selector": private, "expected_epoch": 17,
15830                }),
15831            },
15832            AgentEvent::ToolStart {
15833                tool_call_id: "read-call".into(),
15834                tool_name: "Read".into(),
15835                arguments: serde_json::json!({"file_path":"README.md"}),
15836            },
15837        ];
15838        let batch = ActorEventBatch {
15839            logical_session: Some(LogicalSessionIdentity {
15840                creation: None,
15841                session_id: session_id.into(),
15842                parent_session_id: Some("permission-parent".into()),
15843                root_session_id: session_id.into(),
15844            }),
15845            activation_id: None,
15846            execution_epoch: 0,
15847            source_node_id: None,
15848            source_actor_id: Some(session_id.into()),
15849            first_seq: 1,
15850            last_seq: events.len() as u64,
15851            qos: bamboo_subagent::ActorEventQos::Durable,
15852            events: events
15853                .into_iter()
15854                .map(|event| serde_json::to_value(event).unwrap())
15855                .collect(),
15856        };
15857        let result = bamboo_agent_core::tools::ToolResult {
15858            success: true,
15859            result: format!("{{\"data_base64\":\"{private}\"}}"),
15860            display_preference: Some(private.into()),
15861            images: vec![bamboo_agent_core::tools::ToolResultImage {
15862                mime_type: "image/png".into(),
15863                data: private.into(),
15864            }],
15865        };
15866        let (outcome, _, forwarded, _) = drive_permission_handshake_frames(
15867            session_id,
15868            [
15869                permission_posture_frame(session_id, 7),
15870                ChildFrame::EventBatch { batch },
15871                actor_event_frame(AgentEvent::ToolToken {
15872                    tool_call_id: "download-call".into(),
15873                    content: private.into(),
15874                }),
15875                actor_event_frame(AgentEvent::ToolToken {
15876                    tool_call_id: "read-call".into(),
15877                    content: "public live output".into(),
15878                }),
15879                actor_event_frame(AgentEvent::ToolComplete {
15880                    tool_call_id: "download-call".into(),
15881                    result,
15882                }),
15883                actor_event_frame(AgentEvent::ToolLifecycle {
15884                    tool_call_id: "download-call".into(),
15885                    tool_name: "browser".into(),
15886                    phase: "finished".into(),
15887                    elapsed_ms: Some(1),
15888                    is_mutating: false,
15889                    auto_approved: false,
15890                    summary: Some(private.into()),
15891                    error: Some(private.into()),
15892                }),
15893                actor_event_frame(AgentEvent::ToolError {
15894                    tool_call_id: "download-call".into(),
15895                    error: private.into(),
15896                }),
15897                actor_event_frame(AgentEvent::ToolComplete {
15898                    tool_call_id: "read-call".into(),
15899                    result: bamboo_agent_core::tools::ToolResult::text(true, "public README text"),
15900                }),
15901                completed_actor_frame(),
15902            ],
15903            expected_default_permission_posture(7),
15904        )
15905        .await;
15906        assert_eq!(outcome.unwrap().as_deref(), Some("done"));
15907        let wire = serde_json::to_string(&forwarded).unwrap();
15908        assert!(
15909            !wire.contains(private),
15910            "private content reached actor SSE: {wire}"
15911        );
15912        assert!(wire.contains("public README text"));
15913        assert!(wire.contains("public live output"));
15914        assert!(forwarded.iter().any(|event| matches!(event,
15915            AgentEvent::ToolComplete { tool_call_id, result }
15916                if tool_call_id == "download-call"
15917                && result.result == "Browser tool result hidden"
15918                && result.images.is_empty() && result.display_preference.is_none()
15919        )));
15920    }
15921
15922    #[tokio::test]
15923    async fn actor_missing_reused_and_nested_tool_identity_fails_closed() {
15924        let private = "private-download-content";
15925        let nested = |event| AgentEvent::SubAgentEvent {
15926            parent_session_id: "parent".into(),
15927            child_session_id: "nested-child".into(),
15928            event: Box::new(event),
15929        };
15930        let (outcome, _, forwarded, _) = drive_permission_handshake_frames(
15931            "actor-reorder",
15932            [
15933                permission_posture_frame("actor-reorder", 7),
15934                actor_event_frame(AgentEvent::ToolComplete {
15935                    tool_call_id: "missing".into(),
15936                    result: bamboo_agent_core::tools::ToolResult::text(true, private),
15937                }),
15938                actor_event_frame(AgentEvent::ToolLifecycle {
15939                    tool_call_id: "missing".into(),
15940                    tool_name: "browser".into(),
15941                    phase: private.into(),
15942                    elapsed_ms: None,
15943                    is_mutating: false,
15944                    auto_approved: false,
15945                    summary: Some(private.into()),
15946                    error: Some(private.into()),
15947                }),
15948                actor_event_frame(AgentEvent::ToolApprovalRequested {
15949                    tool_call_id: "approval-only".into(),
15950                    tool_name: "browser".into(),
15951                    parameters: serde_json::json!({"action":"download","selector":private}),
15952                }),
15953                actor_event_frame(AgentEvent::ToolToken {
15954                    tool_call_id: "approval-only".into(),
15955                    content: private.into(),
15956                }),
15957                actor_event_frame(AgentEvent::ToolStart {
15958                    tool_call_id: "malformed-browser".into(),
15959                    tool_name: format!("{private}::browser"),
15960                    arguments: serde_json::json!({"selector":private}),
15961                }),
15962                actor_event_frame(AgentEvent::ToolComplete {
15963                    tool_call_id: "malformed-browser".into(),
15964                    result: bamboo_agent_core::tools::ToolResult::text(true, private),
15965                }),
15966                actor_event_frame(AgentEvent::ToolStart {
15967                    tool_call_id: "reused".into(),
15968                    tool_name: "Read".into(),
15969                    arguments: serde_json::json!({"file_path":"README.md"}),
15970                }),
15971                actor_event_frame(AgentEvent::ToolStart {
15972                    tool_call_id: "reused".into(),
15973                    tool_name: "browser".into(),
15974                    arguments: serde_json::json!({"action":"download","selector":private}),
15975                }),
15976                actor_event_frame(AgentEvent::ToolToken {
15977                    tool_call_id: "reused".into(),
15978                    content: private.into(),
15979                }),
15980                actor_event_frame(nested(AgentEvent::ToolStart {
15981                    tool_call_id: "nested".into(),
15982                    tool_name: "browser".into(),
15983                    arguments: serde_json::json!({"action":"download","selector":private}),
15984                })),
15985                actor_event_frame(nested(AgentEvent::ToolComplete {
15986                    tool_call_id: "nested".into(),
15987                    result: bamboo_agent_core::tools::ToolResult::text(true, private),
15988                })),
15989                actor_event_frame(AgentEvent::ChildApprovalRequested {
15990                    child_session_id: "nested-child".into(),
15991                    request_id: "approval".into(),
15992                    tool_name: format!("{private}::browser"),
15993                    permission: private.into(),
15994                    resource: private.into(),
15995                }),
15996                actor_event_frame(AgentEvent::ChildApprovalChanged {
15997                    parent_session_id: "parent".into(),
15998                    child_session_id: "nested-child".into(),
15999                    child_attempt: 1,
16000                    request_id: "approval".into(),
16001                    version: 1,
16002                    status: "pending".into(),
16003                    reason: Some(private.into()),
16004                    tool_name: "browser".into(),
16005                    permission: private.into(),
16006                    resource: private.into(),
16007                    created_at: "2026-01-01T00:00:00Z".into(),
16008                    resolved_at: None,
16009                }),
16010                completed_actor_frame(),
16011            ],
16012            expected_default_permission_posture(7),
16013        )
16014        .await;
16015        assert_eq!(outcome.unwrap().as_deref(), Some("done"));
16016        let wire = serde_json::to_string(&forwarded).unwrap();
16017        assert!(
16018            !wire.contains(private),
16019            "private content reached actor SSE: {wire}"
16020        );
16021        assert!(wire.contains("Tool result hidden"));
16022        assert!(wire.contains("Browser tool result hidden"));
16023        assert!(wire.contains("Browser interaction approval"));
16024        assert!(!forwarded
16025            .iter()
16026            .any(|event| matches!(event, AgentEvent::ChildApprovalChanged { .. })));
16027    }
16028
16029    #[test]
16030    fn actor_display_tracking_overflow_never_reclassifies_old_private_id() {
16031        let mut display = ActorEventDisplay::default();
16032        display
16033            .project(
16034                AgentEvent::ToolStart {
16035                    tool_call_id: "private-call".into(),
16036                    tool_name: "browser".into(),
16037                    arguments: serde_json::json!({"action":"download","selector":"private"}),
16038                },
16039                0,
16040            )
16041            .unwrap();
16042        for index in 0..MAX_DISPLAY_CALLS {
16043            display
16044                .project(
16045                    AgentEvent::ToolStart {
16046                        tool_call_id: format!("ordinary-{index}"),
16047                        tool_name: "Read".into(),
16048                        arguments: serde_json::json!({"file_path":"README.md"}),
16049                    },
16050                    0,
16051                )
16052                .unwrap();
16053        }
16054        assert!(display.overflowed);
16055        let forwarded = display
16056            .project(
16057                AgentEvent::ToolComplete {
16058                    tool_call_id: "private-call".into(),
16059                    result: bamboo_agent_core::tools::ToolResult::text(true, "private-bytes"),
16060                },
16061                0,
16062            )
16063            .unwrap();
16064        assert!(!serde_json::to_string(&forwarded)
16065            .unwrap()
16066            .contains("private-bytes"));
16067    }
16068
16069    #[test]
16070    fn nested_actor_display_uses_one_identity_budget_and_bounded_keys() {
16071        let mut display = ActorEventDisplay::default();
16072        let nested = |index: usize| AgentEvent::SubAgentEvent {
16073            parent_session_id: "parent".into(),
16074            child_session_id: format!("child-{index}"),
16075            event: Box::new(AgentEvent::ToolStart {
16076                tool_call_id: format!("read-{index}"),
16077                tool_name: "Read".into(),
16078                arguments: serde_json::json!({"file_path":"README.md"}),
16079            }),
16080        };
16081        for index in 0..(MAX_DISPLAY_CALLS / 2) {
16082            assert!(display.project(nested(index), 0).is_some());
16083        }
16084        assert_eq!(
16085            display.identities.load(Ordering::Relaxed),
16086            MAX_DISPLAY_CALLS
16087        );
16088        assert!(display.project(nested(MAX_DISPLAY_CALLS / 2), 0).is_none());
16089        assert!(display.overflowed);
16090        assert!(display.children.is_empty());
16091
16092        let mut oversized = ActorEventDisplay::default();
16093        assert!(oversized
16094            .project(
16095                AgentEvent::SubAgentEvent {
16096                    parent_session_id: "parent".into(),
16097                    child_session_id: "x".repeat(MAX_DISPLAY_ID_BYTES + 1),
16098                    event: Box::new(AgentEvent::ToolComplete {
16099                        tool_call_id: "call".into(),
16100                        result: bamboo_agent_core::tools::ToolResult::text(
16101                            true,
16102                            "private-download-bytes",
16103                        ),
16104                    }),
16105                },
16106                0,
16107            )
16108            .is_none());
16109        assert!(oversized.overflowed);
16110        assert!(oversized
16111            .project(
16112                AgentEvent::ToolStart {
16113                    tool_call_id: "x".repeat(MAX_DISPLAY_ID_BYTES + 1),
16114                    tool_name: "Read".into(),
16115                    arguments: serde_json::json!({"file_path":"README.md"}),
16116                },
16117                0,
16118            )
16119            .is_none());
16120    }
16121
16122    #[tokio::test]
16123    async fn actor_batch_sequence_gap_invalidates_prior_tool_identity() {
16124        let session_id = "actor-gap";
16125        let batch = |seq, event: AgentEvent| {
16126            let event = serde_json::to_value(event).unwrap();
16127            ChildFrame::EventBatch {
16128                batch: ActorEventBatch {
16129                    logical_session: Some(LogicalSessionIdentity {
16130                        creation: None,
16131                        session_id: session_id.into(),
16132                        parent_session_id: Some("permission-parent".into()),
16133                        root_session_id: session_id.into(),
16134                    }),
16135                    activation_id: None,
16136                    execution_epoch: 0,
16137                    source_node_id: None,
16138                    source_actor_id: Some(session_id.into()),
16139                    first_seq: seq,
16140                    last_seq: seq,
16141                    qos: bamboo_subagent::ActorEventQos::classify(&event),
16142                    events: vec![event],
16143                },
16144            }
16145        };
16146        let (outcome, _, forwarded, _) = drive_permission_handshake_frames(
16147            session_id,
16148            [
16149                permission_posture_frame(session_id, 7),
16150                batch(
16151                    1,
16152                    AgentEvent::ToolStart {
16153                        tool_call_id: "reused-call".into(),
16154                        tool_name: "Read".into(),
16155                        arguments: serde_json::json!({"file_path":"README.md"}),
16156                    },
16157                ),
16158                // Sequence 2 may have replaced this ID with a private browser
16159                // call. The actor's next result must not inherit Read display.
16160                batch(
16161                    3,
16162                    AgentEvent::ToolComplete {
16163                        tool_call_id: "reused-call".into(),
16164                        result: bamboo_agent_core::tools::ToolResult::text(
16165                            true,
16166                            "private-download-bytes",
16167                        ),
16168                    },
16169                ),
16170                completed_actor_frame(),
16171            ],
16172            expected_default_permission_posture(7),
16173        )
16174        .await;
16175        assert_eq!(outcome.unwrap().as_deref(), Some("done"));
16176        let wire = serde_json::to_string(&forwarded).unwrap();
16177        assert!(!wire.contains("private-download-bytes"), "{wire}");
16178        assert!(wire.contains("Tool result hidden"));
16179    }
16180
16181    #[tokio::test]
16182    async fn legacy_malformed_actor_event_invalidates_prior_tool_identity() {
16183        let (event_tx, mut event_rx) = mpsc::channel(8);
16184        let mut session = Session::new("legacy-malformed-private", "model");
16185        let mut handshake = PermissionPostureHandshake::NotRequired;
16186        let mut display = ActorEventDisplay::default();
16187        let read_start = serde_json::to_value(AgentEvent::ToolStart {
16188            tool_call_id: "reused-call".into(),
16189            tool_name: "Read".into(),
16190            arguments: serde_json::json!({"file_path":"README.md"}),
16191        })
16192        .unwrap();
16193        process_actor_event(
16194            read_start,
16195            false,
16196            &mut handshake,
16197            None,
16198            None,
16199            &mut session,
16200            &event_tx,
16201            &mut display,
16202        )
16203        .await
16204        .unwrap();
16205
16206        // A mixed-version actor may send a browser ToolStart whose name has
16207        // the wrong wire type. Serde drops it; the reused ID is no longer safe.
16208        let mut malformed = serde_json::to_value(AgentEvent::ToolStart {
16209            tool_call_id: "reused-call".into(),
16210            tool_name: "browser".into(),
16211            arguments: serde_json::json!({
16212                "action":"download", "selector":"private-download-selector"
16213            }),
16214        })
16215        .unwrap();
16216        malformed["tool_name"] = serde_json::json!(7);
16217        process_actor_event(
16218            malformed,
16219            false,
16220            &mut handshake,
16221            None,
16222            None,
16223            &mut session,
16224            &event_tx,
16225            &mut display,
16226        )
16227        .await
16228        .unwrap();
16229        process_actor_event(
16230            serde_json::to_value(AgentEvent::ToolComplete {
16231                tool_call_id: "reused-call".into(),
16232                result: bamboo_agent_core::tools::ToolResult::text(true, "private-download-bytes"),
16233            })
16234            .unwrap(),
16235            false,
16236            &mut handshake,
16237            None,
16238            None,
16239            &mut session,
16240            &event_tx,
16241            &mut display,
16242        )
16243        .await
16244        .unwrap();
16245        let forwarded = std::iter::from_fn(|| event_rx.try_recv().ok()).collect::<Vec<_>>();
16246        assert_eq!(forwarded.len(), 2);
16247        let wire = serde_json::to_string(&forwarded).unwrap();
16248        assert!(!wire.contains("private-download"), "{wire}");
16249        assert!(wire.contains("Tool result hidden"));
16250    }
16251
16252    #[tokio::test]
16253    async fn actor_initial_batch_acks_in_order_and_rejects_stale_confirmation() {
16254        let temp = tempfile::tempdir().unwrap();
16255        let store = Arc::new(
16256            bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
16257                .await
16258                .unwrap(),
16259        );
16260        let storage: Arc<dyn Storage> = store.clone();
16261        let locked = Arc::new(bamboo_storage::LockedSessionStore::new(storage.clone()));
16262        let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
16263            store.clone(),
16264            bamboo_domain::SessionInboxLimits::default(),
16265        ));
16266        let session_id = "actor-confirmation-order";
16267        let run_id = "actor-run-current";
16268        let mut session = Session::new(session_id, "model");
16269        store.save_session(&session).await.unwrap();
16270        for (id, text) in [("actor-first", "first"), ("actor-second", "second")] {
16271            let mut envelope = bamboo_domain::SessionMessageEnvelope::user_input(session_id, text);
16272            envelope.id = bamboo_domain::SessionMessageId::parse(id).unwrap();
16273            inbox.deliver(&envelope).await.unwrap();
16274        }
16275        inbox
16276            .mark_activation_eligible(
16277                session_id,
16278                2,
16279                bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
16280            )
16281            .await
16282            .unwrap();
16283        let router = SessionActivationRouter::new();
16284        let mut owner_registration = router.register_run(session_id, run_id).await.unwrap();
16285        let binding = SessionInboxRuntimeBinding {
16286            router,
16287            inbox: inbox.clone(),
16288            storage,
16289            persistence: locked,
16290            parent_question_lock: None,
16291        };
16292        let pairs = claim_canonical_deliveries(&binding, &mut session, run_id, usize::MAX, None)
16293            .await
16294            .unwrap();
16295        assert_eq!(
16296            pairs
16297                .iter()
16298                .map(|(claim, _)| claim.envelope.id.as_str())
16299                .collect::<Vec<_>>(),
16300            vec!["actor-first", "actor-second"]
16301        );
16302        let seeded = store.load_session(session_id).await.unwrap().unwrap();
16303        assert_eq!(
16304            seeded
16305                .messages
16306                .iter()
16307                .filter(|message| matches!(message.id.as_str(), "actor-first" | "actor-second"))
16308                .map(|message| message.id.as_str())
16309                .collect::<Vec<_>>(),
16310            vec!["actor-first", "actor-second"],
16311            "host context must be durable before actor dispatch"
16312        );
16313        for (claim, _) in &pairs {
16314            assert_eq!(
16315                seeded
16316                    .messages
16317                    .iter()
16318                    .filter(|message| bamboo_domain::is_matching_session_message(
16319                        message,
16320                        &claim.envelope
16321                    ))
16322                    .count(),
16323                1,
16324                "pre-dispatch host checkpoint must contain exactly one canonical marker for {}",
16325                claim.envelope.id
16326            );
16327        }
16328        assert!(
16329            seeded.session_inbox_admission().is_none_or(|cursor| {
16330                !cursor.contains(&pairs[0].0.envelope.id)
16331                    && !cursor.contains(&pairs[1].0.envelope.id)
16332            }),
16333            "pre-dispatch transcript seeding must not forge worker confirmation"
16334        );
16335        assert_eq!(inbox.inspect(session_id).await.unwrap().claimed, 2);
16336        let claims = pairs
16337            .into_iter()
16338            .map(|(claim, _)| claim)
16339            .collect::<VecDeque<_>>();
16340        let first = claims[0].clone();
16341        let second = claims[1].clone();
16342        let mut stale = admission_confirmation(session_id, &first, "stale-run");
16343        stale.canonical_claim_generation = second.generation;
16344        let mut link = ConfirmationSequenceLink {
16345            frames: VecDeque::from([
16346                ChildFrame::SessionMessageAdmitted {
16347                    confirmation: stale,
16348                },
16349                ChildFrame::SessionMessageAdmitted {
16350                    confirmation: admission_confirmation(session_id, &first, run_id),
16351                },
16352                ChildFrame::SessionMessageAdmitted {
16353                    confirmation: admission_confirmation(session_id, &second, run_id),
16354                },
16355                ChildFrame::Terminal {
16356                    status: TerminalStatus::Completed,
16357                    result: Some("done".to_string()),
16358                    error: None,
16359                    final_event_watermark: None,
16360                    transcript: Vec::new(),
16361                },
16362            ]),
16363            sent: Vec::new(),
16364        };
16365        let (event_tx, _event_rx) = mpsc::channel(8);
16366        let cancel = CancellationToken::new();
16367        let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
16368        let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
16369        let result = drive(ActorDriveContext {
16370            expected_creation: None,
16371            client: &mut link,
16372            parent_session_id: "parent",
16373            child_session_id: session_id,
16374            child_attempt: 0,
16375            approval_registry: None,
16376            approval_decider: None,
16377            approval_reviewer: None,
16378            escalation_bridge: None,
16379            event_tx: &event_tx,
16380            cancel_token: &cancel,
16381            live_rx: &mut live_rx,
16382            delivery_rx: &mut delivery_rx,
16383            logical_session: &mut session,
16384            expected_permission_posture: None,
16385            session_inbox_runtime: Some(&binding),
16386            actor_directory_store: None,
16387            canonical_subagent_tool: None,
16388            ticket_service: None,
16389            activation_run_id: Some(run_id),
16390            execution_epoch: 0,
16391            expected_source_actor_id: session_id,
16392            initial_inflight_claims: claims,
16393            plain_actor: false,
16394            remote_environment_lease: false,
16395            readonly_output: None,
16396            local_history_tools: None,
16397            local_history_read_only: false,
16398            strict_history_output: None,
16399            plain_input: None,
16400            canonical_activation: None,
16401            canonical_placement_ref: None,
16402            actor_event_observer: None,
16403            plain_run: None,
16404            first_frame_timeout: Some(Duration::from_secs(1)),
16405        })
16406        .await
16407        .unwrap();
16408        assert_eq!(result.as_deref(), Some("done"));
16409        assert_eq!(
16410            session
16411                .messages
16412                .iter()
16413                .filter(|message| matches!(message.id.as_str(), "actor-first" | "actor-second"))
16414                .map(|message| message.id.as_str())
16415                .collect::<Vec<_>>(),
16416            vec!["actor-first", "actor-second"]
16417        );
16418        let backlog = inbox.inspect(session_id).await.unwrap();
16419        assert_eq!(backlog.pending + backlog.claimed, 0);
16420        let confirmed = store.load_session(session_id).await.unwrap().unwrap();
16421        let cursor = confirmed
16422            .session_inbox_admission()
16423            .expect("exact worker confirmation must checkpoint the admission cursor");
16424        assert!(cursor.contains(&first.envelope.id));
16425        assert!(cursor.contains(&second.envelope.id));
16426        for claim in [&first, &second] {
16427            assert_eq!(
16428                confirmed
16429                    .messages
16430                    .iter()
16431                    .filter(|message| bamboo_domain::is_matching_session_message(
16432                        message,
16433                        &claim.envelope
16434                    ))
16435                    .count(),
16436                1,
16437                "confirmation must retain one exact canonical marker for {}",
16438                claim.envelope.id
16439            );
16440        }
16441        assert!(inbox
16442            .was_admitted(session_id, &first.envelope.id)
16443            .await
16444            .unwrap());
16445        assert!(inbox
16446            .was_admitted(session_id, &second.envelope.id)
16447            .await
16448            .unwrap());
16449        owner_registration.begin_finalization().await;
16450        owner_registration.finish(2).await.unwrap();
16451    }
16452
16453    #[derive(Default)]
16454    struct RecordingCodexTokenAuthority {
16455        issued_for: std::sync::Mutex<Vec<String>>,
16456        revoked: std::sync::Mutex<Vec<String>>,
16457    }
16458
16459    impl CodexRunTokenAuthority for RecordingCodexTokenAuthority {
16460        fn issue(&self, session_id: &str) -> Result<IssuedCodexRunToken, String> {
16461            self.issued_for
16462                .lock()
16463                .expect("issued fixture lock")
16464                .push(session_id.to_string());
16465            Ok(IssuedCodexRunToken {
16466                token_id: format!("id-{session_id}"),
16467                token: format!("bcx1_secret-{session_id}"),
16468            })
16469        }
16470
16471        fn revoke(&self, token_id: &str) {
16472            self.revoked
16473                .lock()
16474                .expect("revoked fixture lock")
16475                .push(token_id.to_string());
16476        }
16477    }
16478
16479    fn codex_executor(auth_mode: Option<&str>, inherit_user_config: Option<bool>) -> ExecutorSpec {
16480        let bamboo_mode = auth_mode == Some("bamboo")
16481            || (auth_mode.is_none() && !inherit_user_config.unwrap_or(false));
16482        ExecutorSpec::Codex {
16483            binary: None,
16484            model: None,
16485            mode: None,
16486            sandbox: None,
16487            inherit_user_config,
16488            auth_mode: auth_mode.map(str::to_string),
16489            base_url: bamboo_mode.then(|| "http://127.0.0.1:9562/openai/v1".to_string()),
16490            wire_api: Some("responses".to_string()),
16491            provider_key_ref: None,
16492            forward_env: None,
16493            approval_policy: None,
16494            network_access: None,
16495            allow_danger_bypass: None,
16496            permission_profile: None,
16497            workspace_owned: None,
16498        }
16499    }
16500
16501    fn permission_resolution(
16502        requested: bamboo_domain::SessionPermissionMode,
16503        effective: bamboo_domain::PermissionMode,
16504    ) -> bamboo_domain::PermissionModeResolution {
16505        bamboo_domain::PermissionModeResolution {
16506            requested,
16507            effective,
16508        }
16509    }
16510
16511    #[test]
16512    fn permission_posture_mapping_contract_is_exact_for_supported_executors() {
16513        use bamboo_domain::{PermissionMode, SessionPermissionMode};
16514
16515        let default =
16516            permission_resolution(SessionPermissionMode::Default, PermissionMode::Default);
16517        assert_eq!(
16518            expected_permission_executor_mapping(&ExecutorSpec::BambooRuntime, default, false)
16519                .unwrap()
16520                .as_deref(),
16521            Some("bamboo_runtime:default")
16522        );
16523        assert_eq!(
16524            expected_permission_executor_mapping(&ExecutorSpec::Echo, default, false).unwrap(),
16525            None,
16526            "transport-only Echo must not claim the typed permission contract"
16527        );
16528        assert_eq!(
16529            expected_permission_executor_mapping(
16530                &ExecutorSpec::CliAdapter {
16531                    command: "must-not-appear-in-contract".to_string(),
16532                    args: vec!["credential-like-argument".to_string()],
16533                },
16534                default,
16535                false,
16536            )
16537            .unwrap(),
16538            None,
16539            "unimplemented CliAdapter must not leak command data into a contract"
16540        );
16541
16542        let claude = ExecutorSpec::ClaudeCode {
16543            binary: None,
16544            model: None,
16545            permission_mode: Some("default".to_string()),
16546            inherit_user_config: None,
16547            forward_env: None,
16548        };
16549        for (resolution, mapping) in [
16550            (
16551                permission_resolution(SessionPermissionMode::Default, PermissionMode::Plan),
16552                "claude_code:permission_mode=plan",
16553            ),
16554            (
16555                permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
16556                "claude_code:permission_mode=bypassPermissions",
16557            ),
16558            (
16559                permission_resolution(SessionPermissionMode::Default, PermissionMode::AcceptEdits),
16560                "claude_code:permission_mode=acceptEdits",
16561            ),
16562            (
16563                permission_resolution(SessionPermissionMode::Default, PermissionMode::DontAsk),
16564                "claude_code:permission_mode=dontAsk",
16565            ),
16566            (
16567                permission_resolution(
16568                    SessionPermissionMode::Bypass,
16569                    PermissionMode::BypassPermissions,
16570                ),
16571                "claude_code:permission_mode=default",
16572            ),
16573        ] {
16574            assert_eq!(
16575                expected_permission_executor_mapping(&claude, resolution, false)
16576                    .unwrap()
16577                    .as_deref(),
16578                Some(mapping)
16579            );
16580        }
16581        assert_eq!(
16582            expected_permission_executor_mapping(&claude, default, true)
16583                .unwrap()
16584                .as_deref(),
16585            Some("claude_code:blocked_explicit_deny")
16586        );
16587
16588        let mut codex_exec = codex_executor(Some("inherit"), Some(true));
16589        if let ExecutorSpec::Codex {
16590            approval_policy, ..
16591        } = &mut codex_exec
16592        {
16593            *approval_policy = Some("on-failure".to_string());
16594        }
16595        assert_eq!(
16596            expected_permission_executor_mapping(&codex_exec, default, false)
16597                .unwrap()
16598                .as_deref(),
16599            Some("codex_exec:approval_policy=on-failure")
16600        );
16601        assert_eq!(
16602            expected_permission_executor_mapping(
16603                &codex_exec,
16604                permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
16605                false,
16606            )
16607            .unwrap()
16608            .as_deref(),
16609            Some("codex_exec:approval_policy=never")
16610        );
16611        assert_eq!(
16612            expected_permission_executor_mapping(&codex_exec, default, true)
16613                .unwrap()
16614                .as_deref(),
16615            Some("codex_exec:blocked_explicit_deny")
16616        );
16617
16618        let mut codex_app_server = codex_executor(Some("inherit"), Some(true));
16619        if let ExecutorSpec::Codex {
16620            mode,
16621            approval_policy,
16622            ..
16623        } = &mut codex_app_server
16624        {
16625            *mode = Some("app_server".to_string());
16626            *approval_policy = Some("on-request".to_string());
16627        }
16628        assert_eq!(
16629            expected_permission_executor_mapping(&codex_app_server, default, false)
16630                .unwrap()
16631                .as_deref(),
16632            Some("codex_app_server:approvalPolicy=on-request")
16633        );
16634        assert_eq!(
16635            expected_permission_executor_mapping(
16636                &codex_app_server,
16637                permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
16638                false,
16639            )
16640            .unwrap()
16641            .as_deref(),
16642            Some("codex_app_server:approvalPolicy=never")
16643        );
16644        assert_eq!(
16645            expected_permission_executor_mapping(&codex_app_server, default, true)
16646                .unwrap()
16647                .as_deref(),
16648            Some("codex_app_server:blocked_explicit_deny")
16649        );
16650    }
16651
16652    #[test]
16653    fn only_bamboo_managed_non_git_workspaces_are_marked_owned() {
16654        let project = tempfile::tempdir().unwrap();
16655        let managed = project.path().join(".bamboo/worktree/child-571");
16656        std::fs::create_dir_all(&managed).unwrap();
16657        assert!(!workspace_is_bamboo_owned(managed.to_str().unwrap()));
16658        let marker = project
16659            .path()
16660            .join(".bamboo/worktree/.bamboo-owned/child-571");
16661        std::fs::create_dir_all(marker.parent().unwrap()).unwrap();
16662        std::fs::write(&marker, "bamboo/child-571").unwrap();
16663        assert!(workspace_is_bamboo_owned(managed.to_str().unwrap()));
16664        let nested = managed.join("nested/path");
16665        std::fs::create_dir_all(&nested).unwrap();
16666        assert!(workspace_is_bamboo_owned(nested.to_str().unwrap()));
16667
16668        let arbitrary = tempfile::tempdir().unwrap();
16669        assert!(!workspace_is_bamboo_owned(
16670            arbitrary.path().to_str().unwrap()
16671        ));
16672    }
16673
16674    #[test]
16675    fn bamboo_codex_token_is_per_run_redacted_and_revoked_on_guard_drop() {
16676        let authority = Arc::new(RecordingCodexTokenAuthority::default());
16677        let authority_dyn: Arc<dyn CodexRunTokenAuthority> = authority.clone();
16678
16679        let (secrets, guard) = build_codex_run_secrets(
16680            &codex_executor(Some("bamboo"), None),
16681            Some(authority_dyn),
16682            "child-570",
16683        )
16684        .unwrap();
16685
16686        let token = secrets
16687            .codex_provider_token
16688            .as_ref()
16689            .expect("bamboo mode mints a token");
16690        assert_eq!(token.expose(), "bcx1_secret-child-570");
16691        assert!(!format!("{token:?}").contains("secret-child-570"));
16692        assert_eq!(
16693            authority.issued_for.lock().unwrap().as_slice(),
16694            ["child-570"]
16695        );
16696        assert!(authority.revoked.lock().unwrap().is_empty());
16697
16698        drop(guard);
16699        assert_eq!(
16700            authority.revoked.lock().unwrap().as_slice(),
16701            ["id-child-570"]
16702        );
16703    }
16704
16705    #[test]
16706    fn non_bamboo_codex_never_mints_and_bamboo_fails_closed_without_authority() {
16707        let authority = Arc::new(RecordingCodexTokenAuthority::default());
16708        let authority_dyn: Arc<dyn CodexRunTokenAuthority> = authority.clone();
16709        let (secrets, guard) = build_codex_run_secrets(
16710            &codex_executor(Some("custom"), None),
16711            Some(authority_dyn),
16712            "child-custom",
16713        )
16714        .unwrap();
16715        assert!(secrets.codex_provider_token.is_none());
16716        assert!(guard.is_none());
16717        assert!(authority.issued_for.lock().unwrap().is_empty());
16718
16719        let error = build_codex_run_secrets(
16720            &codex_executor(Some("bamboo"), None),
16721            None,
16722            "child-no-authority",
16723        )
16724        .err()
16725        .expect("bamboo mode without an authority must fail closed");
16726        assert!(error.to_string().contains("per-run token authority"));
16727    }
16728
16729    #[test]
16730    fn codex_provisioning_never_leaks_the_session_provider_credential() {
16731        let credentials = vec![ScopedCredential {
16732            provider: "openai".to_string(),
16733            api_key: "upstream-secret-must-not-cross".to_string(),
16734            base_url: None,
16735            provider_type: Some("openai".to_string()),
16736            credential_ref: Some("provider.openai.api_key".to_string()),
16737        }];
16738
16739        for (mode, label) in [
16740            (Some("inherit"), "inherit"),
16741            (Some("api_key"), "api_key"),
16742            (Some("bamboo"), "bamboo"),
16743            (None, "default-bamboo"),
16744        ] {
16745            let runner = ActorChildRunner::new(
16746                format!("codex-{label}-test"),
16747                PathBuf::from("/bin/false"),
16748                Vec::new(),
16749                std::env::temp_dir().join(format!("bamboo-codex-{label}-570")),
16750                codex_executor(mode, None),
16751                credentials.clone(),
16752                "openai".to_string(),
16753                1,
16754            );
16755            let mut session = Session::new(format!("child-{label}"), "model");
16756            session.add_message(bamboo_agent_core::Message::user("test"));
16757            let spec = runner.build_spec(
16758                &session,
16759                &crate::runtime::execution::SpawnJob {
16760                    parent_session_id: "parent".to_string(),
16761                    child_session_id: format!("child-{label}"),
16762                    model: "gpt-5.4".to_string(),
16763                    disabled_tools: None,
16764                },
16765            );
16766            assert!(
16767                spec.secrets.provider_credentials.is_empty(),
16768                "{label} Codex must not receive the session provider key"
16769            );
16770        }
16771    }
16772
16773    #[test]
16774    fn non_codex_provisioning_still_receives_only_its_selected_provider_credential() {
16775        let credentials = vec![
16776            ScopedCredential {
16777                provider: "openai".to_string(),
16778                api_key: "selected-openai-secret".to_string(),
16779                base_url: None,
16780                provider_type: Some("openai".to_string()),
16781                credential_ref: Some("provider.openai.api_key".to_string()),
16782            },
16783            ScopedCredential {
16784                provider: "other".to_string(),
16785                api_key: "unrelated-secret".to_string(),
16786                base_url: None,
16787                provider_type: Some("openai".to_string()),
16788                credential_ref: Some("provider.other.api_key".to_string()),
16789            },
16790        ];
16791        let runner = ActorChildRunner::new(
16792            "echo-test".to_string(),
16793            PathBuf::from("/bin/false"),
16794            Vec::new(),
16795            std::env::temp_dir().join("bamboo-echo-provider-570"),
16796            ExecutorSpec::Echo,
16797            credentials,
16798            "openai".to_string(),
16799            1,
16800        );
16801        let spec = runner.build_spec(
16802            &Session::new("child-echo", "model"),
16803            &crate::runtime::execution::SpawnJob {
16804                parent_session_id: "parent".to_string(),
16805                child_session_id: "child-echo".to_string(),
16806                model: "gpt-5.4".to_string(),
16807                disabled_tools: None,
16808            },
16809        );
16810
16811        assert_eq!(spec.secrets.provider_credentials.len(), 1);
16812        assert_eq!(
16813            spec.secrets.provider_credentials[0].api_key,
16814            "selected-openai-secret"
16815        );
16816    }
16817
16818    #[tokio::test]
16819    async fn live_provider_config_refreshes_credentials_and_default_per_activation() {
16820        fn provider_instance(
16821            provider_type: &str,
16822            api_key: &str,
16823        ) -> bamboo_config::ProviderInstanceConfig {
16824            bamboo_config::ProviderInstanceConfig {
16825                provider_type: provider_type.to_string(),
16826                label: None,
16827                api_key: api_key.to_string(),
16828                api_key_encrypted: None,
16829                credential_ref: None,
16830                base_url: None,
16831                model: None,
16832                fast_model: None,
16833                vision_model: None,
16834                reasoning_effort: None,
16835                responses_only_models: Vec::new(),
16836                request_overrides: None,
16837                enabled: true,
16838                extra: Default::default(),
16839            }
16840        }
16841
16842        let mut initial = bamboo_llm::Config::default();
16843        initial.provider_instances.insert(
16844            "old-provider".to_string(),
16845            provider_instance("openai", "old-key"),
16846        );
16847        initial.default_provider_instance = Some("old-provider".to_string());
16848        let live = Arc::new(tokio::sync::RwLock::new(initial));
16849
16850        let runner = ActorChildRunner::new(
16851            "echo-live-config-test".to_string(),
16852            PathBuf::from("/bin/false"),
16853            Vec::new(),
16854            std::env::temp_dir().join("bamboo-echo-live-config"),
16855            ExecutorSpec::Echo,
16856            Vec::new(),
16857            "stale-provider".to_string(),
16858            1,
16859        )
16860        .with_live_provider_config(live.clone());
16861        let job = crate::runtime::execution::SpawnJob {
16862            parent_session_id: "parent".to_string(),
16863            child_session_id: "child".to_string(),
16864            model: "model".to_string(),
16865            disabled_tools: None,
16866        };
16867
16868        let first = runner
16869            .build_live_spec(&Session::new("child", "model"), &job)
16870            .await;
16871        assert_eq!(
16872            first.model.as_ref().map(|model| model.provider.as_str()),
16873            Some("old-provider")
16874        );
16875        assert_eq!(first.secrets.provider_credentials.len(), 1);
16876        assert_eq!(first.secrets.provider_credentials[0].api_key, "old-key");
16877
16878        let mut reloaded = bamboo_llm::Config::default();
16879        reloaded.provider_instances.insert(
16880            "new-provider".to_string(),
16881            provider_instance("openai", "new-key"),
16882        );
16883        reloaded.default_provider_instance = Some("new-provider".to_string());
16884        *live.write().await = reloaded;
16885
16886        let second = runner
16887            .build_live_spec(&Session::new("child", "model"), &job)
16888            .await;
16889        assert_eq!(
16890            second.model.as_ref().map(|model| model.provider.as_str()),
16891            Some("new-provider")
16892        );
16893        assert_eq!(second.secrets.provider_credentials.len(), 1);
16894        assert_eq!(second.secrets.provider_credentials[0].api_key, "new-key");
16895    }
16896
16897    #[test]
16898    fn custom_codex_provisioning_scopes_only_the_referenced_credential() {
16899        let mut executor = codex_executor(Some("custom"), None);
16900        if let ExecutorSpec::Codex {
16901            base_url,
16902            provider_key_ref,
16903            ..
16904        } = &mut executor
16905        {
16906            *base_url = Some("https://provider.example/v1".to_string());
16907            *provider_key_ref = Some("provider.custom.api_key".to_string());
16908        }
16909        let credentials = vec![
16910            ScopedCredential {
16911                provider: "openai".to_string(),
16912                api_key: "session-provider-secret".to_string(),
16913                base_url: None,
16914                provider_type: Some("openai".to_string()),
16915                credential_ref: Some("provider.openai.api_key".to_string()),
16916            },
16917            ScopedCredential {
16918                provider: "custom".to_string(),
16919                api_key: "selected-secret".to_string(),
16920                base_url: None,
16921                provider_type: Some("openai".to_string()),
16922                credential_ref: Some("provider.custom.api_key".to_string()),
16923            },
16924            ScopedCredential {
16925                provider: "other".to_string(),
16926                api_key: "unrelated-secret".to_string(),
16927                base_url: None,
16928                provider_type: Some("openai".to_string()),
16929                credential_ref: Some("provider.other.api_key".to_string()),
16930            },
16931        ];
16932        let runner = ActorChildRunner::new(
16933            "codex-test".to_string(),
16934            PathBuf::from("/bin/false"),
16935            Vec::new(),
16936            std::env::temp_dir().join("bamboo-codex-570"),
16937            executor,
16938            credentials,
16939            "openai".to_string(),
16940            1,
16941        );
16942        let mut session = Session::new("child-custom", "model");
16943        session.add_message(bamboo_agent_core::Message::user("test"));
16944        let spec = runner.build_spec(
16945            &session,
16946            &crate::runtime::execution::SpawnJob {
16947                parent_session_id: "parent".to_string(),
16948                child_session_id: "child-custom".to_string(),
16949                model: "gpt-5.4".to_string(),
16950                disabled_tools: None,
16951            },
16952        );
16953
16954        assert_eq!(spec.secrets.provider_credentials.len(), 1);
16955        assert_eq!(
16956            spec.secrets.provider_credentials[0]
16957                .credential_ref
16958                .as_deref(),
16959            Some("provider.custom.api_key")
16960        );
16961        assert_eq!(
16962            spec.secrets.provider_credentials[0].api_key,
16963            "selected-secret"
16964        );
16965    }
16966
16967    fn spec_with(
16968        role: &str,
16969        provider: &str,
16970        model: &str,
16971        workspace: Option<&str>,
16972        disabled: Option<Vec<&str>>,
16973    ) -> ProvisionSpec {
16974        let mut spec = ProvisionSpec::new(
16975            ChildIdentity {
16976                child_id: "c".into(),
16977                parent_id: None,
16978                project_key: None,
16979                role: role.into(),
16980                depth: 0,
16981            },
16982            ExecutorSpec::Echo,
16983            "/tmp/fab".into(),
16984        );
16985        spec.workspace = workspace.map(|w| w.to_string());
16986        spec.model = Some(ModelRefSpec {
16987            provider: provider.into(),
16988            model: model.into(),
16989        });
16990        spec.disabled_tools = disabled.map(|d| d.into_iter().map(String::from).collect());
16991        spec
16992    }
16993
16994    #[test]
16995    fn fingerprint_matches_interchangeable_children() {
16996        // Same role/provider/model/workspace and equal tool sets (order-insensitive)
16997        // are interchangeable on one warm worker — and differ only in child_id.
16998        let a = spec_with(
16999            "explorer",
17000            "p",
17001            "m",
17002            Some("/ws"),
17003            Some(vec!["Bash", "Edit"]),
17004        );
17005        let mut b = spec_with(
17006            "explorer",
17007            "p",
17008            "m",
17009            Some("/ws"),
17010            Some(vec!["Edit", "Bash"]),
17011        );
17012        b.identity.child_id = "other".into();
17013        assert_eq!(
17014            ActorChildRunner::fingerprint(&a),
17015            ActorChildRunner::fingerprint(&b)
17016        );
17017    }
17018
17019    #[test]
17020    fn logical_identity_is_invariant_across_local_remote_scheduled_and_warm_reuse() {
17021        let mut session =
17022            Session::new_child("logical-child-681", "logical-parent-681", "model", "child");
17023        session.root_session_id = "logical-root-681".to_string();
17024        let job = SpawnJob {
17025            parent_session_id: "logical-parent-681".to_string(),
17026            child_session_id: "logical-child-681".to_string(),
17027            model: "model".to_string(),
17028            disabled_tools: None,
17029        };
17030        let expected = LogicalSessionIdentity {
17031            creation: None,
17032            session_id: "logical-child-681".to_string(),
17033            parent_session_id: Some("logical-parent-681".to_string()),
17034            root_session_id: "logical-root-681".to_string(),
17035        };
17036
17037        let placements_and_transport_ids = [
17038            (Placement::Local, "local-mailbox-first"),
17039            (
17040                Placement::Remote {
17041                    endpoint: "wss://remote.example/actor".to_string(),
17042                },
17043                "remote-process-44",
17044            ),
17045            (
17046                Placement::Schedulable {
17047                    pool: "gpu-pool".to_string(),
17048                },
17049                "scheduled-mailbox-9",
17050            ),
17051            // Same logical child reactivated on a different pooled mailbox.
17052            (Placement::Local, "warm-mailbox-reused-77"),
17053        ];
17054        for (placement, transport_id) in placements_and_transport_ids {
17055            let mut provision = spec_with("worker", "provider", "model", None, None);
17056            provision.placement = placement;
17057            provision.identity.child_id = transport_id.to_string();
17058            assert_eq!(logical_identity_for_actor_run(&session, &job), expected);
17059            assert_ne!(
17060                provision.identity.child_id, expected.session_id,
17061                "test fixture must prove transport identity is independent"
17062            );
17063        }
17064        let mut original = spec_with("worker", "provider", "model", Some("/ws"), None);
17065        original.executor = ExecutorSpec::BambooRuntime;
17066        original.identity.depth = session.spawn_depth;
17067        original.capabilities.required_child_context = true;
17068        original.capabilities.child_creation_identity = true;
17069        original.capabilities.enforce_permissions = true;
17070        original.capabilities.native_tool_ceiling_required = true;
17071        original.capabilities.native_tool_ceiling =
17072            Some(bamboo_subagent::proto::NativeToolCeiling {
17073                version: 1,
17074                child_session_id: session.id.clone(),
17075                parent_session_id: job.parent_session_id.clone(),
17076                root_session_id: session.root_session_id.clone(),
17077                created_at: session.created_at,
17078                spawn_depth: session.spawn_depth,
17079                project_id: None,
17080                tools: vec![],
17081            });
17082        original.validate().unwrap();
17083        let first = ActorChildRunner::fresh_required_worker_spec(&original);
17084        let second = ActorChildRunner::fresh_required_worker_spec(&original);
17085        assert_ne!(first.identity.child_id, second.identity.child_id);
17086        assert_ne!(first.identity.child_id, original.identity.child_id);
17087        for physical in [first, second] {
17088            physical.validate().unwrap();
17089            let mut actual = serde_json::to_value(&physical).unwrap();
17090            actual["identity"]["child_id"] = serde_json::json!(original.identity.child_id);
17091            assert_eq!(actual, serde_json::to_value(&original).unwrap());
17092            assert_eq!(logical_identity_for_actor_run(&session, &job), expected);
17093        }
17094    }
17095
17096    #[test]
17097    fn fingerprint_separates_distinct_runtimes() {
17098        let base = spec_with("explorer", "p", "m", Some("/ws"), None);
17099        let base_fp = ActorChildRunner::fingerprint(&base);
17100        // Each axis that is baked into the worker must split the pool bucket.
17101        assert_ne!(
17102            base_fp,
17103            ActorChildRunner::fingerprint(&spec_with("writer", "p", "m", Some("/ws"), None))
17104        );
17105        assert_ne!(
17106            base_fp,
17107            ActorChildRunner::fingerprint(&spec_with("explorer", "p2", "m", Some("/ws"), None))
17108        );
17109        assert_ne!(
17110            base_fp,
17111            ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m2", Some("/ws"), None))
17112        );
17113        assert_ne!(
17114            base_fp,
17115            ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m", Some("/ws2"), None))
17116        );
17117        assert_ne!(
17118            base_fp,
17119            ActorChildRunner::fingerprint(&spec_with(
17120                "explorer",
17121                "p",
17122                "m",
17123                Some("/ws"),
17124                Some(vec!["Bash"])
17125            ))
17126        );
17127    }
17128
17129    #[test]
17130    fn fingerprint_splits_rotated_provider_credentials_without_exposing_them() {
17131        let mut before = spec_with("explorer", "provider", "model", Some("/ws"), None);
17132        before.secrets.provider_credentials.push(ScopedCredential {
17133            provider: "provider".to_string(),
17134            api_key: "old-secret-key".to_string(),
17135            base_url: Some("https://provider.example/v1".to_string()),
17136            provider_type: Some("openai".to_string()),
17137            credential_ref: None,
17138        });
17139        let mut after = before.clone();
17140        after.secrets.provider_credentials[0].api_key = "new-secret-key".to_string();
17141
17142        let before_fingerprint = ActorChildRunner::fingerprint(&before);
17143        let after_fingerprint = ActorChildRunner::fingerprint(&after);
17144        assert_ne!(before_fingerprint, after_fingerprint);
17145        assert!(!before_fingerprint.contains("old-secret-key"));
17146        assert!(!after_fingerprint.contains("new-secret-key"));
17147    }
17148
17149    #[test]
17150    fn fingerprint_splits_on_baked_capabilities() {
17151        // Every capability baked once at provision time must split the pool
17152        // bucket, else a worker baked for one posture gets reused for another
17153        // (e.g. a depth-1 worker re-stamping spawn_depth onto a depth-4 child,
17154        // breaking the depth cap; or a bypass worker reused for a non-bypass one).
17155        let base_fp =
17156            ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m", Some("/ws"), None));
17157
17158        let mut creation = spec_with("explorer", "p", "m", Some("/ws"), None);
17159        creation.capabilities.child_creation_identity = true;
17160        let typed = ActorChildRunner::fingerprint(&creation);
17161        assert_ne!(
17162            base_fp, typed,
17163            "legacy worker pool cannot satisfy birth-required Run"
17164        );
17165        creation.identity.child_id = "different-logical-child".into();
17166        assert_eq!(
17167            typed,
17168            ActorChildRunner::fingerprint(&creation),
17169            "birth-required mode retains cross-Child pool reuse"
17170        );
17171
17172        let mut depth = spec_with("explorer", "p", "m", Some("/ws"), None);
17173        depth.identity.depth = 2;
17174        assert_ne!(
17175            base_fp,
17176            ActorChildRunner::fingerprint(&depth),
17177            "depth must split"
17178        );
17179
17180        let mut nested = spec_with("explorer", "p", "m", Some("/ws"), None);
17181        nested.capabilities.nested_spawn = true;
17182        assert_ne!(
17183            base_fp,
17184            ActorChildRunner::fingerprint(&nested),
17185            "nested_spawn must split"
17186        );
17187
17188        let mut bypass = spec_with("explorer", "p", "m", Some("/ws"), None);
17189        bypass.capabilities.bypass = true;
17190        assert_ne!(
17191            base_fp,
17192            ActorChildRunner::fingerprint(&bypass),
17193            "bypass must split"
17194        );
17195
17196        let mut auto = spec_with("explorer", "p", "m", Some("/ws"), None);
17197        auto.capabilities.auto_approve_permissions = true;
17198        assert_ne!(
17199            base_fp,
17200            ActorChildRunner::fingerprint(&auto),
17201            "auto_approve_permissions must split"
17202        );
17203
17204        let mut global_auto = spec_with("explorer", "p", "m", Some("/ws"), None);
17205        global_auto.capabilities.permission_requested_mode = "default".to_string();
17206        global_auto.capabilities.permission_effective_mode = "auto".to_string();
17207        global_auto.capabilities.auto_approve_permissions = true;
17208        let mut explicit_auto = global_auto.clone();
17209        explicit_auto.capabilities.permission_requested_mode = "auto".to_string();
17210        assert_ne!(
17211            ActorChildRunner::fingerprint(&global_auto),
17212            ActorChildRunner::fingerprint(&explicit_auto),
17213            "permission_requested_mode must split global and explicit Auto"
17214        );
17215
17216        let mut plan_overlay = explicit_auto.clone();
17217        plan_overlay.capabilities.permission_effective_mode = "plan".to_string();
17218        assert_ne!(
17219            ActorChildRunner::fingerprint(&explicit_auto),
17220            ActorChildRunner::fingerprint(&plan_overlay),
17221            "permission_effective_mode must split Plan overlay from Auto"
17222        );
17223
17224        let mut enforce = spec_with("explorer", "p", "m", Some("/ws"), None);
17225        enforce.capabilities.enforce_permissions = true;
17226        assert_ne!(
17227            base_fp,
17228            ActorChildRunner::fingerprint(&enforce),
17229            "enforce_permissions must split"
17230        );
17231
17232        let mut cap = spec_with("explorer", "p", "m", Some("/ws"), None);
17233        cap.capabilities.max_spawn_depth = Some(8);
17234        assert_ne!(
17235            base_fp,
17236            ActorChildRunner::fingerprint(&cap),
17237            "max_spawn_depth must split"
17238        );
17239
17240        // #73 (P1): the worker bakes `no_human_review` from this flag once at
17241        // build(), so it MUST split the pool or a worker baked for one approval
17242        // posture is reused for the opposite one.
17243        let mut nha = spec_with("explorer", "p", "m", Some("/ws"), None);
17244        nha.capabilities.no_human_approver = true;
17245        assert_ne!(
17246            base_fp,
17247            ActorChildRunner::fingerprint(&nha),
17248            "no_human_approver must split"
17249        );
17250
17251        // The generic read-only no-shell checker is baked once at build(), so a
17252        // planner/Guardian worker must not be reused for an ordinary child.
17253        let mut read_only = spec_with("explorer", "p", "m", Some("/ws"), None);
17254        read_only.capabilities.read_only = true;
17255        assert_ne!(
17256            base_fp,
17257            ActorChildRunner::fingerprint(&read_only),
17258            "read_only must split"
17259        );
17260
17261        // Preserve the legacy Guardian bit as its own rolling-upgrade axis.
17262        let mut gro = spec_with("explorer", "p", "m", Some("/ws"), None);
17263        gro.capabilities.guardian_read_only = true;
17264        assert_ne!(
17265            base_fp,
17266            ActorChildRunner::fingerprint(&gro),
17267            "guardian_read_only must split"
17268        );
17269    }
17270
17271    #[test]
17272    fn fingerprint_splits_codex_exec_and_app_server_workers() {
17273        let mut exec = spec_with("explorer", "p", "m", Some("/ws"), None);
17274        exec.executor = codex_executor(Some("inherit"), None);
17275        let mut app_server = exec.clone();
17276        if let ExecutorSpec::Codex { mode, .. } = &mut app_server.executor {
17277            *mode = Some("app_server".to_string());
17278        }
17279        assert_ne!(
17280            ActorChildRunner::fingerprint(&exec),
17281            ActorChildRunner::fingerprint(&app_server)
17282        );
17283    }
17284
17285    struct StaticDecider(bool);
17286
17287    #[async_trait]
17288    impl ChildApprovalDecider for StaticDecider {
17289        async fn decide(&self, _child: &str, _req: &serde_json::Value) -> bool {
17290            self.0
17291        }
17292    }
17293
17294    struct RecordingReviewer {
17295        reviewed: mpsc::UnboundedSender<(String, String, serde_json::Value)>,
17296    }
17297
17298    #[async_trait]
17299    impl ChildApprovalReviewer for RecordingReviewer {
17300        async fn review(&self, parent: &str, child: &str, request: &serde_json::Value) -> bool {
17301            let _ = self
17302                .reviewed
17303                .send((parent.to_string(), child.to_string(), request.clone()));
17304            true
17305        }
17306    }
17307
17308    // ---- first-frame watchdog (dead-pooled-worker recovery) -----------------
17309
17310    /// A link that never yields a frame — models a worker that died (or never
17311    /// subscribed) so its Run sits queued with no server.
17312    struct SilentLink;
17313    #[async_trait]
17314    impl bamboo_subagent::ChildLink for SilentLink {
17315        async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
17316            Ok(())
17317        }
17318        async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
17319            std::future::pending().await
17320        }
17321    }
17322
17323    /// A link that immediately yields one terminal frame (a healthy fast worker).
17324    struct InstantTerminalLink {
17325        done: bool,
17326    }
17327
17328    struct ApprovalRoundTripLink {
17329        step: u8,
17330        approval_reply: Option<(String, bool)>,
17331    }
17332
17333    #[async_trait]
17334    impl bamboo_subagent::ChildLink for ApprovalRoundTripLink {
17335        async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
17336            if let ParentFrame::ApprovalReply { id, approved } = frame {
17337                self.approval_reply = Some((id, approved));
17338                self.step = 2;
17339            }
17340            Ok(())
17341        }
17342
17343        async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
17344            match self.step {
17345                0 => {
17346                    self.step = 1;
17347                    Ok(Some(ChildFrame::ApprovalRequest {
17348                        id: "approval-1".into(),
17349                        body: serde_json::json!({
17350                            "tool_name": "Bash",
17351                            "permission": "execute",
17352                            "resource": "rm -rf target",
17353                            "permission_request": {"reason_code": "hard_dangerous"}
17354                        }),
17355                    }))
17356                }
17357                1 => std::future::pending().await,
17358                2 => {
17359                    self.step = 3;
17360                    Ok(Some(ChildFrame::Terminal {
17361                        status: TerminalStatus::Completed,
17362                        result: Some("done".into()),
17363                        error: None,
17364                        final_event_watermark: None,
17365                        transcript: vec![],
17366                    }))
17367                }
17368                _ => std::future::pending().await,
17369            }
17370        }
17371    }
17372
17373    #[tokio::test]
17374    async fn drive_routes_forced_ask_to_parent_reviewer_without_human_event() {
17375        let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(8);
17376        let (review_tx, mut review_rx) = mpsc::unbounded_channel();
17377        let reviewer: Arc<dyn ChildApprovalReviewer> = Arc::new(RecordingReviewer {
17378            reviewed: review_tx,
17379        });
17380        let cancel = CancellationToken::new();
17381        let (live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
17382        let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
17383        let mut logical_session = Session::new("child-reviewer", "model");
17384        let live_guard = crate::external_agents::live::register("child-reviewer", live_tx, 0, None);
17385        let mut link = ApprovalRoundTripLink {
17386            step: 0,
17387            approval_reply: None,
17388        };
17389
17390        let result = tokio::time::timeout(
17391            Duration::from_secs(1),
17392            drive(ActorDriveContext {
17393                expected_creation: None,
17394                client: &mut link,
17395                parent_session_id: "parent-reviewer",
17396                child_session_id: "child-reviewer",
17397                child_attempt: 0,
17398                approval_registry: None,
17399                approval_decider: None,
17400                approval_reviewer: Some(&reviewer),
17401                escalation_bridge: None,
17402                event_tx: &event_tx,
17403                cancel_token: &cancel,
17404                live_rx: &mut live_rx,
17405                delivery_rx: &mut delivery_rx,
17406                logical_session: &mut logical_session,
17407                expected_permission_posture: None,
17408                session_inbox_runtime: None,
17409                actor_directory_store: None,
17410                canonical_subagent_tool: None,
17411                ticket_service: None,
17412                activation_run_id: None,
17413                execution_epoch: 0,
17414                expected_source_actor_id: "child-reviewer",
17415                initial_inflight_claims: VecDeque::new(),
17416                plain_actor: false,
17417                remote_environment_lease: false,
17418                readonly_output: None,
17419                local_history_tools: None,
17420                local_history_read_only: false,
17421                strict_history_output: None,
17422                plain_input: None,
17423                canonical_activation: None,
17424                canonical_placement_ref: None,
17425                actor_event_observer: None,
17426                plain_run: None,
17427                first_frame_timeout: None,
17428            }),
17429        )
17430        .await
17431        .expect("worker must receive the reviewer verdict before terminating");
17432
17433        assert_eq!(result.ok().flatten().as_deref(), Some("done"));
17434        assert_eq!(
17435            link.approval_reply,
17436            Some(("approval-1".to_string(), true)),
17437            "reviewer verdict must traverse the live route back to the worker"
17438        );
17439        let (parent, child, body) = tokio::time::timeout(Duration::from_secs(1), review_rx.recv())
17440            .await
17441            .expect("reviewer should be invoked off-loop")
17442            .expect("review channel should remain open");
17443        assert_eq!(parent, "parent-reviewer");
17444        assert_eq!(child, "child-reviewer");
17445        assert_eq!(
17446            body.pointer("/permission_request/reason_code")
17447                .and_then(serde_json::Value::as_str),
17448            Some("hard_dangerous")
17449        );
17450        assert!(
17451            event_rx.try_recv().is_err(),
17452            "must not emit a human-review event"
17453        );
17454        drop(live_guard);
17455    }
17456
17457    #[tokio::test]
17458    async fn drive_denies_forced_ask_without_parent_reviewer_or_manual_event() {
17459        let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(8);
17460        let cancel = CancellationToken::new();
17461        let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
17462        let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
17463        let mut logical_session = Session::new("child-no-reviewer", "model");
17464        let mut link = ApprovalRoundTripLink {
17465            step: 0,
17466            approval_reply: None,
17467        };
17468
17469        let result = tokio::time::timeout(
17470            Duration::from_secs(1),
17471            drive(ActorDriveContext {
17472                expected_creation: None,
17473                client: &mut link,
17474                parent_session_id: "parent-no-reviewer",
17475                child_session_id: "child-no-reviewer",
17476                child_attempt: 0,
17477                approval_registry: None,
17478                approval_decider: None,
17479                approval_reviewer: None,
17480                escalation_bridge: None,
17481                event_tx: &event_tx,
17482                cancel_token: &cancel,
17483                live_rx: &mut live_rx,
17484                delivery_rx: &mut delivery_rx,
17485                logical_session: &mut logical_session,
17486                expected_permission_posture: None,
17487                session_inbox_runtime: None,
17488                actor_directory_store: None,
17489                canonical_subagent_tool: None,
17490                ticket_service: None,
17491                activation_run_id: None,
17492                execution_epoch: 0,
17493                expected_source_actor_id: "child-no-reviewer",
17494                initial_inflight_claims: VecDeque::new(),
17495                plain_actor: false,
17496                remote_environment_lease: false,
17497                readonly_output: None,
17498                local_history_tools: None,
17499                local_history_read_only: false,
17500                strict_history_output: None,
17501                plain_input: None,
17502                canonical_activation: None,
17503                canonical_placement_ref: None,
17504                actor_event_observer: None,
17505                plain_run: None,
17506                first_frame_timeout: None,
17507            }),
17508        )
17509        .await
17510        .expect("fail-closed reply must unblock the child immediately");
17511
17512        assert_eq!(result.ok().flatten().as_deref(), Some("done"));
17513        assert_eq!(link.approval_reply, Some(("approval-1".to_string(), false)));
17514        assert!(
17515            event_rx.try_recv().is_err(),
17516            "missing parent review must not surface a manual approval event"
17517        );
17518    }
17519    #[async_trait]
17520    impl bamboo_subagent::ChildLink for InstantTerminalLink {
17521        async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
17522            Ok(())
17523        }
17524        async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
17525            if self.done {
17526                std::future::pending().await
17527            } else {
17528                self.done = true;
17529                Ok(Some(ChildFrame::Terminal {
17530                    status: TerminalStatus::Completed,
17531                    result: Some("done".into()),
17532                    error: None,
17533                    final_event_watermark: None,
17534                    transcript: vec![],
17535                }))
17536            }
17537        }
17538    }
17539
17540    #[tokio::test]
17541    async fn drive_trips_first_frame_watchdog_on_a_silent_worker() {
17542        let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
17543        let cancel = CancellationToken::new();
17544        let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
17545        let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
17546        let mut logical_session = Session::new("child-x", "model");
17547        let mut link = SilentLink;
17548        let r = drive(ActorDriveContext {
17549            expected_creation: None,
17550            client: &mut link,
17551            parent_session_id: "parent-x",
17552            child_session_id: "child-x",
17553            child_attempt: 0,
17554            approval_registry: None,
17555            approval_decider: None,
17556            approval_reviewer: None,
17557            escalation_bridge: None,
17558            event_tx: &event_tx,
17559            cancel_token: &cancel,
17560            live_rx: &mut live_rx,
17561            delivery_rx: &mut delivery_rx,
17562            logical_session: &mut logical_session,
17563            expected_permission_posture: None,
17564            session_inbox_runtime: None,
17565            actor_directory_store: None,
17566            canonical_subagent_tool: None,
17567            ticket_service: None,
17568            activation_run_id: None,
17569            execution_epoch: 0,
17570            expected_source_actor_id: "child-x",
17571            initial_inflight_claims: VecDeque::new(),
17572            plain_actor: false,
17573            remote_environment_lease: false,
17574            readonly_output: None,
17575            local_history_tools: None,
17576            local_history_read_only: false,
17577            strict_history_output: None,
17578            plain_input: None,
17579            canonical_activation: None,
17580            canonical_placement_ref: None,
17581            actor_event_observer: None,
17582            plain_run: None,
17583            first_frame_timeout: Some(Duration::from_millis(100)),
17584        })
17585        .await;
17586        assert!(
17587            matches!(r, Err(AgentError::WorkerUnresponsive(_))),
17588            "a silent worker must trip the first-frame watchdog, got {r:?}"
17589        );
17590    }
17591
17592    #[tokio::test]
17593    async fn drive_does_not_trip_when_a_frame_arrives() {
17594        let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
17595        let cancel = CancellationToken::new();
17596        let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
17597        let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
17598        let mut logical_session = Session::new("child-y", "model");
17599        let mut link = InstantTerminalLink { done: false };
17600        // Even a tiny timeout must NOT trip: the terminal frame arrives first and
17601        // disarms the watchdog.
17602        let r = drive(ActorDriveContext {
17603            expected_creation: None,
17604            client: &mut link,
17605            parent_session_id: "parent-y",
17606            child_session_id: "child-y",
17607            child_attempt: 0,
17608            approval_registry: None,
17609            approval_decider: None,
17610            approval_reviewer: None,
17611            escalation_bridge: None,
17612            event_tx: &event_tx,
17613            cancel_token: &cancel,
17614            live_rx: &mut live_rx,
17615            delivery_rx: &mut delivery_rx,
17616            logical_session: &mut logical_session,
17617            expected_permission_posture: None,
17618            session_inbox_runtime: None,
17619            actor_directory_store: None,
17620            canonical_subagent_tool: None,
17621            ticket_service: None,
17622            activation_run_id: None,
17623            execution_epoch: 0,
17624            expected_source_actor_id: "child-y",
17625            initial_inflight_claims: VecDeque::new(),
17626            plain_actor: false,
17627            remote_environment_lease: false,
17628            readonly_output: None,
17629            local_history_tools: None,
17630            local_history_read_only: false,
17631            strict_history_output: None,
17632            plain_input: None,
17633            canonical_activation: None,
17634            canonical_placement_ref: None,
17635            actor_event_observer: None,
17636            plain_run: None,
17637            first_frame_timeout: Some(Duration::from_millis(50)),
17638        })
17639        .await;
17640        assert_eq!(r.ok().flatten().as_deref(), Some("done"));
17641    }
17642
17643    #[tokio::test]
17644    async fn child_approval_fails_closed_without_decider() {
17645        // No decider wired ⇒ the host denies (safe default), unchanged behavior.
17646        let body = serde_json::json!({"tool_name":"Bash","permission":"run","resource":"rm -rf /"});
17647        assert!(!decide_child_approval(None, "child-1", &body).await);
17648    }
17649
17650    #[tokio::test]
17651    async fn child_approval_honors_wired_decider() {
17652        let body =
17653            serde_json::json!({"tool_name":"Write","permission":"write","resource":"/tmp/x"});
17654        let approve: Arc<dyn ChildApprovalDecider> = Arc::new(StaticDecider(true));
17655        let deny: Arc<dyn ChildApprovalDecider> = Arc::new(StaticDecider(false));
17656        assert!(decide_child_approval(Some(&approve), "child-1", &body).await);
17657        assert!(!decide_child_approval(Some(&deny), "child-1", &body).await);
17658    }
17659
17660    // ---- #193: remote placement routing -------------------------------------
17661
17662    use crate::runtime::execution::SpawnJob;
17663    use bamboo_agent_core::Session;
17664
17665    /// A runner with a BOGUS worker_bin (`/bin/false`): a local spawn here would
17666    /// FAIL, so a passing remote test proves the remote path never spawns.
17667    fn bogus_runner(placements: HashMap<String, ResolvedRemotePlacement>) -> ActorChildRunner {
17668        ActorChildRunner::new(
17669            "test-actor".into(),
17670            PathBuf::from("/bin/false"),
17671            vec![],
17672            std::env::temp_dir().join("bamboo-test-fab-193"),
17673            ExecutorSpec::Echo,
17674            vec![],
17675            "anthropic".into(),
17676            4,
17677        )
17678        .with_remote_placements(placements)
17679    }
17680
17681    /// A child session of the given role (the role rides `subagent_type`, the
17682    /// path build_spec + the remote lookup both read).
17683    fn session_of_role(role: &str, assignment: &str) -> Session {
17684        let mut s = Session::new("child-1", "test-model");
17685        s.metadata
17686            .insert("subagent_type".to_string(), role.to_string());
17687        s.add_message(bamboo_agent_core::Message::user(assignment));
17688        s
17689    }
17690
17691    fn job_for(child: &str) -> SpawnJob {
17692        SpawnJob {
17693            parent_session_id: "parent-1".into(),
17694            child_session_id: child.into(),
17695            model: String::new(),
17696            disabled_tools: None,
17697        }
17698    }
17699
17700    #[tokio::test]
17701    async fn spawn_depth_provisioning_uses_live_policy_for_local_and_remote_workers() {
17702        let live = Arc::new(tokio::sync::RwLock::new(bamboo_llm::Config::default()));
17703        live.write().await.subagents_mut().max_spawn_depth = Some(1);
17704        let mut remote = HashMap::new();
17705        remote.insert(
17706            "remote-role".into(),
17707            ResolvedRemotePlacement {
17708                endpoint: "wss://worker.example:8443".into(),
17709                token: None,
17710                ca_cert_file: None,
17711                host_label: None,
17712                broker_peer: None,
17713                requirements: None,
17714            },
17715        );
17716        let runner = bogus_runner(remote).with_live_provider_config(live.clone());
17717        let root = Session::new("provision-root", "model");
17718        for role in ["local-role", "remote-role"] {
17719            let mut child =
17720                Session::new_child_of(format!("provision-{role}"), &root, "model", "child");
17721            child.metadata.insert("subagent_type".into(), role.into());
17722            let first = runner.build_live_spec(&child, &job_for(&child.id)).await;
17723            assert_eq!(first.capabilities.max_spawn_depth, Some(1));
17724            assert!(!first.capabilities.nested_spawn);
17725            assert_eq!(
17726                matches!(first.placement, Placement::Remote { .. }),
17727                role == "remote-role"
17728            );
17729            live.write().await.subagents_mut().max_spawn_depth = Some(7);
17730            let second = runner.build_live_spec(&child, &job_for(&child.id)).await;
17731            assert_eq!(second.capabilities.max_spawn_depth, Some(7));
17732            assert!(second.capabilities.nested_spawn);
17733            assert_ne!(
17734                ActorChildRunner::fingerprint(&first),
17735                ActorChildRunner::fingerprint(&second)
17736            );
17737            assert_eq!(second.identity.depth, 1);
17738            live.write().await.subagents_mut().max_spawn_depth = Some(1);
17739        }
17740        let fallback = bogus_runner(HashMap::new()).with_max_spawn_depth(0);
17741        let spec = fallback.build_spec(&root, &job_for(&root.id));
17742        assert_eq!(spec.capabilities.max_spawn_depth, Some(0));
17743        assert!(!spec.capabilities.nested_spawn);
17744    }
17745
17746    struct RecordingChildSessionPort {
17747        parent: Session,
17748        saved: std::sync::Mutex<Option<Session>>,
17749    }
17750
17751    impl RecordingChildSessionPort {
17752        fn saved_child(&self) -> Session {
17753            self.saved
17754                .lock()
17755                .expect("saved-child fixture lock")
17756                .clone()
17757                .expect("create_child_action must save the child")
17758        }
17759    }
17760
17761    #[async_trait]
17762    impl crate::session_app::child_session::ChildSessionPort for RecordingChildSessionPort {
17763        async fn load_root_session(
17764            &self,
17765            root_id: &str,
17766        ) -> Result<Session, crate::session_app::child_session::ChildSessionError> {
17767            assert_eq!(root_id, self.parent.id);
17768            Ok(self.parent.clone())
17769        }
17770
17771        async fn load_child_for_parent(
17772            &self,
17773            _parent_id: &str,
17774            _child_id: &str,
17775        ) -> Result<Session, crate::session_app::child_session::ChildSessionError> {
17776            unreachable!("create_child_action does not reload the child")
17777        }
17778
17779        async fn save_child_session(
17780            &self,
17781            child: &mut Session,
17782        ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17783            *self.saved.lock().expect("saved-child fixture lock") = Some(child.clone());
17784            Ok(())
17785        }
17786
17787        async fn save_child_session_authoritative_flags(
17788            &self,
17789            _child: &mut Session,
17790        ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17791            unreachable!("new-child creation uses the ordinary save")
17792        }
17793
17794        async fn is_child_running(&self, _child_id: &str) -> bool {
17795            false
17796        }
17797
17798        async fn list_children(
17799            &self,
17800            _parent_id: &str,
17801        ) -> Vec<crate::session_app::child_session::ChildSessionEntry> {
17802            Vec::new()
17803        }
17804
17805        async fn enqueue_child_run(
17806            &self,
17807            _parent: &Session,
17808            _child: &Session,
17809        ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17810            unreachable!("fixture creates the child with auto_run=false")
17811        }
17812
17813        async fn cancel_child_run_and_wait(
17814            &self,
17815            _child_id: &str,
17816        ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17817            unreachable!("create_child_action does not cancel")
17818        }
17819
17820        async fn delete_child_session(
17821            &self,
17822            _parent_id: &str,
17823            _child_id: &str,
17824        ) -> Result<
17825            crate::session_app::child_session::DeleteChildResult,
17826            crate::session_app::child_session::ChildSessionError,
17827        > {
17828            unreachable!("create_child_action does not delete")
17829        }
17830
17831        async fn get_child_runner_info(
17832            &self,
17833            _child_id: &str,
17834        ) -> Option<crate::session_app::child_session::ChildRunnerInfo> {
17835            None
17836        }
17837
17838        async fn register_parent_wait_for_child(
17839            &self,
17840            _parent_session_id: &str,
17841            _child_session_id: &str,
17842            _tool_call_id: Option<&str>,
17843        ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17844            unreachable!("create_child_action does not register a wait")
17845        }
17846
17847        async fn register_parent_wait_for_children(
17848            &self,
17849            _parent_session_id: &str,
17850            _child_session_ids: &[String],
17851            _policy: bamboo_domain::session::runtime_state::ChildWaitPolicy,
17852        ) -> Result<usize, crate::session_app::child_session::ChildSessionError> {
17853            unreachable!("create_child_action does not register a wait")
17854        }
17855
17856        async fn active_child_ids(&self, _parent_session_id: &str) -> Vec<String> {
17857            Vec::new()
17858        }
17859
17860        async fn find_resident_child(
17861            &self,
17862            _root_session_id: &str,
17863            _resident_name: &str,
17864        ) -> Option<String> {
17865            None
17866        }
17867
17868        async fn ensure_child_indexed(&self, _child_session_id: &str) {}
17869    }
17870
17871    #[test]
17872    fn build_spec_sets_remote_placement_for_matching_role() {
17873        let mut placements = HashMap::new();
17874        placements.insert(
17875            "explorer".to_string(),
17876            ResolvedRemotePlacement {
17877                endpoint: "wss://gpu-host:8443".into(),
17878                token: Some("T-secret".into()),
17879                ca_cert_file: None,
17880                host_label: None,
17881                broker_peer: None,
17882                requirements: None,
17883            },
17884        );
17885        let runner = bogus_runner(placements);
17886
17887        // The broker credential never rides the child RunSpec secrets envelope.
17888        let s = session_of_role("explorer", "do the thing");
17889        let spec = runner.build_spec(&s, &job_for("child-1"));
17890        match &spec.placement {
17891            Placement::Remote { endpoint } => assert_eq!(endpoint, "wss://gpu-host:8443"),
17892            other => panic!("expected Remote, got {other:?}"),
17893        }
17894        assert!(spec.secrets.worker_auth_token.is_none());
17895    }
17896
17897    #[test]
17898    fn build_spec_leaves_local_for_unmatched_role() {
17899        let mut placements = HashMap::new();
17900        placements.insert(
17901            "explorer".to_string(),
17902            ResolvedRemotePlacement {
17903                endpoint: "wss://gpu-host:8443".into(),
17904                token: Some("T".into()),
17905                ca_cert_file: None,
17906                host_label: None,
17907                broker_peer: None,
17908                requirements: None,
17909            },
17910        );
17911        let runner = bogus_runner(placements);
17912
17913        // A DIFFERENT role keeps the default Local placement + no bearer.
17914        let s = session_of_role("writer", "do the thing");
17915        let spec = runner.build_spec(&s, &job_for("child-1"));
17916        assert_eq!(spec.placement, Placement::Local);
17917        assert!(spec.secrets.worker_auth_token.is_none());
17918    }
17919
17920    #[test]
17921    fn build_spec_local_when_no_placements() {
17922        let runner = bogus_runner(HashMap::new());
17923        let s = session_of_role("explorer", "do the thing");
17924        let spec = runner.build_spec(&s, &job_for("child-1"));
17925        assert_eq!(spec.placement, Placement::Local);
17926        assert!(spec.secrets.worker_auth_token.is_none());
17927    }
17928
17929    #[test]
17930    fn typed_child_state_is_the_read_only_authority_under_auto_and_bypass() {
17931        for mode in [
17932            bamboo_domain::SessionPermissionMode::Auto,
17933            bamboo_domain::SessionPermissionMode::Bypass,
17934        ] {
17935            let runner = bogus_runner(HashMap::new());
17936            let mut child = session_of_role("planner", "inspect and plan");
17937            let runtime = child
17938                .agent_runtime_state
17939                .get_or_insert_with(bamboo_domain::AgentRuntimeState::default);
17940            runtime.set_permission_mode(mode);
17941            runtime.read_only = true;
17942
17943            let spec = runner.build_spec(&child, &job_for("read-only-child"));
17944
17945            assert!(spec.capabilities.read_only);
17946            assert!(spec.capabilities.read_only_enforced());
17947            assert!(
17948                spec.capabilities.guardian_read_only,
17949                "the compatibility alias must protect planners on older workers"
17950            );
17951            assert_eq!(spec.capabilities.permission_requested_mode, mode.as_str());
17952            assert_eq!(spec.capabilities.permission_effective_mode, "plan");
17953            assert!(
17954                !spec.capabilities.bypass,
17955                "read-only must win over requested {mode:?}"
17956            );
17957            assert_eq!(
17958                spec.capabilities.auto_approve_permissions,
17959                mode == bamboo_domain::SessionPermissionMode::Auto,
17960                "Auto may suppress prompts but must not widen Plan authorization"
17961            );
17962            let disabled = spec
17963                .disabled_tools
17964                .as_ref()
17965                .expect("read-only state must provision a host-owned denylist");
17966            for tool_name in [
17967                "Edit",
17968                "Write",
17969                "Plan",
17970                "SubAgent",
17971                "session_control",
17972                "request_permissions",
17973            ] {
17974                assert!(
17975                    disabled.iter().any(|name| name == tool_name),
17976                    "read-only provisioning must disable {tool_name}"
17977                );
17978            }
17979        }
17980
17981        let runner = bogus_runner(HashMap::new());
17982        let cosmetic_planner = runner.build_spec(
17983            &session_of_role("planner", "label only"),
17984            &job_for("cosmetic"),
17985        );
17986        assert!(
17987            !cosmetic_planner.capabilities.read_only_enforced(),
17988            "a cosmetic role label must not change authority"
17989        );
17990        assert!(cosmetic_planner.disabled_tools.is_none());
17991
17992        let cosmetic_guardian = runner.build_spec(
17993            &session_of_role("guardian", "label only"),
17994            &job_for("cosmetic-guardian"),
17995        );
17996        assert!(
17997            !cosmetic_guardian.capabilities.read_only_enforced(),
17998            "a Guardian label without typed state or the legacy denylist is cosmetic"
17999        );
18000
18001        let legacy_guardian = session_of_role("guardian", "pre-upgrade reviewer");
18002        let mut legacy_job = job_for("legacy-guardian");
18003        legacy_job.disabled_tools = Some(vec![
18004            "Edit".to_string(),
18005            "Write".to_string(),
18006            "SubAgent".to_string(),
18007            "WebFetch".to_string(),
18008        ]);
18009        let legacy_spec = runner.build_spec(&legacy_guardian, &legacy_job);
18010        assert!(
18011            legacy_spec.capabilities.read_only_enforced(),
18012            "an in-flight pre-upgrade Guardian keeps its read-only authority"
18013        );
18014
18015        let mut differently_named = session_of_role("explorer", "typed authority");
18016        differently_named
18017            .agent_runtime_state
18018            .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18019            .read_only = true;
18020        let typed = runner.build_spec(&differently_named, &job_for("typed"));
18021        assert!(
18022            typed.capabilities.read_only_enforced(),
18023            "typed state must enforce read-only independently of role"
18024        );
18025    }
18026
18027    #[test]
18028    fn typed_read_only_forces_native_external_executor_profiles() {
18029        let mut planner = session_of_role("planner", "inspect and plan");
18030        planner
18031            .agent_runtime_state
18032            .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18033            .read_only = true;
18034
18035        let claude_runner = ActorChildRunner::new(
18036            "test-actor".into(),
18037            PathBuf::from("/bin/false"),
18038            vec![],
18039            std::env::temp_dir().join("bamboo-test-plan-claude"),
18040            ExecutorSpec::ClaudeCode {
18041                binary: None,
18042                model: None,
18043                permission_mode: Some("bypassPermissions".to_string()),
18044                inherit_user_config: Some(true),
18045                forward_env: None,
18046            },
18047            vec![],
18048            "anthropic".into(),
18049            4,
18050        );
18051        let claude_spec = claude_runner.build_spec(&planner, &job_for("planner-claude"));
18052        let disabled = claude_spec
18053            .disabled_tools
18054            .as_ref()
18055            .expect("typed read-only child must receive a host-owned denylist");
18056        for tool in ["Bash", "BashInput", "BashOutput", "KillShell"] {
18057            assert!(
18058                disabled.iter().any(|name| name == tool),
18059                "typed read-only child must not advertise {tool}"
18060            );
18061        }
18062        assert!(matches!(
18063            claude_spec.executor,
18064            ExecutorSpec::ClaudeCode {
18065                permission_mode: Some(ref mode),
18066                inherit_user_config: Some(false),
18067                ..
18068            } if mode == "plan"
18069        ));
18070        assert_eq!(claude_spec.capabilities.permission_effective_mode, "plan");
18071
18072        let mut codex_exec = codex_executor(Some("inherit"), Some(true));
18073        if let ExecutorSpec::Codex {
18074            sandbox,
18075            approval_policy,
18076            network_access,
18077            allow_danger_bypass,
18078            permission_profile,
18079            ..
18080        } = &mut codex_exec
18081        {
18082            *sandbox = Some("danger-full-access".to_string());
18083            *approval_policy = Some("on-failure".to_string());
18084            *network_access = Some(true);
18085            *allow_danger_bypass = Some(true);
18086            *permission_profile = Some("full-access".to_string());
18087        }
18088        let codex_runner = ActorChildRunner::new(
18089            "test-actor".into(),
18090            PathBuf::from("/bin/false"),
18091            vec![],
18092            std::env::temp_dir().join("bamboo-test-plan-codex"),
18093            codex_exec,
18094            vec![],
18095            "openai".into(),
18096            4,
18097        );
18098        let codex_spec = codex_runner.build_spec(&planner, &job_for("planner-codex"));
18099        assert_eq!(
18100            expected_permission_executor_mapping(
18101                &codex_spec.executor,
18102                codex_spec.capabilities.permission_resolution().unwrap(),
18103                false,
18104            )
18105            .unwrap()
18106            .as_deref(),
18107            Some("codex_exec:approval_policy=never")
18108        );
18109        assert!(matches!(
18110            codex_spec.executor,
18111            ExecutorSpec::Codex {
18112                sandbox: Some(ref sandbox),
18113                inherit_user_config: Some(false),
18114                approval_policy: Some(ref approval_policy),
18115                network_access: Some(false),
18116                allow_danger_bypass: Some(false),
18117                permission_profile: Some(ref profile),
18118                ..
18119            } if sandbox == "read-only"
18120                && approval_policy == "never"
18121                && profile == "read-only"
18122        ));
18123
18124        let mut app_server = codex_executor(Some("inherit"), Some(true));
18125        if let ExecutorSpec::Codex {
18126            mode,
18127            sandbox,
18128            approval_policy,
18129            network_access,
18130            allow_danger_bypass,
18131            permission_profile,
18132            ..
18133        } = &mut app_server
18134        {
18135            *mode = Some("app_server".to_string());
18136            *sandbox = Some("danger-full-access".to_string());
18137            *approval_policy = Some("on-request".to_string());
18138            *network_access = Some(true);
18139            *allow_danger_bypass = Some(true);
18140            *permission_profile = Some("full-access".to_string());
18141        }
18142        let app_server_runner = ActorChildRunner::new(
18143            "test-actor".into(),
18144            PathBuf::from("/bin/false"),
18145            vec![],
18146            std::env::temp_dir().join("bamboo-test-plan-codex-app-server"),
18147            app_server,
18148            vec![],
18149            "openai".into(),
18150            4,
18151        );
18152        let app_server_spec =
18153            app_server_runner.build_spec(&planner, &job_for("planner-codex-app-server"));
18154        assert_eq!(
18155            expected_permission_executor_mapping(
18156                &app_server_spec.executor,
18157                app_server_spec
18158                    .capabilities
18159                    .permission_resolution()
18160                    .unwrap(),
18161                false,
18162            )
18163            .unwrap()
18164            .as_deref(),
18165            Some("codex_app_server:approvalPolicy=never")
18166        );
18167        assert!(matches!(
18168            app_server_spec.executor,
18169            ExecutorSpec::Codex {
18170                mode: Some(ref mode),
18171                sandbox: Some(ref sandbox),
18172                inherit_user_config: Some(false),
18173                approval_policy: Some(ref approval_policy),
18174                network_access: Some(false),
18175                allow_danger_bypass: Some(false),
18176                permission_profile: Some(ref profile),
18177                ..
18178            } if mode == "app_server"
18179                && sandbox == "read-only"
18180                && approval_policy == "on-request"
18181                && profile == "read-only"
18182        ));
18183    }
18184
18185    #[test]
18186    fn typed_read_only_rejects_both_codex_executor_modes() {
18187        let mut planner = session_of_role("planner", "inspect and plan");
18188        planner
18189            .agent_runtime_state
18190            .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18191            .read_only = true;
18192
18193        for mode in ["exec", "app_server"] {
18194            let mut executor = codex_executor(Some("inherit"), Some(true));
18195            if let ExecutorSpec::Codex {
18196                mode: configured_mode,
18197                ..
18198            } = &mut executor
18199            {
18200                *configured_mode = Some(mode.to_string());
18201            }
18202            let runner = ActorChildRunner::new(
18203                "test-actor".into(),
18204                PathBuf::from("/bin/false"),
18205                vec![],
18206                std::env::temp_dir().join(format!("bamboo-test-plan-codex-{mode}")),
18207                executor,
18208                vec![],
18209                "openai".into(),
18210                4,
18211            );
18212            let spec = runner.build_spec(&planner, &job_for(&format!("planner-codex-{mode}")));
18213            let error = ensure_read_only_activation_is_enforceable(&spec)
18214                .expect_err("Codex command execution must fail closed for read-only activations");
18215            let message = error.to_string();
18216            assert!(message.contains("Codex executor"), "{message}");
18217            assert!(message.contains("command execution"), "{message}");
18218        }
18219    }
18220
18221    #[tokio::test]
18222    async fn typed_read_only_codex_fails_before_worker_spawn_or_dispatch() {
18223        let mut planner = session_of_role("planner", "inspect and plan");
18224        planner
18225            .agent_runtime_state
18226            .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18227            .read_only = true;
18228
18229        let runner = ActorChildRunner::new(
18230            "test-actor".into(),
18231            PathBuf::from("/bin/false"),
18232            vec![],
18233            std::env::temp_dir().join("bamboo-test-plan-codex-no-spawn"),
18234            codex_executor(Some("inherit"), Some(true)),
18235            vec![],
18236            "openai".into(),
18237            4,
18238        );
18239        let (event_tx, _event_rx) = mpsc::channel::<AgentEvent>(4);
18240        let error = runner
18241            .execute_external_child(
18242                &mut planner,
18243                &job_for("planner-codex-no-spawn"),
18244                event_tx,
18245                CancellationToken::new(),
18246            )
18247            .await
18248            .expect_err("typed read-only Codex must fail before invoking /bin/false");
18249        let message = error.to_string();
18250        assert!(message.contains("Codex executor"), "{message}");
18251        assert!(message.contains("command execution"), "{message}");
18252    }
18253
18254    #[test]
18255    fn ticket_placement_keeps_local_and_ordinary_remote_execution_available() {
18256        let runner = bogus_runner(HashMap::new());
18257        let ordinary = session_of_role("worker", "ordinary work");
18258        for placement in [
18259            Placement::Local,
18260            Placement::Remote {
18261                endpoint: "wss://fixture.invalid/actor".into(),
18262            },
18263            Placement::Schedulable {
18264                pool: "fixture-pool".into(),
18265            },
18266        ] {
18267            let mut spec = runner.build_spec(&ordinary, &job_for("placement"));
18268            spec.placement = placement;
18269            ensure_ticket_placement_is_enforceable(&ordinary, &spec).unwrap();
18270            for marker in [
18271                crate::ticket_runtime::TICKET_DISPATCH_KEY,
18272                crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY,
18273            ] {
18274                let mut ticket = ordinary.clone();
18275                ticket.metadata.insert(marker.into(), "fixture".into());
18276                assert_eq!(
18277                    ensure_ticket_placement_is_enforceable(&ticket, &spec).is_ok(),
18278                    matches!(spec.placement, Placement::Local)
18279                );
18280            }
18281        }
18282    }
18283
18284    #[tokio::test]
18285    async fn ticket_placement_rejected_before_remote_route_claim_or_worker_spawn() {
18286        for marker in [
18287            crate::ticket_runtime::TICKET_DISPATCH_KEY,
18288            crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY,
18289        ] {
18290            for schedulable in [false, true] {
18291                let mut session = session_of_role("worker", "Ticket work");
18292                session.metadata.insert(marker.into(), "fixture".into());
18293                let before = serde_json::to_value(&session).unwrap();
18294                let remote = if schedulable {
18295                    HashMap::new()
18296                } else {
18297                    HashMap::from([(
18298                        "worker".into(),
18299                        ResolvedRemotePlacement {
18300                            endpoint: "wss://fixture.invalid/actor".into(),
18301                            ..Default::default()
18302                        },
18303                    )])
18304                };
18305                let sched = if schedulable {
18306                    HashMap::from([(
18307                        "worker".into(),
18308                        sched_placement("fixture-pool", "wss://fixture.invalid"),
18309                    )])
18310                } else {
18311                    HashMap::new()
18312                };
18313                let runner = bogus_sched_runner(remote, sched);
18314                let (events, mut observations) = mpsc::channel::<AgentEvent>(4);
18315                let error = runner
18316                    .execute_external_child(
18317                        &mut session,
18318                        &job_for("ticket-no-remote"),
18319                        events,
18320                        CancellationToken::new(),
18321                    )
18322                    .await
18323                    .unwrap_err();
18324                assert!(
18325                    error
18326                        .to_string()
18327                        .contains("Ticket execution requires local placement"),
18328                    "{error}"
18329                );
18330                assert_eq!(serde_json::to_value(&session).unwrap(), before);
18331                assert!(observations.try_recv().is_err());
18332            }
18333        }
18334    }
18335
18336    #[test]
18337    fn typed_read_only_rejects_resident_actor_placements() {
18338        let runner = bogus_runner(HashMap::new());
18339        let mut planner = session_of_role("planner", "inspect and plan");
18340        planner
18341            .agent_runtime_state
18342            .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18343            .read_only = true;
18344
18345        for placement in [
18346            Placement::Remote {
18347                endpoint: "wss://resident.example/actor".to_string(),
18348            },
18349            Placement::Schedulable {
18350                pool: "resident-pool".to_string(),
18351            },
18352        ] {
18353            let mut spec = runner.build_spec(&planner, &job_for("read-only-resident"));
18354            spec.placement = placement;
18355
18356            let error = ensure_read_only_activation_is_enforceable(&spec)
18357                .expect_err("resident placement must fail closed for read-only activation");
18358            let message = error.to_string();
18359            assert!(message.contains("requires local placement"), "{message}");
18360            assert!(message.contains("read-only tool boundary"), "{message}");
18361        }
18362
18363        let local = runner.build_spec(&planner, &job_for("read-only-local"));
18364        ensure_read_only_activation_is_enforceable(&local)
18365            .expect("a locally provisioned read-only worker is enforceable");
18366    }
18367
18368    #[tokio::test]
18369    async fn build_spec_preserves_exact_inherited_permission_mode_for_child_worker() {
18370        // Exercise the real creation path instead of pre-seeding the child by
18371        // hand: both legacy Bypass and zero-prompt Auto must survive child
18372        // creation and the actor provisioning boundary without collapsing.
18373        for (label, mode) in [
18374            ("bypass", bamboo_domain::SessionPermissionMode::Bypass),
18375            ("auto", bamboo_domain::SessionPermissionMode::Auto),
18376        ] {
18377            let runner = bogus_runner(HashMap::new());
18378            let mut parent = Session::new(format!("parent-{label}"), "test-model");
18379            parent
18380                .agent_runtime_state
18381                .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18382                .set_permission_mode(mode);
18383            let workspace = tempfile::tempdir().expect("workspace fixture");
18384            let port = RecordingChildSessionPort {
18385                parent: parent.clone(),
18386                saved: Default::default(),
18387            };
18388            let child_id = format!("child-{label}-{}", uuid::Uuid::new_v4());
18389            crate::session_app::child_session::create_child_action(
18390                &port,
18391                crate::session_app::child_session::CreateChildInput {
18392                    parent_session: parent,
18393                    child_id: child_id.clone(),
18394                    title: format!("{label} child"),
18395                    responsibility: "Run ordinary commands".to_string(),
18396                    assignment_prompt: "run an ordinary command".to_string(),
18397                    subagent_type: "explorer".to_string(),
18398                    workspace: workspace.path().to_string_lossy().into_owned(),
18399                    workspace_source: crate::project_context::WorkspaceSource::Explicit,
18400                    model_override: None,
18401                    model_ref_override: None,
18402                    runtime_metadata: HashMap::new(),
18403                    read_only: false,
18404                    auto_run: false,
18405                    reasoning_effort: None,
18406                    lifecycle: None,
18407                    resident_name: None,
18408                    resident_context: None,
18409                    disabled_tools: None,
18410                    context_fork: None,
18411                },
18412            )
18413            .await
18414            .expect("create inherited-permission child");
18415            let child = port.saved_child();
18416
18417            assert_eq!(
18418                child
18419                    .agent_runtime_state
18420                    .as_ref()
18421                    .map(bamboo_domain::AgentRuntimeState::effective_permission_mode),
18422                Some(mode),
18423                "create_child_action must inherit {label} from the parent"
18424            );
18425
18426            let spec = runner.build_spec(&child, &job_for(&child_id));
18427
18428            assert_eq!(
18429                spec.capabilities.bypass,
18430                mode == bamboo_domain::SessionPermissionMode::Bypass
18431            );
18432            assert_eq!(
18433                spec.capabilities.auto_approve_permissions,
18434                mode == bamboo_domain::SessionPermissionMode::Auto
18435            );
18436            assert!(
18437                spec.capabilities.enforce_permissions,
18438                "policy evaluation must remain active under {label}"
18439            );
18440        }
18441    }
18442
18443    #[tokio::test]
18444    async fn child_resident_and_guardian_inherit_project_through_actor_run_spec() {
18445        let project_id = bamboo_domain::ProjectId::parse("project-inherited").expect("Project id");
18446        let workspace = tempfile::tempdir().expect("workspace fixture");
18447
18448        for (role, lifecycle, resident_name) in [
18449            ("explorer", None, None),
18450            ("resident", Some("resident"), Some("stable-reviewer")),
18451            ("guardian", None, None),
18452        ] {
18453            let mut parent = Session::new(format!("parent-{role}"), "test-model");
18454            parent.set_project_id_meta(project_id.to_string());
18455            let port = RecordingChildSessionPort {
18456                parent: parent.clone(),
18457                saved: Default::default(),
18458            };
18459            let child_id = format!("child-{role}-{}", uuid::Uuid::new_v4());
18460            crate::session_app::child_session::create_child_action(
18461                &port,
18462                crate::session_app::child_session::CreateChildInput {
18463                    parent_session: parent,
18464                    child_id: child_id.clone(),
18465                    title: format!("{role} child"),
18466                    responsibility: "Review the assigned work".to_string(),
18467                    assignment_prompt: "inspect the change".to_string(),
18468                    subagent_type: role.to_string(),
18469                    workspace: workspace.path().to_string_lossy().into_owned(),
18470                    workspace_source: crate::project_context::WorkspaceSource::Explicit,
18471                    model_override: None,
18472                    model_ref_override: None,
18473                    runtime_metadata: HashMap::new(),
18474                    read_only: false,
18475                    auto_run: false,
18476                    reasoning_effort: None,
18477                    lifecycle: lifecycle.map(str::to_string),
18478                    resident_name: resident_name.map(str::to_string),
18479                    resident_context: None,
18480                    disabled_tools: None,
18481                    context_fork: None,
18482                },
18483            )
18484            .await
18485            .expect("create Project-inheriting child");
18486            let child = port.saved_child();
18487            assert_eq!(
18488                crate::project_context::ProjectContextResolver::project_id_from_session(&child),
18489                Some(project_id.clone()),
18490                "{role} child must inherit its parent's Project"
18491            );
18492
18493            assert_eq!(
18494                project_id_for_actor_run(&child).expect("valid actor Project identity"),
18495                Some(project_id.clone()),
18496                "{role} actor RunSpec must preserve inherited Project identity"
18497            );
18498        }
18499    }
18500
18501    #[test]
18502    fn placement_metadata_stamps_remote_and_schedulable_not_local() {
18503        // Local children carry no stamp — the DTO defaults them to the backend host.
18504        assert_eq!(placement_metadata(&Placement::Local, None), None);
18505
18506        // Remote, no node label → host derived from the endpoint.
18507        let r = placement_metadata(
18508            &Placement::Remote {
18509                endpoint: "wss://10.0.0.5:8443/stream".into(),
18510            },
18511            None,
18512        )
18513        .unwrap();
18514        assert!(r.contains(r#""kind":"remote""#), "{r}");
18515        assert!(r.contains(r#""host":"10.0.0.5""#), "{r}");
18516
18517        // A cluster node's label (its metadata) OVERRIDES the raw endpoint host.
18518        let labeled = placement_metadata(
18519            &Placement::Remote {
18520                endpoint: "ws://169.254.230.101:8899".into(),
18521            },
18522            Some("mini"),
18523        )
18524        .unwrap();
18525        assert!(labeled.contains(r#""host":"mini""#), "{labeled}");
18526
18527        // Schedulable → {kind:"remote", host:<node label, else pool>}.
18528        let s = placement_metadata(
18529            &Placement::Schedulable {
18530                pool: "explorers".into(),
18531            },
18532            Some("mini"),
18533        )
18534        .unwrap();
18535        assert!(s.contains(r#""kind":"remote""#), "{s}");
18536        assert!(s.contains(r#""host":"mini""#), "{s}");
18537
18538        // The stamp round-trips through the storage placement type.
18539        let p: bamboo_storage::SessionPlacement = serde_json::from_str(&labeled).unwrap();
18540        assert_eq!(p.kind, "remote");
18541        assert_eq!(p.host, "mini");
18542    }
18543
18544    /// A legacy direct worker endpoint cannot bypass the broker activation
18545    /// contract, even when the endpoint is reachable and has a bearer token.
18546    #[tokio::test]
18547    async fn execute_external_child_rejects_direct_remote_worker() {
18548        let mut placements = HashMap::new();
18549        placements.insert(
18550            "explorer".to_string(),
18551            ResolvedRemotePlacement {
18552                endpoint: "ws://127.0.0.1:1".into(),
18553                token: Some("remote-test-token".into()),
18554                ca_cert_file: None,
18555                host_label: Some("mini-e2e".into()),
18556                broker_peer: None,
18557                requirements: None,
18558            },
18559        );
18560        let runner = bogus_runner(placements);
18561        let mut session = session_of_role("explorer", "hello remote");
18562        let job = job_for("child-1");
18563        let (event_tx, _event_rx) = mpsc::channel::<AgentEvent>(64);
18564        let error = runner
18565            .execute_external_child(&mut session, &job, event_tx, CancellationToken::new())
18566            .await
18567            .expect_err("direct remote transport must fail closed");
18568        assert!(error
18569            .to_string()
18570            .contains("remote_broker_activation_unavailable"));
18571        assert!(session
18572            .messages
18573            .iter()
18574            .all(|message| message.role != Role::Assistant));
18575    }
18576
18577    // ---- #181 (P2b): schedulable placement routing --------------------------
18578
18579    /// A bogus-worker_bin runner carrying SCHEDULABLE placements (and optionally
18580    /// remote ones, to test precedence). A local spawn here would fail on
18581    /// `/bin/false`, so a passing schedulable test proves no subprocess spawned.
18582    fn bogus_sched_runner(
18583        remote: HashMap<String, ResolvedRemotePlacement>,
18584        sched: HashMap<String, ResolvedSchedulablePlacement>,
18585    ) -> ActorChildRunner {
18586        ActorChildRunner::new(
18587            "test-actor".into(),
18588            PathBuf::from("/bin/false"),
18589            vec![],
18590            std::env::temp_dir().join("bamboo-test-fab-181"),
18591            ExecutorSpec::Echo,
18592            vec![],
18593            "anthropic".into(),
18594            4,
18595        )
18596        .with_remote_placements(remote)
18597        .with_schedulable_placements(sched)
18598    }
18599
18600    fn sched_placement(
18601        pool: &str,
18602        _registry_url: impl Into<String>,
18603    ) -> ResolvedSchedulablePlacement {
18604        ResolvedSchedulablePlacement {
18605            pool: pool.into(),
18606            host_label: None,
18607            requirements: None,
18608            ..Default::default()
18609        }
18610    }
18611
18612    #[test]
18613    fn build_spec_sets_schedulable_placement_for_matching_role() {
18614        let mut sched = HashMap::new();
18615        sched.insert(
18616            "explorer".to_string(),
18617            sched_placement("gpu-pool", "unused"),
18618        );
18619        let runner = bogus_sched_runner(HashMap::new(), sched);
18620
18621        let s = session_of_role("explorer", "do the thing");
18622        let spec = runner.build_spec(&s, &job_for("child-1"));
18623        match &spec.placement {
18624            Placement::Schedulable { pool } => assert_eq!(pool, "gpu-pool"),
18625            other => panic!("expected Schedulable, got {other:?}"),
18626        }
18627        // No per-placement bearer now — the bus connection carries the bus token.
18628        assert!(spec.secrets.worker_auth_token.is_none());
18629    }
18630
18631    #[test]
18632    fn build_spec_remote_wins_when_role_in_both_maps() {
18633        // A role present in BOTH remote_placements and schedulable_placements must
18634        // resolve to the FIXED remote placement (documented precedence).
18635        let mut remote = HashMap::new();
18636        remote.insert(
18637            "explorer".to_string(),
18638            ResolvedRemotePlacement {
18639                endpoint: "wss://fixed-host:8443".into(),
18640                token: Some("T-remote".into()),
18641                ca_cert_file: None,
18642                host_label: None,
18643                broker_peer: None,
18644                requirements: None,
18645            },
18646        );
18647        let mut sched = HashMap::new();
18648        sched.insert(
18649            "explorer".to_string(),
18650            sched_placement("gpu-pool", "https://control-plane:9562"),
18651        );
18652        let runner = bogus_sched_runner(remote, sched);
18653
18654        let s = session_of_role("explorer", "do the thing");
18655        let spec = runner.build_spec(&s, &job_for("child-1"));
18656        match &spec.placement {
18657            Placement::Remote { endpoint } => assert_eq!(endpoint, "wss://fixed-host:8443"),
18658            other => panic!("expected Remote (precedence), got {other:?}"),
18659        }
18660        assert!(spec.secrets.worker_auth_token.is_none());
18661    }
18662
18663    #[test]
18664    fn build_spec_local_for_unmatched_schedulable_role() {
18665        let mut sched = HashMap::new();
18666        sched.insert(
18667            "explorer".to_string(),
18668            sched_placement("gpu-pool", "https://control-plane:9562"),
18669        );
18670        let runner = bogus_sched_runner(HashMap::new(), sched);
18671        let s = session_of_role("writer", "do the thing");
18672        let spec = runner.build_spec(&s, &job_for("child-1"));
18673        assert_eq!(spec.placement, Placement::Local);
18674        assert!(spec.secrets.worker_auth_token.is_none());
18675    }
18676
18677    /// The full role → resolved-placement → badge-host chain: a child routed to a
18678    /// remote/schedulable placement carrying a cluster node's `host_label` stamps
18679    /// that label; without a label it falls back to the endpoint host / pool; a
18680    /// Local child gets no stamp (the DTO defaults it to the backend host).
18681    #[test]
18682    fn placement_stamp_uses_node_label_for_remote_and_schedulable() {
18683        // Remote WITH a node label → {remote, <label>}, overriding the raw IP.
18684        let mut remote = HashMap::new();
18685        remote.insert(
18686            "explorer".to_string(),
18687            ResolvedRemotePlacement {
18688                endpoint: "ws://169.254.230.101:8899".into(),
18689                token: None,
18690                ca_cert_file: None,
18691                host_label: Some("mini".into()),
18692                broker_peer: None,
18693                requirements: None,
18694            },
18695        );
18696        let runner = bogus_runner(remote);
18697        let spec = runner.build_spec(&session_of_role("explorer", "go"), &job_for("c1"));
18698        let stamp = runner
18699            .placement_stamp_for(&spec)
18700            .expect("remote child is stamped");
18701        assert!(stamp.contains(r#""kind":"remote""#), "{stamp}");
18702        assert!(stamp.contains(r#""host":"mini""#), "{stamp}");
18703
18704        // Remote WITHOUT a node label → falls back to the endpoint host.
18705        let mut remote_nolabel = HashMap::new();
18706        remote_nolabel.insert(
18707            "explorer".to_string(),
18708            ResolvedRemotePlacement {
18709                endpoint: "ws://169.254.230.101:8899".into(),
18710                token: None,
18711                ca_cert_file: None,
18712                host_label: None,
18713                broker_peer: None,
18714                requirements: None,
18715            },
18716        );
18717        let r2 = bogus_runner(remote_nolabel);
18718        let spec2 = r2.build_spec(&session_of_role("explorer", "go"), &job_for("c1"));
18719        assert!(r2
18720            .placement_stamp_for(&spec2)
18721            .unwrap()
18722            .contains(r#""host":"169.254.230.101""#));
18723
18724        // Schedulable WITH a node label → {remote, <label>} (a node, not a pool name).
18725        let mut sched = HashMap::new();
18726        sched.insert(
18727            "mac-mini-monitor".to_string(),
18728            ResolvedSchedulablePlacement {
18729                pool: "mac-mini-monitor".into(),
18730                host_label: Some("mini".into()),
18731                requirements: None,
18732                ..Default::default()
18733            },
18734        );
18735        let sr = bogus_sched_runner(HashMap::new(), sched);
18736        let spec3 = sr.build_spec(&session_of_role("mac-mini-monitor", "go"), &job_for("c1"));
18737        let stamp3 = sr
18738            .placement_stamp_for(&spec3)
18739            .expect("scheduled child is stamped");
18740        assert!(stamp3.contains(r#""kind":"remote""#), "{stamp3}");
18741        assert!(stamp3.contains(r#""host":"mini""#), "{stamp3}");
18742
18743        // A Local (unmatched) child gets NO stamp.
18744        let local = bogus_runner(HashMap::new());
18745        let spec4 = local.build_spec(&session_of_role("writer", "go"), &job_for("c1"));
18746        assert_eq!(local.placement_stamp_for(&spec4), None);
18747    }
18748
18749    #[tokio::test]
18750    async fn legacy_schedulable_route_fails_closed_without_local_fallback() {
18751        let mut sched = HashMap::new();
18752        sched.insert(
18753            "explorer".to_string(),
18754            sched_placement("gpu-pool", "unused"),
18755        );
18756        let runner = bogus_sched_runner(HashMap::new(), sched);
18757        let mut session = session_of_role("explorer", "do the thing");
18758        let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
18759        let error = runner
18760            .execute_external_child(
18761                &mut session,
18762                &job_for("child-1"),
18763                event_tx,
18764                CancellationToken::new(),
18765            )
18766            .await
18767            .expect_err("legacy bus presence cannot grant placement");
18768        assert!(error
18769            .to_string()
18770            .contains("remote_broker_activation_unavailable"));
18771    }
18772}