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(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_messages: session.messages.clone(),
611            expected_provider_transcript: session.provider_transcript.clone(),
612            messages: vec![message],
613            native_groups: Vec::new(),
614        })
615        .await
616        .map_err(|error| AgentError::LLM(format!("remote actor reply commit failed: {error}")))?;
617    session.messages = committed.messages;
618    session.provider_transcript = committed.provider_transcript;
619    session.updated_at = committed.updated_at;
620    Ok(())
621}
622
623impl ActivePlacementLease {
624    async fn reserve(
625        registry: bamboo_storage::v2::FileHostRegistry,
626        actor_store: Arc<bamboo_storage::SessionStoreV2>,
627        route: ScopedPlacementRoute,
628        mailbox: String,
629        actor_id: String,
630        run_id: String,
631        project_id: Option<bamboo_domain::ProjectId>,
632        required_tools: BTreeSet<String>,
633        parent_cancel: &CancellationToken,
634    ) -> Result<Self, AgentError> {
635        let scheduler = PlacementScheduler::new(registry.clone());
636        let mut query = route.query().await?;
637        let observed = query
638            .observe_host_capacity(&mailbox, &route.worker_role)
639            .await
640            .map_err(|_| remote_broker_unavailable())?
641            .ok_or_else(remote_broker_unavailable)?;
642        let host = scheduler
643            .observe_scoped_host(
644                &mut query,
645                &observed.host_ref,
646                &mailbox,
647                &route.worker_role,
648                chrono::Duration::seconds(110),
649            )
650            .await
651            .map_err(|_| remote_broker_unavailable())?;
652        if host.capabilities.placement_class != bamboo_domain::ActorPlacementClass::Remote
653            || host.connection_generation != observed.connection_generation
654            || route
655                .pinned_mailbox
656                .as_deref()
657                .is_some_and(|pinned| pinned != mailbox)
658        {
659            return Err(remote_broker_unavailable());
660        }
661        let now = chrono::Utc::now();
662        let request = HostPlacementRequest {
663            intent: HostPlacementIntent::PinnedHost {
664                host_ref: host.host_ref.clone(),
665            },
666            actor_id,
667            run_id,
668            project_id: project_id.clone(),
669            trust_zone: route.requirements.trust_zone.clone(),
670            workspace_label: route.requirements.workspace_label.clone(),
671            executor: "bamboo-runtime".into(),
672            required_tools,
673            network_zone: route.requirements.network_zone.clone(),
674            require_network_isolation: route.requirements.require_network_isolation,
675            preferred_host_ref: None,
676            now,
677            lease_expires_at: now + chrono::Duration::seconds(80),
678        };
679        if !request.eligible(&host) {
680            return Err(remote_broker_unavailable());
681        }
682        let lease = scheduler
683            .reserve(request)
684            .await
685            .map_err(|_| remote_broker_unavailable())?;
686        let placement_ref = bamboo_domain::ActorPlacementRef {
687            class: bamboo_domain::ActorPlacementClass::Remote,
688            lease_id: lease.lease_id.clone(),
689            slot_epoch: Some(lease.epoch),
690        };
691        let mut claimed_fence = None;
692        let actor_binding = async {
693            let actor = actor_store
694                .ensure_actor(&lease.actor_id)
695                .await
696                .map_err(|_| remote_broker_unavailable())?;
697            if actor.actor.project_id != project_id.as_ref().map(|id| id.to_string()) {
698                return Err(remote_broker_unavailable());
699            }
700            let placement_intent = if route.pinned_mailbox.is_some() {
701                bamboo_domain::ActorPlacementIntent::PinnedHost {
702                    host_ref: host.host_ref.clone(),
703                }
704            } else {
705                bamboo_domain::ActorPlacementIntent::Pool {
706                    pool_id: route.worker_role.clone(),
707                }
708            };
709            actor_store
710                .bind_control_plane(
711                    &lease.actor_id,
712                    bamboo_domain::ActorControlPlaneBinding {
713                        policy_revision: None,
714                        placement_intent: Some(placement_intent),
715                    },
716                )
717                .await
718                .map_err(|_| {
719                    tracing::warn!(stage = "actor_bind", "remote placement admission failed");
720                    remote_broker_unavailable()
721                })?;
722            let activation = actor_store
723                .claim_activation(&ActorActivationClaim {
724                    actor_id: lease.actor_id.clone(),
725                    run_id: lease.run_id.clone(),
726                    lease_owner: format!("worker-host-{}", uuid::Uuid::new_v4()),
727                    lease_expires_at: lease.expires_at,
728                    inbox_generation: 0,
729                    placement_ref: Some(placement_ref.clone()),
730                    now: chrono::Utc::now(),
731                })
732                .await
733                .map_err(|_| {
734                    tracing::warn!(stage = "actor_claim", "remote placement admission failed");
735                    remote_broker_unavailable()
736                })?;
737            let fence = activation.fence();
738            claimed_fence = Some(fence.clone());
739            actor_store
740                .start_activation(&fence, chrono::Utc::now())
741                .await
742                .map_err(|_| {
743                    tracing::warn!(stage = "actor_start", "remote placement admission failed");
744                    remote_broker_unavailable()
745                })?;
746            let reservation = registry
747                .validate_run_reservation(bamboo_domain::WorkerRunReservationCheck {
748                    placement_ref: placement_ref.clone(),
749                    actor_id: lease.actor_id.clone(),
750                    run_id: lease.run_id.clone(),
751                    observed_host_ref: host.host_ref.clone(),
752                    observed_connection_generation: host.connection_generation.clone(),
753                    now: chrono::Utc::now(),
754                })
755                .await
756                .map_err(|error| {
757                    tracing::warn!(stage = "slot_preflight", reason = %error, "remote placement admission failed");
758                    remote_broker_unavailable()
759                })?;
760            if reservation.mailbox != mailbox || reservation.lease != lease {
761                tracing::warn!(stage = "slot_binding", "remote placement admission failed");
762                return Err(remote_broker_unavailable());
763            }
764            Ok::<_, AgentError>(fence)
765        }
766        .await;
767        let fence = match actor_binding {
768            Ok(fence) => fence,
769            Err(error) => {
770                if let Some(fence) = claimed_fence.as_ref() {
771                    if let Err(rollback_error) =
772                        fail_pre_dispatch_activation(actor_store.as_ref(), fence).await
773                    {
774                        tracing::warn!(
775                            stage = "actor_pre_dispatch_rollback",
776                            reason = %rollback_error,
777                            "remote placement activation rollback failed"
778                        );
779                    }
780                }
781                let _ = registry.release_slot(&lease).await;
782                return Err(error);
783            }
784        };
785        let lease = Arc::new(tokio::sync::Mutex::new(lease));
786        let host_ref = host.host_ref.clone();
787        let lost = Arc::new(AtomicBool::new(false));
788        let run_cancel = parent_cancel.child_token();
789        let heartbeat = {
790            let registry = registry.clone();
791            let actor_store = actor_store.clone();
792            let route = route.clone();
793            let host_ref = host_ref.clone();
794            let mailbox = mailbox.clone();
795            let fence = fence.clone();
796            let placement_ref = placement_ref.clone();
797            let lease = lease.clone();
798            let lost = lost.clone();
799            let run_cancel = run_cancel.clone();
800            tokio::spawn(async move {
801                let mut interval = tokio::time::interval(Duration::from_secs(30));
802                interval.tick().await;
803                loop {
804                    tokio::select! {
805                        _ = run_cancel.cancelled() => break,
806                        _ = interval.tick() => {}
807                    }
808                    let refreshed = async {
809                        let mut query = route.query().await?;
810                        let host = PlacementScheduler::new(registry.clone())
811                            .observe_scoped_host(
812                                &mut query,
813                                &host_ref,
814                                &mailbox,
815                                &route.worker_role,
816                                chrono::Duration::seconds(110),
817                            )
818                            .await
819                            .map_err(|_| remote_broker_unavailable())?;
820                        let current = lease.lock().await.clone();
821                        if host.connection_generation != current.connection_generation {
822                            return Err(remote_broker_unavailable());
823                        }
824                        let entry = actor_store
825                            .inspect_actor(&current.actor_id)
826                            .await
827                            .map_err(|_| remote_broker_unavailable())?;
828                        if entry.activation.as_ref().is_none_or(|activation| {
829                            activation.fence() != fence
830                                || activation.placement_ref.as_ref() != Some(&placement_ref)
831                        }) {
832                            return Err(remote_broker_unavailable());
833                        }
834                        let now = chrono::Utc::now();
835                        registry
836                            .validate_run_reservation(bamboo_domain::WorkerRunReservationCheck {
837                                placement_ref: placement_ref.clone(),
838                                actor_id: current.actor_id.clone(),
839                                run_id: current.run_id.clone(),
840                                observed_host_ref: host.host_ref.clone(),
841                                observed_connection_generation: host.connection_generation.clone(),
842                                now,
843                            })
844                            .await
845                            .map_err(|_| remote_broker_unavailable())?;
846                        let renewed = registry
847                            .renew_slot(&current, now, now + chrono::Duration::seconds(80))
848                            .await
849                            .map_err(|_| remote_broker_unavailable())?;
850                        *lease.lock().await = renewed;
851                        let expiry = lease.lock().await.expires_at;
852                        actor_store
853                            .renew_activation(&fence, now, expiry)
854                            .await
855                            .map_err(|_| remote_broker_unavailable())?;
856                        Ok::<(), AgentError>(())
857                    }
858                    .await;
859                    if refreshed.is_err() {
860                        lost.store(true, Ordering::SeqCst);
861                        run_cancel.cancel();
862                        break;
863                    }
864                }
865            })
866        };
867        Ok(Self {
868            registry,
869            actor_store,
870            route,
871            mailbox,
872            host_ref,
873            fence,
874            placement_ref,
875            lease,
876            heartbeat,
877            run_cancel,
878            lost,
879            release_on_drop: AtomicBool::new(false),
880        })
881    }
882
883    fn lost(&self) -> bool {
884        self.lost.load(Ordering::SeqCst)
885    }
886
887    async fn abort_if_run_unsent(&mut self, send_invoked: bool) -> Result<bool, AgentError> {
888        if send_invoked {
889            return Ok(false);
890        }
891        self.abort_before_dispatch().await?;
892        Ok(true)
893    }
894
895    async fn abort_before_dispatch(&mut self) -> Result<(), AgentError> {
896        self.heartbeat.abort();
897        let _ = (&mut self.heartbeat).await;
898        let current = self.lease.lock().await.clone();
899        let failed = fail_pre_dispatch_activation(self.actor_store.as_ref(), &self.fence).await;
900        let released = self.registry.release_slot(&current).await;
901        if let Err(error) = failed {
902            tracing::warn!(stage = "actor_pre_dispatch_rollback", reason = %error, "remote activation failure could not be recorded");
903            return Err(remote_broker_unavailable());
904        }
905        if let Err(error) = released {
906            if !matches!(error, bamboo_domain::HostRegistryError::StaleLease) {
907                tracing::warn!(stage = "slot_pre_dispatch_rollback", reason = %error, "remote slot release failed");
908                return Err(remote_broker_unavailable());
909            }
910        }
911        Ok(())
912    }
913
914    async fn complete(
915        &mut self,
916        outcome: ActorActivationFinish,
917        terminal_proven: bool,
918    ) -> Result<(), AgentError> {
919        if !terminal_proven {
920            // Keep the activation live until its lease expires. A silent Worker
921            // may still be executing; this blocks an immediate ActorId retry.
922            return if outcome == ActorActivationFinish::Succeeded {
923                Err(remote_broker_unavailable())
924            } else {
925                Ok(())
926            };
927        }
928        // Wait for the heartbeat to stop before reading the exact lease. It
929        // must not renew the slot after terminal verification or release.
930        self.heartbeat.abort();
931        let _ = (&mut self.heartbeat).await;
932        let current = self.lease.lock().await.clone();
933        // A correlated Outcome from the selected mailbox alone cannot release
934        // a new generation's slot. Reobserve the scoped broker identity at
935        // terminal time, including fast reconnects between heartbeat ticks.
936        let mut query = self.route.query().await?;
937        let observed = query
938            .observe_host_capacity(&self.mailbox, &self.route.worker_role)
939            .await
940            .map_err(|_| remote_broker_unavailable())?
941            .ok_or_else(remote_broker_unavailable)?;
942        if observed.host_ref != self.host_ref
943            || observed.connection_generation != current.connection_generation
944            || observed.mailbox != self.mailbox
945            || observed.role.as_deref() != Some(self.route.worker_role.as_str())
946        {
947            return Err(remote_broker_unavailable());
948        }
949        let entry = self
950            .actor_store
951            .inspect_actor(&current.actor_id)
952            .await
953            .map_err(|_| remote_broker_unavailable())?;
954        if entry.activation.as_ref().is_none_or(|activation| {
955            activation.fence() != self.fence
956                || activation.placement_ref.as_ref() != Some(&self.placement_ref)
957        }) {
958            return Err(remote_broker_unavailable());
959        }
960        self.registry
961            .validate_slot(&current, chrono::Utc::now())
962            .await
963            .map_err(|_| remote_broker_unavailable())?;
964        self.actor_store
965            .finish_activation(&self.fence, chrono::Utc::now(), outcome)
966            .await
967            .map_err(|_| remote_broker_unavailable())?;
968        // A terminal ActorDirectory record permits an immediate retry. Free
969        // its exact slot before returning rather than racing a Drop task.
970        self.release_on_drop.store(true, Ordering::SeqCst);
971        match self.registry.release_slot(&current).await {
972            Ok(()) | Err(bamboo_domain::HostRegistryError::StaleLease) => {
973                self.release_on_drop.store(false, Ordering::SeqCst);
974                Ok(())
975            }
976            Err(error) => {
977                tracing::warn!(stage = "slot_terminal_release", reason = %error, "remote slot release failed");
978                Err(remote_broker_unavailable())
979            }
980        }
981    }
982}
983
984impl Drop for ActivePlacementLease {
985    fn drop(&mut self) {
986        self.run_cancel.cancel();
987        self.heartbeat.abort();
988        if !self.release_on_drop.load(Ordering::SeqCst) {
989            return;
990        }
991        let registry = self.registry.clone();
992        let actor_store = self.actor_store.clone();
993        let fence = self.fence.clone();
994        let placement_ref = self.placement_ref.clone();
995        let lease = self.lease.clone();
996        if let Ok(runtime) = tokio::runtime::Handle::try_current() {
997            runtime.spawn(async move {
998                let current = lease.lock().await.clone();
999                if actor_store
1000                    .inspect_actor(&current.actor_id)
1001                    .await
1002                    .ok()
1003                    .and_then(|entry| entry.activation)
1004                    .is_some_and(|activation| {
1005                        activation.fence() == fence
1006                            && activation.placement_ref.as_ref() == Some(&placement_ref)
1007                    })
1008                {
1009                    let _ = registry.release_slot(&current).await;
1010                }
1011            });
1012        }
1013    }
1014}
1015
1016/// Host-only receipt repair uses the same immutable boot configuration as the
1017/// actor runner. It never infers a broker route from Child display metadata or
1018/// a Worker claim. A role whose fixed remote route is ambiguous or incomplete
1019/// stays blocked until an operator restores a provable route.
1020pub struct BrokerTerminalReceiptReconciler {
1021    store: Arc<bamboo_storage::SessionStoreV2>,
1022    bus: Option<bamboo_subagent::BusEndpoint>,
1023    remote: HashMap<String, Option<ReceiptRemoteRoute>>,
1024    ticket_service: Option<Arc<bamboo_tickets::TicketService>>,
1025}
1026
1027struct ReceiptRemoteRoute {
1028    endpoint: String,
1029    token: String,
1030    ca_cert_file: PathBuf,
1031    parent: bamboo_subagent::AgentRef,
1032}
1033
1034#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
1035pub struct BrokerReceiptRepairReport {
1036    pub candidates: usize,
1037    pub repaired: usize,
1038    pub blocked: usize,
1039}
1040
1041impl BrokerTerminalReceiptReconciler {
1042    pub fn new(store: Arc<bamboo_storage::SessionStoreV2>, config: &bamboo_config::Config) -> Self {
1043        let subagents = config.subagents();
1044        let mut counts = HashMap::<&str, usize>::new();
1045        for placement in &subagents.remote_placements {
1046            *counts.entry(placement.role.as_str()).or_default() += 1;
1047        }
1048        let mut remote = HashMap::new();
1049        for placement in &subagents.remote_placements {
1050            let route = (counts.get(placement.role.as_str()) == Some(&1))
1051                .then(|| receipt_remote_route(placement))
1052                .flatten();
1053            remote.insert(placement.role.clone(), route);
1054        }
1055        Self {
1056            store,
1057            ticket_service: None,
1058            bus: subagents.broker.clone().and_then(|broker| {
1059                (!broker.endpoint.trim().is_empty() && !broker.token.is_empty()).then_some(
1060                    bamboo_subagent::BusEndpoint {
1061                        endpoint: broker.endpoint,
1062                        token: broker.token,
1063                    },
1064                )
1065            }),
1066            remote,
1067        }
1068    }
1069
1070    pub fn with_ticket_service(
1071        mut self,
1072        service: Option<Arc<bamboo_tickets::TicketService>>,
1073    ) -> Self {
1074        self.ticket_service = service;
1075        self
1076    }
1077
1078    /// One physical-tree scan. Every Child is independent; a failed route,
1079    /// broker, or ACK retains its Host receipt and never blocks another Child.
1080    pub async fn reconcile_once(&self) -> BrokerReceiptRepairReport {
1081        let candidates = match self
1082            .store
1083            .discover_unconfirmed_broker_terminal_children()
1084            .await
1085        {
1086            Ok(candidates) => candidates,
1087            Err(error) => {
1088                tracing::warn!(%error, "broker terminal receipt scan blocked; no ACK attempted");
1089                return BrokerReceiptRepairReport {
1090                    blocked: 1,
1091                    ..BrokerReceiptRepairReport::default()
1092                };
1093            }
1094        };
1095        let mut report = BrokerReceiptRepairReport {
1096            candidates: candidates.len(),
1097            ..BrokerReceiptRepairReport::default()
1098        };
1099        for child in candidates {
1100            let result =
1101                tokio::time::timeout(Duration::from_secs(15), self.reconcile_child(&child)).await;
1102            match result {
1103                Ok(Ok(())) => report.repaired += 1,
1104                Ok(Err(reason)) => {
1105                    report.blocked += 1;
1106                    tracing::warn!(child_id = %child.id, reason, "broker terminal receipt retained for retry");
1107                }
1108                Err(_) => {
1109                    report.blocked += 1;
1110                    tracing::warn!(child_id = %child.id, "broker terminal receipt repair timed out; retained for retry");
1111                }
1112            }
1113        }
1114        report
1115    }
1116
1117    async fn reconcile_child(&self, child: &Session) -> Result<(), &'static str> {
1118        if child.metadata.get("runtime.kind").map(String::as_str) != Some("external")
1119            || child.metadata.get("external.protocol").map(String::as_str) != Some("actor")
1120            || child.metadata.get("external.agent_id").map(String::as_str)
1121                != Some(super::config::LOCAL_ACTOR_AGENT_ID)
1122        {
1123            return Err("Child has no trusted built-in actor recovery route");
1124        }
1125        let current = self
1126            .store
1127            .load_session(&child.id)
1128            .await
1129            .map_err(|_| "canonical Child reload failed")?
1130            .ok_or("canonical Child disappeared")?;
1131        if current.kind != bamboo_domain::SessionKind::Child
1132            || current.id != child.id
1133            || current.created_at != child.created_at
1134            || current.parent_session_id != child.parent_session_id
1135            || current.root_session_id != child.root_session_id
1136            || current.project_id_meta() != child.project_id_meta()
1137            || current.metadata.get("runtime.kind").map(String::as_str) != Some("external")
1138            || current
1139                .metadata
1140                .get("external.protocol")
1141                .map(String::as_str)
1142                != Some("actor")
1143            || current
1144                .metadata
1145                .get("external.agent_id")
1146                .map(String::as_str)
1147                != Some(super::config::LOCAL_ACTOR_AGENT_ID)
1148            || bamboo_domain::ActorSession::from_session(&current).is_err()
1149        {
1150            return Err("canonical Child birth, Root, or Project changed");
1151        }
1152        let root = self
1153            .store
1154            .load_session(&child.root_session_id)
1155            .await
1156            .map_err(|_| "canonical Root reload failed")?
1157            .ok_or("canonical Root disappeared")?;
1158        if root.kind != bamboo_domain::SessionKind::Root
1159            || root.id != child.root_session_id
1160            || root.root_session_id != child.root_session_id
1161            || root.project_id_meta() != child.project_id_meta()
1162        {
1163            return Err("canonical Root identity changed");
1164        }
1165        let parent_id = child
1166            .parent_session_id
1167            .as_deref()
1168            .ok_or("Child parent identity missing")?;
1169        let parent = self
1170            .store
1171            .load_session(parent_id)
1172            .await
1173            .map_err(|_| "canonical parent reload failed")?
1174            .ok_or("canonical parent disappeared")?;
1175        if parent.id != parent_id
1176            || !matches!(
1177                parent.kind,
1178                bamboo_domain::SessionKind::Root | bamboo_domain::SessionKind::Child
1179            )
1180            || parent.root_session_id != child.root_session_id
1181            || parent.project_id_meta() != child.project_id_meta()
1182            || parent.spawn_depth.checked_add(1) != Some(current.spawn_depth)
1183        {
1184            return Err("canonical parent lineage changed");
1185        }
1186
1187        // Validate the Host checkpoint before opening a recovery connection.
1188        // This rejects an uncommitted terminal, stale transcript, or changed
1189        // Child identity before touching the broker.
1190        let receipts = self
1191            .store
1192            .recover_broker_terminal_receipts(&current)
1193            .await
1194            .map_err(|_| "Host broker terminal checkpoint unverified")?;
1195        if receipts.is_empty() {
1196            return Ok(());
1197        }
1198        let role = current
1199            .metadata
1200            .get("subagent_type")
1201            .map(String::as_str)
1202            .unwrap_or("worker");
1203        for receipt in receipts {
1204            publish_recovered_completion_source(&self.store, &receipt)
1205                .await
1206                .map_err(|_| "committed Child completion source remains pending")?;
1207            if current
1208                .metadata
1209                .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
1210            {
1211                let service = self
1212                    .ticket_service
1213                    .as_ref()
1214                    .ok_or("Ticket authority unavailable; no result ACK")?;
1215                crate::ticket_runtime::checkpoint_ticket_result(
1216                    service,
1217                    self.store.as_ref(),
1218                    &current,
1219                    &receipt.activation_run_id,
1220                )
1221                .await
1222                .map_err(|_| "Ticket result not checkpointed; broker receipt retained")?;
1223            }
1224            let local_mailbox = format!("p-{}", child.id);
1225            // The committed receipt records the route used by this run. A
1226            // later role configuration change must not redirect its ACK.
1227            let mut link = if receipt.parent_mailbox == local_mailbox {
1228                let bus = self
1229                    .bus
1230                    .as_ref()
1231                    .ok_or("local broker receipt bus unavailable")?;
1232                bamboo_broker::BrokerChildLink::connect_receipt_recovery(
1233                    &bus.endpoint,
1234                    bamboo_subagent::AgentRef {
1235                        session_id: local_mailbox,
1236                        role: None,
1237                    },
1238                    &bus.token,
1239                )
1240                .await
1241                .map_err(|_| "local broker receipt connection unavailable")?
1242            } else if let Some(remote) = self.remote.get(role) {
1243                let route = remote
1244                    .as_ref()
1245                    .ok_or("fixed remote broker route is unavailable or ambiguous")?;
1246                if receipt.parent_mailbox != route.parent.session_id {
1247                    return Err("fixed remote parent mailbox changed since Host checkpoint");
1248                }
1249                let tls = bamboo_broker::client_config_trusting_cert(&route.ca_cert_file)
1250                    .map_err(|_| "fixed remote broker TLS pin unavailable")?;
1251                bamboo_broker::BrokerChildLink::connect_receipt_recovery_with_tls(
1252                    &route.endpoint,
1253                    route.parent.clone(),
1254                    &route.token,
1255                    tls,
1256                )
1257                .await
1258                .map_err(|_| "fixed remote broker receipt connection unavailable")?
1259            } else {
1260                return Err("committed broker receipt route is unavailable");
1261            };
1262            if link.parent_mailbox() != receipt.parent_mailbox {
1263                return Err("broker receipt link mailbox differs from Host checkpoint");
1264            }
1265            bamboo_subagent::ChildLink::acknowledge_recovered_durable_frames(
1266                &mut link,
1267                &receipt.broker_identity,
1268                &receipt.message_ids,
1269            )
1270            .await
1271            .map_err(|_| "broker identity changed or exact ACK unconfirmed")?;
1272            self.store
1273                .clear_acknowledged_broker_terminal_receipt(
1274                    &receipt.session_id,
1275                    receipt.created_at,
1276                    &receipt.activation_run_id,
1277                    &receipt.parent_mailbox,
1278                )
1279                .await
1280                .map_err(|_| "broker ACK succeeded but Host receipt cleanup failed")?;
1281        }
1282        Ok(())
1283    }
1284}
1285
1286/// Finish only the interrupted publication of an already committed Host
1287/// receipt. The pending source stays in the existing protected source record;
1288/// no provider, event admission or transcript reconstruction is involved.
1289async fn publish_recovered_completion_source(
1290    store: &Arc<bamboo_storage::SessionStoreV2>,
1291    receipt: &bamboo_storage::v2::BrokerTerminalReceipt,
1292) -> Result<(), String> {
1293    let expected = store
1294        .load_session(&receipt.session_id)
1295        .await
1296        .map_err(|error| format!("Child completion recovery reload failed: {error}"))?
1297        .ok_or("Child completion recovery snapshot missing")?;
1298    if expected.id != receipt.session_id
1299        || expected.created_at != receipt.created_at
1300        || expected.parent_session_id.as_deref() != Some(receipt.parent_session_id.as_str())
1301        || expected.root_session_id != receipt.root_session_id
1302        || expected.project_id_meta() != receipt.project_id
1303    {
1304        return Err("Child completion recovery identity changed".into());
1305    }
1306    let mut candidate = expected.clone();
1307    if candidate.last_run_status().as_deref() == Some("running") {
1308        candidate.set_last_run_status(receipt.terminal_status.clone());
1309        if let Some(error) = &receipt.terminal_error {
1310            candidate.set_last_run_error(error.clone());
1311        } else {
1312            candidate.clear_last_run_error();
1313        }
1314    }
1315    if crate::execution::ChildCompletionSource::publish_after_durable_delivery(
1316        &mut candidate,
1317        &receipt.activation_run_id,
1318    )? {
1319        let source = crate::execution::ChildCompletionSource::from_committed_session(&candidate)
1320            .ok_or("Child completion recovery source invalid")?;
1321        let published = candidate
1322            .metadata
1323            .get("runtime.child_completion_source_v1")
1324            .ok_or("Child completion recovery source missing")?;
1325        // CAS reloads under the physical Session lock and mutates only the
1326        // source/status fields. Another reconciler or successor cannot be
1327        // overwritten by this earlier snapshot.
1328        let saved = store
1329            .publish_broker_terminal_source(receipt, &expected, published)
1330            .await
1331            .map_err(|error| format!("Child completion recovery publication failed: {error}"))?;
1332        if crate::execution::ChildCompletionSource::from_committed_session(&saved).as_ref()
1333            != Some(&source)
1334        {
1335            return Err("Child completion recovery source changed".into());
1336        }
1337    }
1338    Ok(())
1339}
1340
1341fn receipt_remote_route(
1342    placement: &bamboo_config::RemoteActorPlacement,
1343) -> Option<ReceiptRemoteRoute> {
1344    let peer = placement.broker_peer.as_ref().filter(|peer| peer.valid())?;
1345    let endpoint = url::Url::parse(&placement.endpoint).ok()?;
1346    if placement.endpoint.len() > 2048
1347        || endpoint.scheme() != "wss"
1348        || endpoint.host_str().is_none()
1349        || !endpoint.username().is_empty()
1350        || endpoint.password().is_some()
1351        || endpoint.query().is_some()
1352        || endpoint.fragment().is_some()
1353    {
1354        return None;
1355    }
1356    let token_env = placement
1357        .token_env
1358        .as_deref()
1359        .filter(|name| !name.is_empty() && name.len() <= 256)?;
1360    let token = std::env::var(token_env).ok()?;
1361    if !(32..=256).contains(&token.len()) || !token.bytes().all(|byte| byte.is_ascii_graphic()) {
1362        return None;
1363    }
1364    let ca_cert_file = PathBuf::from(placement.ca_cert_file.as_deref()?);
1365    bamboo_broker::client_config_trusting_cert(&ca_cert_file).ok()?;
1366    Some(ReceiptRemoteRoute {
1367        endpoint: placement.endpoint.clone(),
1368        token,
1369        ca_cert_file,
1370        parent: bamboo_subagent::AgentRef {
1371            session_id: peer.parent_mailbox.clone(),
1372            role: peer.parent_role.clone(),
1373        },
1374    })
1375}
1376
1377/// How `execute_external_child` should obtain its worker connection, decided
1378/// once from `spec.placement`. Splits the divergent acquire/connect + retire
1379/// logic three ways while the shared middle (Run dispatch, live registration,
1380/// drive, close) stays identical. Remote uses a scoped broker peer route.
1381enum PlacementKind {
1382    Local,
1383    Remote,
1384    Schedulable,
1385}
1386
1387/// Ticket execution needs the local process-owned stop checkpoint before ACK.
1388fn ensure_ticket_placement_is_enforceable(
1389    session: &Session,
1390    spec: &ProvisionSpec,
1391) -> Result<(), AgentError> {
1392    let ticket = session
1393        .metadata
1394        .contains_key(crate::ticket_runtime::TICKET_DISPATCH_KEY)
1395        || session
1396            .metadata
1397            .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY);
1398    if ticket && !matches!(spec.placement, Placement::Local) {
1399        return Err(AgentError::LLM(
1400            "Ticket execution requires local placement and owned stop proof".into(),
1401        ));
1402    }
1403    Ok(())
1404}
1405
1406/// A read-only activation needs an executor and worker whose tool surface and
1407/// no-shell checker enforce the typed capability. Codex's read-only sandbox
1408/// prevents writes but still permits command execution, so neither Codex mode
1409/// can currently satisfy the no-shell contract. Local Bamboo/Claude workers are
1410/// fingerprinted by their baked fields, while remote/schedulable placements
1411/// connect to an already-provisioned resident and [`RunSpec`] currently carries
1412/// no equivalent per-activation authority. Fail closed before provisioning or
1413/// dispatch instead of treating either posture as read-only enforcement.
1414fn ensure_read_only_activation_is_enforceable(spec: &ProvisionSpec) -> Result<(), AgentError> {
1415    if !spec.capabilities.read_only_enforced() {
1416        return Ok(());
1417    }
1418
1419    if matches!(spec.executor, ExecutorSpec::Codex { .. }) {
1420        return Err(AgentError::LLM(format!(
1421            "read-only actor role '{}' cannot use the Codex executor because its read-only sandbox still permits command execution; use bamboo_runtime or claude_code",
1422            spec.identity.role
1423        )));
1424    }
1425
1426    if !matches!(spec.placement, Placement::Local) {
1427        return Err(AgentError::LLM(format!(
1428            "read-only actor role '{}' requires local placement; remote and schedulable resident workers cannot verify this activation's read-only tool boundary",
1429            spec.identity.role
1430        )));
1431    }
1432
1433    Ok(())
1434}
1435
1436/// Spawns and drives a child session as an independent actor: a `bamboo-subagent` worker process.
1437pub struct ActorChildRunner {
1438    approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
1439    permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
1440    agent_id: String,
1441    worker_bin: PathBuf,
1442    worker_args: Vec<String>,
1443    builtin_required_context_route: bool,
1444    native_tool_ceiling: Option<Arc<dyn super::runtime::NativeToolCeilingSource>>,
1445    fabric_dir: PathBuf,
1446    executor: ExecutorSpec,
1447    /// Per-provider credentials snapshotted from the parent config at build
1448    /// time; the spec carries only the ONE the child's provider needs. Server
1449    /// runtimes install `live_provider_config` below so a provider hot reload
1450    /// cannot strand this fallback snapshot for the rest of the process.
1451    credentials: Vec<ScopedCredential>,
1452    /// Parent's default provider (used when the child has no explicit one).
1453    default_provider: String,
1454    /// Startup fallback for embeddings without a live Host configuration.
1455    max_spawn_depth: u32,
1456    /// Live server configuration used to resolve provider credentials at child
1457    /// activation time, including the depth cap for new worker provisioning.
1458    /// Executor/placement policy remains the immutable runner configuration.
1459    live_provider_config: Option<Arc<tokio::sync::RwLock<bamboo_llm::Config>>>,
1460    /// The mailbox bus to run local children over (the unified transport). Local
1461    /// sub-agents require it; `None` only when no broker could be embedded.
1462    bus: Option<bamboo_subagent::BusEndpoint>,
1463    /// Backpressure: bounds the number of concurrently *running* actors; further
1464    /// runs wait for a slot instead of exploding the process table. (Idle pooled
1465    /// workers do not hold a slot.)
1466    concurrency: std::sync::Arc<tokio::sync::Semaphore>,
1467    /// Warm-worker pool keyed by a reuse fingerprint
1468    /// (role/provider/model/workspace/disabled-tools/baked-caps). A finished run
1469    /// parks its bus worker here so the next interchangeable child reuses it
1470    /// (delivers its `Run` to the same mailbox) instead of spawning a fresh
1471    /// process — collapsing N sibling sub-agents onto a few warm workers.
1472    pool: Arc<tokio::sync::Mutex<WorkerPool>>,
1473    max_idle_per_key: usize,
1474    max_idle_total: usize,
1475    pool_reaper_started: AtomicBool,
1476    /// Monotonic fencing token for concrete worker attempts in this runtime.
1477    /// `activation_run_id` fences across runtime restarts; this epoch also
1478    /// fences a late first worker when an activation is retried in-place.
1479    next_execution_epoch: AtomicU64,
1480    /// Host-side decision for a child's gated-tool approval request (Phase 2).
1481    /// `None` ⇒ fail-closed DENY (the safe default). A wired decider (policy or
1482    /// human-routing bridge) returns approve/deny over the actor WS.
1483    approval_decider: Option<Arc<dyn ChildApprovalDecider>>,
1484    /// Off-loop parent-agent reviewer for forced-ask requests. The root server
1485    /// wires a session-aware reviewer; nested workers wire their owning model
1486    /// reviewer directly into the per-run runner.
1487    approval_reviewer: Option<Arc<dyn ChildApprovalReviewer>>,
1488    /// Per-run escalation host bridge for non-bypass child-approval routing (#68;
1489    /// Phase 6, Part B). The owning worker's `run()` installs its OWN host bridge
1490    /// here via `set_escalation_bridge`; `execute_external_child` CAPTURES it at
1491    /// grandchild-spawn time and hands the owned value to `drive()`, which uses it
1492    /// to RE-PROXY a child's approval request UP to the parent run — chaining up
1493    /// every level until a bypass level (model-review) or the top orchestrator
1494    /// (human) decides, then relaying the reply back down. Was a process-global
1495    /// slot; now per-runner so a fire-and-forget grandchild that OUTLIVES the run
1496    /// that spawned it keeps that run's bridge for its whole lifetime instead of
1497    /// reading a stale/overwritten global at approval time (→ fail-closed deny).
1498    escalation_bridge: Arc<std::sync::Mutex<Option<bamboo_subagent::executor::HostBridge>>>,
1499    /// Roles pinned to a REMOTE resident worker (#193), keyed by sub-agent role
1500    /// (the child's `subagent_type`). A role present here routes through the
1501    /// dedicated remote branch in `execute_external_child` (Bearer-authenticated
1502    /// `wss://` connect, no spawn, no pool, no kill) instead of the local
1503    /// subprocess + warm-pool path. Empty (the default) = all-local behavior.
1504    remote_placements: HashMap<String, ResolvedRemotePlacement>,
1505    /// Fixed parent mailbox subscription has exactly one in-process owner.
1506    strict_remote_subscription: tokio::sync::Mutex<()>,
1507    /// Roles routed to a REGISTRY-SCHEDULED worker (#181, P2b), keyed by sub-agent
1508    /// role. A role present here (AND not already in `remote_placements`, which
1509    /// wins) routes through the dedicated SCHEDULABLE branch in
1510    /// `execute_external_child`: query the registry for live workers in the pool,
1511    /// pick one (round-robin), connect over `wss://` — no spawn, no pool, no kill,
1512    /// and NO local-subprocess fallback (no live worker ⇒ a clear error). Empty
1513    /// (the default) = all-local behavior.
1514    schedulable_placements: HashMap<String, ResolvedSchedulablePlacement>,
1515    /// Per-pool round-robin cursor for schedulable scheduling (#181, P2b). Bumped
1516    /// once per pick so successive sibling spawns SPREAD across a pool's live
1517    /// workers instead of all landing on the first candidate. Best-effort spread,
1518    /// not a load balancer — the registry's live set can change between picks.
1519    /// Optional server authority used only by `Codex` in `bamboo` auth mode.
1520    codex_run_tokens: Option<Arc<dyn CodexRunTokenAuthority>>,
1521    /// Canonical logical-session inbox resources, late-bound by each owning
1522    /// runtime. Kept per runner/runtime; never process-global.
1523    session_inbox_runtime: Arc<std::sync::Mutex<Option<SessionInboxRuntimeBinding>>>,
1524    actor_directory_store: std::sync::Mutex<Option<Arc<bamboo_storage::SessionStoreV2>>>,
1525    canonical_subagent_tool: std::sync::Mutex<Option<Arc<dyn bamboo_agent_core::tools::Tool>>>,
1526    ticket_service: std::sync::Mutex<Option<Arc<bamboo_tickets::TicketService>>>,
1527    actor_event_observer: std::sync::Mutex<Option<Arc<dyn ActorEventObserver>>>,
1528    /// A processed broker terminal still needs the Host's final Child Session
1529    /// save before its exact Event/Outcome mailbox receipts can be ACKed.
1530    pending_durable_links: tokio::sync::Mutex<
1531        HashMap<(String, chrono::DateTime<chrono::Utc>, String), PendingDurableChildLink>,
1532    >,
1533}
1534
1535struct PendingDurableChildLink {
1536    link: Box<dyn bamboo_subagent::ChildLink>,
1537    /// Captured from this Host frame pump after canonical nested-wait checks.
1538    /// The map key binds it to the exact Child birth and activation Run.
1539    host_nested_wait_handoff: bool,
1540    // Never reconstructed from Worker JSON or ordinary Session metadata.
1541    strict_history: Option<StrictTerminalHistory>,
1542    strict_history_required: bool,
1543    strict_history_committed: bool,
1544}
1545
1546#[derive(Clone)]
1547struct StrictTerminalHistory {
1548    proof: bamboo_storage::v2::HostTerminalCompleteness,
1549    watermark: bamboo_subagent::ActorEventWatermark,
1550}
1551
1552fn effective_broker_terminal_status(
1553    raw: TerminalStatus,
1554    host_nested_wait_handoff: bool,
1555) -> Result<&'static str, String> {
1556    if host_nested_wait_handoff {
1557        if raw != TerminalStatus::Completed {
1558            return Err("nested wait cannot project a failed broker terminal".into());
1559        }
1560        Ok("suspended")
1561    } else {
1562        Ok(broker_terminal_status_label(raw))
1563    }
1564}
1565
1566fn broker_terminal_status_label(status: TerminalStatus) -> &'static str {
1567    match status {
1568        TerminalStatus::Completed => "completed",
1569        TerminalStatus::Suspended => "suspended",
1570        TerminalStatus::Error => "error",
1571        TerminalStatus::Cancelled => "cancelled",
1572    }
1573}
1574
1575/// Decides how the host answers a child worker's gated-tool approval request
1576/// (Phase 2: child → parent approval delegation). Async so an implementation
1577/// can consult a policy. With no decider wired the host replies with a
1578/// fail-closed DENY.
1579///
1580/// NOTE: `decide` is awaited inside the per-child frame pump, so an
1581/// implementation must resolve promptly (e.g. a policy lookup). Model-based
1582/// review belongs in [`ChildApprovalReviewer`], which runs off-loop and returns
1583/// through the live steering channel without stalling the frame pump.
1584#[async_trait]
1585pub trait ChildApprovalDecider: Send + Sync {
1586    /// Decide whether `child_session_id` may perform the gated action described
1587    /// by `request` (`{tool_name, permission, resource}`).
1588    async fn decide(&self, child_session_id: &str, request: &serde_json::Value) -> bool;
1589}
1590
1591/// Resolve a child approval request to approve/deny. Fail-closed (DENY) when no
1592/// decider is wired — the single, testable seam for the host-side decision.
1593async fn decide_child_approval(
1594    decider: Option<&Arc<dyn ChildApprovalDecider>>,
1595    child_session_id: &str,
1596    request: &serde_json::Value,
1597) -> bool {
1598    match decider {
1599        Some(decider) => decider.decide(child_session_id, request).await,
1600        None => false,
1601    }
1602}
1603
1604/// How long a chained parent-agent review may take before the child's gated
1605/// tool fails closed (DENY). Bounds an unanswered request so it cannot hang the
1606/// worker indefinitely.
1607const CHILD_APPROVAL_TIMEOUT: Duration = Duration::from_secs(300);
1608
1609/// Off-loop reviewer for a child's gated-tool approval request (Phase 6, Part B).
1610///
1611/// Installed (process-global) by a BYPASSED self-orchestrating worker so its
1612/// children's forced-ask (dangerous) gated actions — which still raise
1613/// `ConfirmationRequired` even under bypass — get an LLM reasonableness check
1614/// rather than a blind pass. `review` is an LLM call: `drive()` invokes it in a
1615/// SPAWNED task (NEVER in the frame pump) and delivers the verdict async via the
1616/// live channel, so the agent loop is never blocked.
1617/// Process-local Host scope. Durable data can compare its stamp but cannot
1618/// deserialize a new live authority from it.
1619#[derive(Clone)]
1620pub struct ChildApprovalScope {
1621    stamp: serde_json::Value,
1622    deadline: chrono::DateTime<chrono::Utc>,
1623    parent: String,
1624    child: String,
1625    run: String,
1626    epoch: u64,
1627    current_epoch: Arc<AtomicU64>,
1628    cancel: CancellationToken,
1629    router: Arc<crate::SessionActivationRouter>,
1630    admission: Arc<AtomicUsize>,
1631}
1632
1633pub struct ChildApprovalAdmission(Arc<AtomicUsize>);
1634impl Drop for ChildApprovalAdmission {
1635    fn drop(&mut self) {
1636        self.0.store(2, Ordering::Release);
1637    }
1638}
1639
1640impl ChildApprovalScope {
1641    /// Called by the actual Host frame pump, never from a wire payload.
1642    pub fn new(
1643        parent: &str,
1644        child: &str,
1645        transport: (u32, &str, u64, &str, chrono::DateTime<chrono::Utc>),
1646        router: Arc<crate::SessionActivationRouter>,
1647        cancel: CancellationToken,
1648        current_epoch: Arc<AtomicU64>,
1649    ) -> Self {
1650        let (attempt, run, epoch, reply, deadline) = transport;
1651        let deadline = deadline.min(chrono::Utc::now() + chrono::Duration::seconds(240));
1652        Self {
1653            stamp: serde_json::json!({"host_scope": uuid::Uuid::new_v4().to_string(),
1654                "attempt": attempt, "run": run, "epoch": epoch, "reply": reply,
1655                "deadline": deadline}),
1656            deadline,
1657            parent: parent.into(),
1658            child: child.into(),
1659            run: run.into(),
1660            epoch,
1661            current_epoch,
1662            cancel,
1663            router,
1664            admission: Arc::new(AtomicUsize::new(0)),
1665        }
1666    }
1667    pub fn stamp(&self) -> &serde_json::Value {
1668        &self.stamp
1669    }
1670    pub fn deadline(&self) -> chrono::DateTime<chrono::Utc> {
1671        self.deadline
1672    }
1673    pub fn first_admission(&self) -> Option<ChildApprovalAdmission> {
1674        self.admission
1675            .compare_exchange(0, 1, Ordering::AcqRel, Ordering::Acquire)
1676            .ok()
1677            .map(|_| ChildApprovalAdmission(self.admission.clone()))
1678    }
1679    pub fn is_admitting(&self) -> bool {
1680        self.admission.load(Ordering::Acquire) == 1
1681    }
1682    pub async fn is_current(&self, parent: &str, child: &str) -> bool {
1683        self.parent == parent
1684            && self.child == child
1685            && !self.cancel.is_cancelled()
1686            && self.current_epoch.load(Ordering::Acquire) == self.epoch
1687            && self.router.owns_run(child, &self.run).await
1688    }
1689}
1690
1691#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1692pub enum ChildApprovalReview {
1693    Reply(bool),
1694    NoReply,
1695}
1696
1697#[async_trait]
1698pub trait ChildApprovalReviewer: Send + Sync {
1699    /// Judge whether the gated action `request` (`{tool_name, permission,
1700    /// resource}`) is reasonable for `child_session_id`'s task. `true` = approve.
1701    async fn review(
1702        &self,
1703        parent_session_id: &str,
1704        child_session_id: &str,
1705        request: &serde_json::Value,
1706    ) -> bool;
1707
1708    async fn review_scoped(
1709        &self,
1710        parent: &str,
1711        child: &str,
1712        request: &serde_json::Value,
1713        _scope: &ChildApprovalScope,
1714    ) -> ChildApprovalReview {
1715        ChildApprovalReview::Reply(self.review(parent, child, request).await)
1716    }
1717}
1718
1719fn child_approval_reviewer_slot() -> &'static std::sync::OnceLock<Arc<dyn ChildApprovalReviewer>> {
1720    static SLOT: std::sync::OnceLock<Arc<dyn ChildApprovalReviewer>> = std::sync::OnceLock::new();
1721    &SLOT
1722}
1723
1724/// Install the process-global child-approval reviewer (idempotent; first wins).
1725pub fn set_child_approval_reviewer(reviewer: Arc<dyn ChildApprovalReviewer>) {
1726    let _ = child_approval_reviewer_slot().set(reviewer);
1727}
1728
1729/// The process-global child-approval reviewer, if installed.
1730pub fn child_approval_reviewer() -> Option<Arc<dyn ChildApprovalReviewer>> {
1731    child_approval_reviewer_slot().get().cloned()
1732}
1733
1734impl ActorChildRunner {
1735    #[allow(clippy::too_many_arguments)]
1736    pub fn new(
1737        agent_id: String,
1738        worker_bin: PathBuf,
1739        worker_args: Vec<String>,
1740        fabric_dir: PathBuf,
1741        executor: ExecutorSpec,
1742        credentials: Vec<ScopedCredential>,
1743        default_provider: String,
1744        max_concurrent: usize,
1745    ) -> Self {
1746        Self {
1747            approval_registry: None,
1748            permission_config: None,
1749            agent_id,
1750            worker_bin,
1751            worker_args,
1752            builtin_required_context_route: false,
1753            fabric_dir,
1754            executor,
1755            credentials,
1756            default_provider,
1757            max_spawn_depth: MAX_SPAWN_DEPTH,
1758            live_provider_config: None,
1759            native_tool_ceiling: None,
1760            bus: None,
1761            concurrency: std::sync::Arc::new(tokio::sync::Semaphore::new(max_concurrent.max(1))),
1762            pool: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
1763            max_idle_per_key: DEFAULT_MAX_IDLE_PER_KEY,
1764            max_idle_total: DEFAULT_MAX_IDLE_TOTAL,
1765            pool_reaper_started: AtomicBool::new(false),
1766            next_execution_epoch: AtomicU64::new(0),
1767            approval_decider: None,
1768            approval_reviewer: None,
1769            escalation_bridge: Arc::new(std::sync::Mutex::new(None)),
1770            remote_placements: HashMap::new(),
1771            strict_remote_subscription: tokio::sync::Mutex::new(()),
1772            schedulable_placements: HashMap::new(),
1773            codex_run_tokens: None,
1774            session_inbox_runtime: Arc::new(std::sync::Mutex::new(None)),
1775            actor_directory_store: std::sync::Mutex::new(None),
1776            canonical_subagent_tool: std::sync::Mutex::new(None),
1777            ticket_service: std::sync::Mutex::new(None),
1778            actor_event_observer: std::sync::Mutex::new(None),
1779            pending_durable_links: tokio::sync::Mutex::new(HashMap::new()),
1780        }
1781    }
1782
1783    /// Bind the AppState-owned live configuration. Child activations read this
1784    /// after a successful provider reload, so newly added/rotated provider
1785    /// credentials take effect without restarting Bamboo.
1786    /// Only the built-in factory sets this from its immutable launch config.
1787    pub(crate) fn with_builtin_required_context_route(mut self, supported: bool) -> Self {
1788        self.builtin_required_context_route = supported;
1789        self
1790    }
1791
1792    pub(crate) fn with_native_tool_ceiling_source(
1793        mut self,
1794        source: Option<Arc<dyn super::runtime::NativeToolCeilingSource>>,
1795    ) -> Self {
1796        self.native_tool_ceiling = source;
1797        self
1798    }
1799
1800    async fn require_initial_input_release_worker(&self) -> crate::runtime::runner::Result<()> {
1801        bamboo_subagent::fleet::require_worker_capability(
1802            &self.worker_bin,
1803            &self.worker_args,
1804            bamboo_subagent::provision::INITIAL_INPUT_RELEASE_WORKER_CAPABILITY,
1805        )
1806        .await
1807        .map_err(|_| AgentError::LLM("owned initial release worker capability unconfirmed".into()))
1808    }
1809
1810    pub fn with_live_provider_config(
1811        mut self,
1812        config: Arc<tokio::sync::RwLock<bamboo_llm::Config>>,
1813    ) -> Self {
1814        self.live_provider_config = Some(config);
1815        self
1816    }
1817
1818    pub fn with_max_spawn_depth(mut self, max_spawn_depth: u32) -> Self {
1819        self.max_spawn_depth = max_spawn_depth;
1820        self
1821    }
1822
1823    pub fn with_approval_registry(
1824        mut self,
1825        registry: super::approval_registry::SharedApprovalRegistry,
1826    ) -> Self {
1827        self.approval_registry = Some(registry);
1828        self
1829    }
1830
1831    pub fn with_permission_config(
1832        mut self,
1833        config: Arc<bamboo_tools::permission::PermissionConfig>,
1834    ) -> Self {
1835        self.permission_config = Some(config);
1836        self
1837    }
1838
1839    /// Run children over the mailbox bus (the unified actor+mailbox transport).
1840    /// When set, local children dial this bus and are driven by mailbox id; when
1841    /// unset they use the legacy direct-WS path. The server passes its in-process
1842    /// broker here (`subagents.broker`); tests without a broker leave it unset.
1843    pub fn with_bus(mut self, bus: Option<bamboo_subagent::BusEndpoint>) -> Self {
1844        self.bus = bus.filter(|b| !b.endpoint.trim().is_empty());
1845        self
1846    }
1847
1848    /// Wire the host-side decider for child gated-tool approval requests
1849    /// (Phase 2). Without this the host fail-closed DENYs every request.
1850    pub fn with_approval_decider(mut self, decider: Arc<dyn ChildApprovalDecider>) -> Self {
1851        self.approval_decider = Some(decider);
1852        self
1853    }
1854
1855    pub fn with_approval_reviewer(mut self, reviewer: Arc<dyn ChildApprovalReviewer>) -> Self {
1856        self.approval_reviewer = Some(reviewer);
1857        self
1858    }
1859
1860    pub fn with_codex_run_tokens(
1861        mut self,
1862        authority: Option<Arc<dyn CodexRunTokenAuthority>>,
1863    ) -> Self {
1864        self.codex_run_tokens = authority;
1865        self
1866    }
1867
1868    /// Pin specific sub-agent roles to remote resident workers (#193). The map
1869    /// is keyed by role (`subagent_type`); a child whose role is present connects
1870    /// over `wss://` to the resolved endpoint instead of spawning a local
1871    /// subprocess. Default (empty) keeps every role on the local path — exactly
1872    /// today's behavior.
1873    pub fn with_remote_placements(
1874        mut self,
1875        placements: HashMap<String, ResolvedRemotePlacement>,
1876    ) -> Self {
1877        self.remote_placements = placements;
1878        self
1879    }
1880
1881    /// Route specific sub-agent roles to a registry-SCHEDULED worker (#181, P2b).
1882    /// The map is keyed by role (`subagent_type`); a child whose role is present
1883    /// (and NOT already pinned by `remote_placements`, which takes precedence) is
1884    /// run on a live worker discovered from the registry instead of a local
1885    /// subprocess. Default (empty) keeps every role on the local path.
1886    pub fn with_schedulable_placements(
1887        mut self,
1888        placements: HashMap<String, ResolvedSchedulablePlacement>,
1889    ) -> Self {
1890        self.schedulable_placements = placements;
1891        self
1892    }
1893
1894    /// Reuse fingerprint: two children are interchangeable on one warm worker iff
1895    /// they share role, provider, model, provider-credential revision,
1896    /// workspace, disabled-tool set, AND every
1897    /// capability the worker BAKES at provision time (`BambooRuntimeExecutor`
1898    /// stamps these once and reuses them across runs): nesting depth, nested-spawn
1899    /// stack, requested/effective permission modes, legacy bypass/auto flags,
1900    /// permission enforcement, and the depth cap. Omitting any
1901    /// of these lets the pool hand a run a worker baked for a DIFFERENT posture —
1902    /// e.g. a depth-1 worker (with its own spawn stack) reused for a depth-4
1903    /// child would re-stamp `spawn_depth=1` and pass the depth-cap check, breaking
1904    /// the recursion bound; or a bypass worker reused for a non-bypass child. So
1905    /// these MUST split the pool bucket. Everything else (assignment, history) is
1906    /// shipped per-run in the `RunSpec` and does not affect the fingerprint.
1907    /// Reuse fingerprint (role/provider/model/workspace/disabled-tools/baked
1908    /// caps): two children with the same fingerprint are interchangeable on one
1909    /// warm worker, so they share a pool bucket. Any axis the worker bakes ONCE
1910    /// at provision time MUST be in here, else a worker baked for one posture
1911    /// gets reused for another (see the `fingerprint_*` tests).
1912    fn fingerprint(spec: &ProvisionSpec) -> String {
1913        let role = spec.identity.role.as_str();
1914        let (provider, model) = spec
1915            .model
1916            .as_ref()
1917            .map(|m| (m.provider.as_str(), m.model.as_str()))
1918            .unwrap_or(("", ""));
1919        let workspace = spec.workspace.as_deref().unwrap_or("");
1920        let mut tools = spec.disabled_tools.clone().unwrap_or_default();
1921        tools.sort();
1922        let caps = &spec.capabilities;
1923        // Provider credentials are baked into a worker at provision time. Hash
1924        // the scoped envelope so a hot-reloaded key/base URL cannot reuse a
1925        // worker provisioned with stale authority. The key itself never enters
1926        // the pool key or logs.
1927        let credential_revision = if spec.secrets.provider_credentials.is_empty() {
1928            "none".to_string()
1929        } else {
1930            let serialized = serde_json::to_vec(&spec.secrets.provider_credentials)
1931                .expect("scoped provider credentials are serializable");
1932            hex::encode(Sha256::digest(serialized))
1933        };
1934        // The worker constructs its executor exactly once. In particular,
1935        // Codex exec and app-server workers are not interchangeable.
1936        let executor = serde_json::to_string(&spec.executor).unwrap_or_default();
1937        format!(
1938            "{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}",
1939            tools.join(","),
1940            spec.identity.depth,
1941            caps.nested_spawn,
1942            caps.permission_requested_mode,
1943            caps.permission_effective_mode,
1944            caps.bypass,
1945            caps.auto_approve_permissions,
1946            caps.enforce_permissions,
1947            caps.max_spawn_depth.unwrap_or(0),
1948            // #73 review (P1): a worker bakes `no_human_review` ONCE from this flag
1949            // at build() and never re-reads it per run, so the pool MUST NOT hand a
1950            // worker baked for one approval posture to a run of the opposite one —
1951            // else a scheduled-root worker reused for an interactive child would
1952            // silently model-review instead of asking the human (and vice-versa,
1953            // reintroducing the 300s-deny). Split the bucket on it.
1954            caps.no_human_approver,
1955            // The read-only no-shell checker is baked once at build(), so a planner
1956            // or Guardian worker must NOT be reused for an ordinary child.
1957            caps.read_only,
1958            // Preserve the legacy Guardian bit as a distinct fingerprint axis
1959            // during rolling upgrades even though both bits enforce read-only.
1960            caps.guardian_read_only,
1961            caps.child_creation_identity,
1962        )
1963    }
1964
1965    /// Start one weakly-owned sweep task for this runner. It does not keep the
1966    /// runner/pool alive, and it never holds the pool lock while killing a child.
1967    fn ensure_pool_reaper(&self) {
1968        if self
1969            .pool_reaper_started
1970            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
1971            .is_err()
1972        {
1973            return;
1974        }
1975        let pool = Arc::downgrade(&self.pool);
1976        tokio::spawn(async move {
1977            let mut interval = tokio::time::interval(POOLED_REAPER_INTERVAL);
1978            interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1979            // Tokio intervals tick immediately once; consume that tick so a new
1980            // runner does not scan an empty pool before its first worker parks.
1981            interval.tick().await;
1982            loop {
1983                interval.tick().await;
1984                let Some(pool) = pool.upgrade() else {
1985                    break;
1986                };
1987                let _ = reap_worker_pool_once(&pool).await;
1988            }
1989        });
1990    }
1991
1992    fn fresh_required_worker_spec(spec: &ProvisionSpec) -> ProvisionSpec {
1993        let mut physical = spec.clone();
1994        // A killed one-shot worker may leave an unACKed Run in its mailbox.
1995        // A fresh subscriber must not replay that Run. Logical birth, storage
1996        // namespace, typed input targets and authority remain on the RunSpec.
1997        physical.identity.child_id = format!("required-worker-{}", uuid::Uuid::new_v4());
1998        physical
1999    }
2000
2001    /// Check out a warm bus worker for `key`, reusing a live parked one if any,
2002    /// else spawning a fresh one that dials the bus. The returned worker is OWNED
2003    /// by the caller for the run's duration (checkout removes it from the pool, so
2004    /// a concurrent sibling gets a different worker or spawns its own — one run per
2005    /// worker at a time, matching the pre-bus pool semantics).
2006    async fn acquire_bus_worker(
2007        &self,
2008        key: &str,
2009        spec: &ProvisionSpec,
2010    ) -> crate::runtime::runner::Result<PooledWorker> {
2011        if spec.capabilities.required_child_context {
2012            let physical = Self::fresh_required_worker_spec(spec);
2013            let worker = spawn_worker_on_bus(&self.worker_bin, &self.worker_args, &physical)
2014                .await
2015                .map_err(|error| {
2016                    AgentError::LLM(format!("required-context worker spawn failed: {error}"))
2017                })?;
2018            let mailbox_id = worker.record.agent_id.clone();
2019            return Ok(PooledWorker {
2020                worker,
2021                mailbox_id,
2022                parked_at: None,
2023            });
2024        }
2025        self.ensure_pool_reaper();
2026        // Drain the bucket, skipping (and reaping) any worker whose process exited
2027        // or crossed its idle deadline while parked. A live, fresh one is handed
2028        // straight out for reuse.
2029        loop {
2030            let candidate = {
2031                let mut pool = self.pool.lock().await;
2032                let candidate = pool.get_mut(key).and_then(|bucket| bucket.pop());
2033                if pool.get(key).is_some_and(Vec::is_empty) {
2034                    pool.remove(key);
2035                }
2036                candidate
2037            };
2038            let Some(mut candidate) = candidate else {
2039                break;
2040            };
2041            let now = Instant::now();
2042            let expired = parked_worker_expired(
2043                candidate.parked_at,
2044                now,
2045                Duration::from_secs(POOLED_IDLE_TIMEOUT_SECS),
2046            );
2047            if candidate.worker.is_alive() && !expired {
2048                candidate.parked_at = None;
2049                return Ok(candidate);
2050            }
2051            tracing::info!(
2052                pool_key = key,
2053                mailbox_id = %candidate.mailbox_id,
2054                worker_pid = ?candidate.worker.pid(),
2055                shutdown_reason = if expired {
2056                    PoolReapReason::IdleTimeout.as_str()
2057                } else {
2058                    PoolReapReason::ProcessExited.as_str()
2059                },
2060                "discarding unusable parked sub-agent worker during checkout"
2061            );
2062            candidate.worker.kill().await;
2063        }
2064
2065        let spawned = spawn_worker_on_bus(&self.worker_bin, &self.worker_args, spec)
2066            .await
2067            .map_err(|e| AgentError::LLM(format!("actor spawn (bus) failed: {e}")))?;
2068        let mailbox_id = spawned.record.agent_id.clone();
2069        Ok(PooledWorker {
2070            worker: spawned,
2071            mailbox_id,
2072            parked_at: None,
2073        })
2074    }
2075
2076    /// Park a warm bus worker for reuse after a clean run; if its bucket is full
2077    /// (or it died), kill it instead. The worker stays dialed-in + subscribed
2078    /// while parked, so a reusing child just delivers a new `Run` to its mailbox.
2079    async fn release_bus_worker(&self, key: &str, mut worker: PooledWorker) {
2080        self.ensure_pool_reaper();
2081        if !worker.worker.is_alive() {
2082            tracing::info!(
2083                pool_key = key,
2084                mailbox_id = %worker.mailbox_id,
2085                worker_pid = ?worker.worker.pid(),
2086                shutdown_reason = "process_exited_before_park",
2087                "discarding sub-agent worker instead of parking"
2088            );
2089            worker.worker.kill().await;
2090            return;
2091        }
2092        worker.parked_at = Some(Instant::now());
2093        let mut pool = self.pool.lock().await;
2094        let key_count = pool.get(key).map_or(0, Vec::len);
2095        let total_count = pool.values().map(Vec::len).sum::<usize>();
2096        let limit_reason =
2097            idle_pool_limit_reason(&pool, key, self.max_idle_per_key, self.max_idle_total);
2098        if let Some(shutdown_reason) = limit_reason {
2099            drop(pool);
2100            tracing::info!(
2101                pool_key = key,
2102                mailbox_id = %worker.mailbox_id,
2103                worker_pid = ?worker.worker.pid(),
2104                idle_workers_for_key = key_count,
2105                idle_workers_total = total_count,
2106                max_idle_per_key = self.max_idle_per_key,
2107                max_idle_total = self.max_idle_total,
2108                shutdown_reason,
2109                "discarding sub-agent worker because the warm pool is full"
2110            );
2111            worker.worker.kill().await;
2112            return;
2113        }
2114        pool.entry(key.to_string()).or_default().push(worker);
2115    }
2116
2117    /// Assemble the parent-resolved provisioning document for this child using
2118    /// one coherent provider snapshot.
2119    fn build_spec_with_provider_config(
2120        &self,
2121        session: &Session,
2122        job: &SpawnJob,
2123        credentials: &[ScopedCredential],
2124        default_provider: &str,
2125        max_spawn_depth: u32,
2126    ) -> ProvisionSpec {
2127        let mut spec = ProvisionSpec::new(
2128            ChildIdentity {
2129                child_id: job.child_session_id.clone(),
2130                parent_id: Some(job.parent_session_id.clone()),
2131                project_key: None,
2132                role: session
2133                    .metadata
2134                    .get("subagent_type")
2135                    .cloned()
2136                    .unwrap_or_else(|| "worker".to_string()),
2137                // The child session already carries the correct depth
2138                // (create_child_action's new_child_of did parent.spawn_depth+1);
2139                // stamp it so the worker can re-establish it on its run session
2140                // and enforce the max-depth cap across the actor boundary.
2141                depth: session.spawn_depth,
2142            },
2143            self.executor.clone(),
2144            self.fabric_dir.to_string_lossy().into_owned(),
2145        );
2146        spec.workspace = session.workspace.clone();
2147        if let ExecutorSpec::Codex {
2148            workspace_owned, ..
2149        } = &mut spec.executor
2150        {
2151            *workspace_owned = Some(
2152                spec.workspace
2153                    .as_deref()
2154                    .is_some_and(workspace_is_bamboo_owned),
2155            );
2156        }
2157        // Unified transport: when a bus is configured, the child dials it (no
2158        // listen socket / file discovery) and the parent drives it by mailbox id.
2159        spec.bus = self.bus.clone();
2160        // Final model: the session's pinned model_ref (create.model / routing already applied),
2161        // falling back to the job's bare model on the parent's default provider.
2162        spec.model = session
2163            .model_ref
2164            .as_ref()
2165            .map(|r| ModelRefSpec {
2166                provider: r.provider.clone(),
2167                model: r.model.clone(),
2168            })
2169            .or_else(|| {
2170                let m = job.model.trim();
2171                (!m.is_empty()).then(|| ModelRefSpec {
2172                    provider: default_provider.to_string(),
2173                    model: m.to_string(),
2174                })
2175            });
2176        spec.disabled_tools = job.disabled_tools.clone();
2177        match &spec.executor {
2178            // Codex auth is independent of the session's normal Bamboo model
2179            // provider. Inherit/API-key/Bamboo modes need no credential-store
2180            // secret at provisioning; custom mode gets exactly its referenced
2181            // key. This prevents an unrelated upstream provider key from
2182            // reaching a Codex worker that only needs a per-run bcx1_ token.
2183            ExecutorSpec::Codex {
2184                auth_mode,
2185                provider_key_ref,
2186                ..
2187            } => {
2188                if auth_mode.as_deref() == Some("custom") {
2189                    if let Some(reference) = provider_key_ref {
2190                        if let Some(credential) = credentials.iter().find(|credential| {
2191                            credential.credential_ref.as_deref() == Some(reference)
2192                        }) {
2193                            spec.secrets.provider_credentials.push(credential.clone());
2194                        } else {
2195                            tracing::warn!(
2196                                "actor child {}: custom Codex credential reference '{}' did not resolve",
2197                                job.child_session_id,
2198                                reference
2199                            );
2200                        }
2201                    } else {
2202                        tracing::warn!(
2203                            "actor child {}: custom Codex executor has no credential reference",
2204                            job.child_session_id
2205                        );
2206                    }
2207                }
2208            }
2209            // Other executors keep the existing least-privilege contract: only
2210            // the credential for the child session's selected provider.
2211            _ => {
2212                let provider = spec
2213                    .model
2214                    .as_ref()
2215                    .map(|model| model.provider.as_str())
2216                    .filter(|provider| !provider.trim().is_empty())
2217                    .unwrap_or(default_provider);
2218                if let Some(credential) = credentials
2219                    .iter()
2220                    .find(|credential| credential.provider == provider)
2221                {
2222                    spec.secrets.provider_credentials.push(credential.clone());
2223                } else {
2224                    tracing::warn!(
2225                        "actor child {}: no credential found for provider '{}'",
2226                        job.child_session_id,
2227                        provider
2228                    );
2229                }
2230            }
2231        }
2232        // Tool exposure is baked at provision time. Every actual nested create
2233        // is also checked against the canonical Host's current depth policy.
2234        spec.capabilities.nested_spawn = session.spawn_depth < max_spawn_depth;
2235        spec.capabilities.max_spawn_depth = Some(max_spawn_depth);
2236        // #69: activate child-approval review. Sub-agents enforce permissions so
2237        // their DANGEROUS actions (the worker uses a HIGH threshold) reach the
2238        // parent for review — escalated to the human, or model-reviewed off-loop
2239        // when the parent is in bypass. The worker installs no checker without
2240        // this, so the whole review chain would otherwise stay dormant.
2241        spec.capabilities.enforce_permissions = true;
2242        // Propagate "bypass permissions" so a self-orchestrating worker knows it
2243        // is a bypassed parent and installs the off-loop model-reviewer for its
2244        // children's forced-ask actions (Phase 6, Part B). The child session
2245        // already carries the inherited flag (create_child_action seeds it).
2246        let requested_permission_mode = session
2247            .agent_runtime_state
2248            .as_ref()
2249            .map(|state| state.effective_permission_mode())
2250            .unwrap_or_default();
2251        let configured_permission_mode = self
2252            .permission_config
2253            .as_ref()
2254            .map(|config| config.mode())
2255            .unwrap_or_default();
2256        // #73: propagate "no interactive human approver" (headless / scheduled /
2257        // deployed root, inherited by the child session). When set, the worker's
2258        // per-run approval proxy model-reviews a gated action locally instead of
2259        // escalating to a human who will never answer (which would 300s-deny).
2260        spec.capabilities.no_human_approver = session
2261            .agent_runtime_state
2262            .as_ref()
2263            .is_some_and(|s| s.no_human_approver);
2264        // Read-only authority comes from the child's typed runtime state, never
2265        // its cosmetic role label. The sole legacy recovery path additionally
2266        // requires the old Guardian role AND its host-owned mutation denylist,
2267        // preserving in-flight pre-upgrade reviewers without making a label an
2268        // authority boundary.
2269        let typed_read_only = session
2270            .agent_runtime_state
2271            .as_ref()
2272            .is_some_and(|state| state.read_only);
2273        let legacy_guardian_read_only = spec.identity.role == "guardian"
2274            && spec.disabled_tools.as_ref().is_some_and(|disabled| {
2275                ["Edit", "Write", "SubAgent", "WebFetch"]
2276                    .iter()
2277                    .all(|name| disabled.iter().any(|disabled_name| disabled_name == *name))
2278            });
2279        let read_only = typed_read_only || legacy_guardian_read_only;
2280        spec.capabilities.read_only = read_only;
2281        // Emit the old field for every read-only child. Older workers ignore
2282        // `read_only` but understand this bit and will install the strict
2283        // no-shell checker, which prevents a rolling upgrade from weakening Plan.
2284        spec.capabilities.guardian_read_only = read_only;
2285        if spec.capabilities.read_only_enforced() {
2286            // Do not even construct nested delegation machinery for a read-only
2287            // inspection child; the denylist below remains a second boundary.
2288            spec.capabilities.nested_spawn = false;
2289            // Treat the capability as the authority boundary, not as a hint
2290            // that every caller must remember to pair with a schema filter.
2291            // Preserve caller-specific disables while always removing Bamboo's
2292            // shell, mutating, persistent, interactive, and delegation surfaces.
2293            spec.disabled_tools
2294                .get_or_insert_with(Default::default)
2295                .extend(crate::runtime::guardian_state::read_only_child_disabled_tools());
2296            match &mut spec.executor {
2297                ExecutorSpec::ClaudeCode {
2298                    permission_mode,
2299                    inherit_user_config,
2300                    ..
2301                } => {
2302                    *permission_mode = Some("plan".to_string());
2303                    // User config may add MCP tools/hooks outside the provisioned
2304                    // surface, so it is never inherited by a read-only child.
2305                    *inherit_user_config = Some(false);
2306                }
2307                ExecutorSpec::Codex {
2308                    mode,
2309                    sandbox,
2310                    inherit_user_config,
2311                    approval_policy,
2312                    network_access,
2313                    allow_danger_bypass,
2314                    permission_profile,
2315                    ..
2316                } => {
2317                    *sandbox = Some("read-only".to_string());
2318                    *inherit_user_config = Some(false);
2319                    // app-server provisioning accepts `on-request`; the typed
2320                    // Plan activation maps that to `approvalPolicy=never`.
2321                    *approval_policy = Some(
2322                        if mode.as_deref() == Some("app_server") {
2323                            "on-request"
2324                        } else {
2325                            "never"
2326                        }
2327                        .to_string(),
2328                    );
2329                    *network_access = Some(false);
2330                    *allow_danger_bypass = Some(false);
2331                    *permission_profile = Some("read-only".to_string());
2332                }
2333                _ => {}
2334            }
2335        }
2336        let read_only_overlay = session
2337            .agent_runtime_state
2338            .as_ref()
2339            .is_some_and(|state| state.plan_mode.is_some())
2340            || spec.capabilities.read_only_enforced()
2341            || executor_has_read_only_permission_profile(&spec.executor);
2342        let permission_resolution = bamboo_domain::resolve_permission_mode_with_read_only(
2343            requested_permission_mode,
2344            configured_permission_mode,
2345            read_only_overlay,
2346        );
2347        spec.capabilities.bypass = permission_resolution.bypass_permissions();
2348        spec.capabilities.auto_approve_permissions =
2349            permission_resolution.suppress_approval_prompts();
2350        spec.capabilities.permission_requested_mode =
2351            permission_resolution.requested.as_str().to_string();
2352        spec.capabilities.permission_effective_mode =
2353            permission_resolution.effective.as_str().to_string();
2354        // #193: route this role to a REMOTE resident worker when one is pinned.
2355        // `spec.identity.role` was just computed from `subagent_type` above; a
2356        // match flips the placement to Remote. The scoped broker credential stays
2357        // on the Host transport and never enters the child RunSpec. No match
2358        // leaves the default `Placement::Local`,
2359        // so the local path is byte-for-byte unchanged for every non-pinned role.
2360        if let Some(placement) = self.remote_placements.get(spec.identity.role.as_str()) {
2361            spec.placement = Placement::Remote {
2362                endpoint: placement.endpoint.clone(),
2363            };
2364        } else if let Some(placement) = self.schedulable_placements.get(spec.identity.role.as_str())
2365        {
2366            // #181 (P2b): route this role to a SCHEDULED worker — ONLY when it is
2367            // NOT already pinned to a fixed remote endpoint (the `else if` makes
2368            // remote_placements take precedence for a role in both). The concrete
2369            // worker is picked at run time in `execute_external_child` from the bus
2370            // (a live connected worker of the pool role). No per-placement bearer
2371            // now — the bus connection uses the bus token. No match in either map
2372            // leaves the default `Placement::Local`.
2373            spec.placement = Placement::Schedulable {
2374                pool: placement.pool.clone(),
2375            };
2376        }
2377        spec
2378    }
2379
2380    /// Assemble from the startup snapshot. Kept synchronous for focused unit
2381    /// tests and non-server callers that do not install a live config source.
2382    fn build_spec(&self, session: &Session, job: &SpawnJob) -> ProvisionSpec {
2383        self.build_spec_with_provider_config(
2384            session,
2385            job,
2386            &self.credentials,
2387            &self.default_provider,
2388            self.max_spawn_depth,
2389        )
2390    }
2391
2392    /// Assemble from the latest AppState config when available. Taking the
2393    /// async read lock here gives every new activation a post-reload credential
2394    /// view without copying secrets into a second independently refreshed
2395    /// runtime store.
2396    async fn build_live_spec(&self, session: &Session, job: &SpawnJob) -> ProvisionSpec {
2397        let Some(config) = self.live_provider_config.as_ref() else {
2398            return self.build_spec(session, job);
2399        };
2400
2401        let config = config.read().await;
2402        let credentials = super::runtime::extract_provider_credentials(&config);
2403        let default_provider = config.effective_default_provider().to_string();
2404        self.build_spec_with_provider_config(
2405            session,
2406            job,
2407            &credentials,
2408            &default_provider,
2409            config.subagents().effective_max_spawn_depth(),
2410        )
2411    }
2412
2413    /// The `metadata["placement"]` JSON to stamp on a child from its resolved
2414    /// placement, preferring the matching cluster node's `host_label` (its
2415    /// operator label/host) over the raw endpoint/pool. `None` for a Local child
2416    /// (the DTO defaults it to the backend's own host). Split out of
2417    /// `execute_external_child` so the role→placement→host resolution is unit-testable.
2418    fn placement_stamp_for(&self, spec: &ProvisionSpec) -> Option<String> {
2419        let host_label = match &spec.placement {
2420            Placement::Remote { .. } => self
2421                .remote_placements
2422                .get(spec.identity.role.as_str())
2423                .and_then(|p| p.host_label.as_deref()),
2424            Placement::Schedulable { .. } => self
2425                .schedulable_placements
2426                .get(spec.identity.role.as_str())
2427                .and_then(|p| p.host_label.as_deref()),
2428            Placement::Local => None,
2429        };
2430        placement_metadata(&spec.placement, host_label)
2431    }
2432
2433    fn scoped_placement_route(
2434        &self,
2435        spec: &ProvisionSpec,
2436    ) -> Result<Option<ScopedPlacementRoute>, AgentError> {
2437        match &spec.placement {
2438            Placement::Local => Ok(None),
2439            Placement::Remote { .. } => {
2440                let placement = self
2441                    .remote_placements
2442                    .get(&spec.identity.role)
2443                    .ok_or_else(remote_broker_unavailable)?;
2444                let peer = placement
2445                    .broker_peer
2446                    .as_ref()
2447                    .and_then(|peer| peer.as_ref().ok())
2448                    .ok_or_else(remote_broker_unavailable)?;
2449                let worker_role = peer
2450                    .worker_role
2451                    .clone()
2452                    .ok_or_else(remote_broker_unavailable)?;
2453                Ok(Some(ScopedPlacementRoute {
2454                    endpoint: placement.endpoint.clone(),
2455                    token: placement
2456                        .token
2457                        .clone()
2458                        .ok_or_else(remote_broker_unavailable)?,
2459                    ca_cert_file: placement
2460                        .ca_cert_file
2461                        .clone()
2462                        .ok_or_else(remote_broker_unavailable)?,
2463                    parent: bamboo_subagent::AgentRef {
2464                        session_id: peer.parent_mailbox.clone(),
2465                        role: peer.parent_role.clone(),
2466                    },
2467                    worker_role,
2468                    pinned_mailbox: Some(peer.worker_mailbox.clone()),
2469                    requirements: placement
2470                        .requirements
2471                        .clone()
2472                        .ok_or_else(remote_broker_unavailable)?,
2473                }))
2474            }
2475            Placement::Schedulable { .. } => {
2476                let placement = self
2477                    .schedulable_placements
2478                    .get(&spec.identity.role)
2479                    .ok_or_else(remote_broker_unavailable)?;
2480                let parent = placement
2481                    .broker_parent
2482                    .as_ref()
2483                    .ok_or_else(remote_broker_unavailable)?;
2484                Ok(Some(ScopedPlacementRoute {
2485                    endpoint: placement.endpoint.clone(),
2486                    token: placement
2487                        .token
2488                        .clone()
2489                        .ok_or_else(remote_broker_unavailable)?,
2490                    ca_cert_file: placement
2491                        .ca_cert_file
2492                        .clone()
2493                        .ok_or_else(remote_broker_unavailable)?,
2494                    parent: bamboo_subagent::AgentRef {
2495                        session_id: parent.parent_mailbox.clone(),
2496                        role: Some(parent.parent_role.clone()),
2497                    },
2498                    worker_role: placement.pool.clone(),
2499                    pinned_mailbox: None,
2500                    requirements: placement
2501                        .requirements
2502                        .clone()
2503                        .ok_or_else(remote_broker_unavailable)?,
2504                }))
2505            }
2506        }
2507    }
2508
2509    async fn select_scoped_worker(
2510        &self,
2511        route: &ScopedPlacementRoute,
2512        registry: &bamboo_storage::v2::FileHostRegistry,
2513        actor_id: &str,
2514        project_id: Option<bamboo_domain::ProjectId>,
2515        required_tools: &BTreeSet<String>,
2516    ) -> Result<String, AgentError> {
2517        if let Some(mailbox) = &route.pinned_mailbox {
2518            return Ok(mailbox.clone());
2519        }
2520        let mut query = route.query().await?;
2521        let candidates = query
2522            .list_connected(&route.worker_role)
2523            .await
2524            .map_err(|_| remote_broker_unavailable())?;
2525        let scheduler = PlacementScheduler::new(registry.clone());
2526        let mut selected: Option<(String, usize, u16)> = None;
2527        for mailbox in candidates {
2528            let Some(observed) = query
2529                .observe_host_capacity(&mailbox, &route.worker_role)
2530                .await
2531                .map_err(|_| remote_broker_unavailable())?
2532            else {
2533                continue;
2534            };
2535            let Ok(host) = scheduler
2536                .observe_scoped_host(
2537                    &mut query,
2538                    &observed.host_ref,
2539                    &mailbox,
2540                    &route.worker_role,
2541                    chrono::Duration::seconds(110),
2542                )
2543                .await
2544            else {
2545                continue;
2546            };
2547            let now = chrono::Utc::now();
2548            let request = HostPlacementRequest {
2549                intent: HostPlacementIntent::Pool {
2550                    pool_id: route.worker_role.clone(),
2551                },
2552                actor_id: actor_id.to_owned(),
2553                run_id: "selection".into(),
2554                project_id: project_id.clone(),
2555                trust_zone: route.requirements.trust_zone.clone(),
2556                workspace_label: route.requirements.workspace_label.clone(),
2557                executor: "bamboo-runtime".into(),
2558                required_tools: required_tools.clone(),
2559                network_zone: route.requirements.network_zone.clone(),
2560                require_network_isolation: route.requirements.require_network_isolation,
2561                preferred_host_ref: None,
2562                now,
2563                lease_expires_at: now + chrono::Duration::seconds(80),
2564            };
2565            if host.capabilities.placement_class != bamboo_domain::ActorPlacementClass::Remote
2566                || !request.eligible(&host)
2567            {
2568                continue;
2569            }
2570            let load = host.active_slots(now);
2571            if selected.as_ref().is_none_or(|(_, old_load, old_slots)| {
2572                load * usize::from(*old_slots) < *old_load * usize::from(host.max_slots)
2573            }) {
2574                selected = Some((mailbox, load, host.max_slots));
2575            }
2576        }
2577        selected
2578            .map(|(mailbox, _, _)| mailbox)
2579            .ok_or_else(remote_broker_unavailable)
2580    }
2581}
2582
2583#[async_trait]
2584impl ExternalChildRunner for ActorChildRunner {
2585    async fn validate_required_child_context_route(&self, session: &Session) -> Result<(), String> {
2586        let role = session
2587            .metadata
2588            .get("subagent_type")
2589            .map(String::as_str)
2590            .unwrap_or("worker");
2591        let current = std::env::current_exe()
2592            .ok()
2593            .and_then(|path| path.canonicalize().ok());
2594        if self.native_tool_ceiling.is_none()
2595            || !self.builtin_required_context_route
2596            || !self.should_handle(session).await
2597            || current.is_none()
2598            || self.worker_bin.canonicalize().ok() != current
2599            || self.worker_args != ["subagent-worker".to_string()]
2600            || !matches!(self.executor, ExecutorSpec::BambooRuntime)
2601            || self.bus.is_none()
2602            || self.remote_placements.contains_key(role)
2603            || self.schedulable_placements.contains_key(role)
2604        {
2605            return Err(
2606                "required_child_context_unsupported: built-in fresh local Bamboo worker required"
2607                    .into(),
2608            );
2609        }
2610        bamboo_subagent::fleet::require_worker_capability(
2611            &self.worker_bin,
2612            &self.worker_args,
2613            bamboo_subagent::provision::REQUIRED_CHILD_CONTEXT_WORKER_CAPABILITY,
2614        )
2615        .await
2616        .map_err(|_| {
2617            "required_child_context_unsupported: worker capability unconfirmed".to_string()
2618        })?;
2619        bamboo_subagent::fleet::require_worker_capability(
2620            &self.worker_bin,
2621            &self.worker_args,
2622            bamboo_subagent::provision::NATIVE_TOOL_CEILING_WORKER_CAPABILITY,
2623        )
2624        .await
2625        .map_err(|_| {
2626            "native_tool_ceiling_unsupported: worker capability unconfirmed".to_string()
2627        })?;
2628        bamboo_subagent::fleet::require_worker_capability(
2629            &self.worker_bin,
2630            &self.worker_args,
2631            bamboo_subagent::provision::CHILD_CREATION_IDENTITY_WORKER_CAPABILITY,
2632        )
2633        .await
2634        .map_err(|_| {
2635            "required_child_context_unsupported: worker birth capability unconfirmed".into()
2636        })
2637    }
2638    async fn should_handle(&self, session: &Session) -> bool {
2639        session.metadata.get("runtime.kind") == Some(&"external".to_string())
2640            && session.metadata.get("external.protocol") == Some(&"actor".to_string())
2641            && session.metadata.get("external.agent_id") == Some(&self.agent_id)
2642    }
2643
2644    fn set_escalation_bridge(&self, bridge: Option<bamboo_subagent::executor::HostBridge>) {
2645        *self.escalation_bridge.lock().recover_poison() = bridge;
2646    }
2647
2648    fn set_session_inbox_runtime(&self, binding: Option<SessionInboxRuntimeBinding>) {
2649        *self.session_inbox_runtime.lock().recover_poison() = binding;
2650    }
2651
2652    fn set_actor_directory_store(&self, store: Option<Arc<bamboo_storage::SessionStoreV2>>) {
2653        *self.actor_directory_store.lock().recover_poison() = store;
2654    }
2655
2656    fn set_canonical_subagent_tool(&self, tool: Option<Arc<dyn bamboo_agent_core::tools::Tool>>) {
2657        *self.canonical_subagent_tool.lock().recover_poison() = tool;
2658    }
2659
2660    fn set_ticket_service(&self, service: Option<Arc<bamboo_tickets::TicketService>>) {
2661        *self.ticket_service.lock().recover_poison() = service;
2662    }
2663
2664    fn set_actor_event_observer(&self, observer: Option<Arc<dyn ActorEventObserver>>) {
2665        *self.actor_event_observer.lock().recover_poison() = observer;
2666    }
2667
2668    async fn prepare_durable_child_delivery(
2669        &self,
2670        session: &Session,
2671        activation_run_id: &str,
2672    ) -> Result<bool, String> {
2673        let key = (
2674            session.id.clone(),
2675            session.created_at,
2676            activation_run_id.to_owned(),
2677        );
2678        let (delivery, host_nested_wait_handoff, strict_history) = {
2679            let pending = self.pending_durable_links.lock().await;
2680            let Some(pending) = pending.get(&key) else {
2681                return Ok(false);
2682            };
2683            if pending.strict_history_required && pending.strict_history.is_none() {
2684                return Err("Host strict terminal completeness proof missing".into());
2685            }
2686            (
2687                pending
2688                    .link
2689                    .durable_delivery_receipt()
2690                    .ok_or_else(|| "broker terminal has no exact durable receipt".to_string())?,
2691                pending.host_nested_wait_handoff,
2692                pending.strict_history.clone(),
2693            )
2694        };
2695        let expected_status =
2696            effective_broker_terminal_status(delivery.terminal_status, host_nested_wait_handoff)?;
2697        if session.last_run_status().as_deref() != Some(expected_status) {
2698            tracing::warn!(
2699                child_session_id = %session.id,
2700                activation_run_id,
2701                expected_status,
2702                actual_status = ?session.last_run_status(),
2703                "Host final status differs from accepted broker terminal"
2704            );
2705            return Err("Host final status differs from accepted broker terminal".into());
2706        }
2707        let store = self
2708            .actor_directory_store
2709            .lock()
2710            .recover_poison()
2711            .clone()
2712            .ok_or_else(|| "Host broker receipt store unavailable".to_string())?;
2713        let binding = self
2714            .session_inbox_runtime
2715            .lock()
2716            .recover_poison()
2717            .clone()
2718            .ok_or_else(|| "Host activation fence unavailable for broker receipt".to_string())?;
2719        if !binding
2720            .router
2721            .owns_run(&session.id, activation_run_id)
2722            .await
2723        {
2724            return Err("stale Child activation cannot prepare broker receipt".into());
2725        }
2726        let ticket_service = self.ticket_service.lock().recover_poison().clone();
2727        let yielded_call = if expected_status == "completed"
2728            && session
2729                .metadata
2730                .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
2731        {
2732            let service = ticket_service
2733                .as_deref()
2734                .ok_or("Ticket authority unavailable for broker question")?;
2735            crate::ticket_runtime::question_terminal_call_id(service, session, activation_run_id)
2736                .map_err(|e| e.to_string())?
2737        } else {
2738            None
2739        };
2740        let route = bamboo_storage::v2::BrokerTerminalRoute {
2741            activation_run_id,
2742            broker_identity: &delivery.broker_identity,
2743            parent_mailbox: &delivery.parent_mailbox,
2744            broker_correlation_id: &delivery.correlation_id,
2745            message_ids: &delivery.message_ids,
2746        };
2747        if let Some(call_id) = yielded_call {
2748            let proof = bamboo_storage::v2::HostToolYield::from_verified_host(
2749                session,
2750                activation_run_id,
2751                &call_id,
2752            )
2753            .map_err(|error| error.to_string())?;
2754            if let Some(history) = strict_history.as_ref() {
2755                store
2756                    .prepare_broker_tool_yield_receipt_with_completeness(
2757                        session,
2758                        route,
2759                        &proof,
2760                        &history.proof,
2761                    )
2762                    .await
2763            } else {
2764                store
2765                    .prepare_broker_tool_yield_receipt(session, route, &proof)
2766                    .await
2767            }
2768            .map_err(|error| format!("Host broker receipt prepare failed: {error}"))?;
2769        } else {
2770            if let Some(history) = strict_history.as_ref() {
2771                store
2772                    .prepare_broker_terminal_receipt_with_completeness(
2773                        session,
2774                        route,
2775                        &history.proof,
2776                    )
2777                    .await
2778            } else {
2779                store
2780                    .prepare_broker_terminal_receipt(
2781                        session,
2782                        activation_run_id,
2783                        &delivery.broker_identity,
2784                        &delivery.parent_mailbox,
2785                        &delivery.correlation_id,
2786                        &delivery.message_ids,
2787                    )
2788                    .await
2789            }
2790            .map_err(|error| format!("Host broker receipt prepare failed: {error}"))?;
2791        }
2792        Ok(true)
2793    }
2794
2795    async fn commit_durable_child_delivery(
2796        &self,
2797        session: &Session,
2798        activation_run_id: &str,
2799    ) -> Result<(), String> {
2800        let key = (
2801            session.id.clone(),
2802            session.created_at,
2803            activation_run_id.to_owned(),
2804        );
2805        let mut pending_links = self.pending_durable_links.lock().await;
2806        let Some(pending) = pending_links.get_mut(&key) else {
2807            return Ok(());
2808        };
2809        let delivery = pending
2810            .link
2811            .durable_delivery_receipt()
2812            .ok_or_else(|| "broker terminal lost its exact durable receipt".to_string())?;
2813        let expected_status = effective_broker_terminal_status(
2814            delivery.terminal_status,
2815            pending.host_nested_wait_handoff,
2816        )?;
2817        if session.last_run_status().as_deref() != Some(expected_status) {
2818            return Err("Host final status differs from accepted broker terminal".into());
2819        }
2820        if pending.strict_history_required && pending.strict_history.is_none() {
2821            return Err("Host strict terminal completeness proof missing".into());
2822        }
2823        let store = self
2824            .actor_directory_store
2825            .lock()
2826            .recover_poison()
2827            .clone()
2828            .ok_or_else(|| "Host broker receipt store unavailable".to_string())?;
2829        let committed = if let Some(history) = pending.strict_history.as_ref() {
2830            store
2831                .commit_broker_terminal_receipt_with_completeness(
2832                    session,
2833                    activation_run_id,
2834                    &history.proof,
2835                )
2836                .await
2837        } else {
2838            store
2839                .commit_broker_terminal_receipt(session, activation_run_id)
2840                .await
2841        }
2842        .map_err(|error| format!("Host broker receipt checkpoint unconfirmed: {error}"))?;
2843        if committed.broker_identity != delivery.broker_identity
2844            || committed.parent_mailbox != delivery.parent_mailbox
2845            || committed.broker_correlation_id != delivery.correlation_id
2846            || committed.message_ids != delivery.message_ids
2847            || committed.terminal_status != expected_status
2848        {
2849            return Err("Host broker receipt changed before completion".into());
2850        }
2851        if let Some(history) = pending.strict_history.as_ref() {
2852            let proof = committed
2853                .terminal_completeness
2854                .as_ref()
2855                .ok_or_else(|| "Host strict terminal completeness readback missing".to_string())?;
2856            let creation = bamboo_subagent::proto::ChildCreationIdentity {
2857                created_at: session.created_at,
2858                spawn_depth: session.spawn_depth,
2859            };
2860            validate_strict_history_terminal(
2861                Some(&history.watermark),
2862                session,
2863                session
2864                    .parent_session_id
2865                    .as_deref()
2866                    .ok_or("Child parent missing")?,
2867                Some(activation_run_id),
2868                proof.execution_epoch,
2869                Some(&creation),
2870                proof.contiguous_applied_seq,
2871            )
2872            .map_err(|error| error.to_string())?;
2873        }
2874        // The same exact-snapshot CAS serves live and cold publication. An
2875        // append-safe generic save could expose Completed after merging new
2876        // input that invalidates the source; CAS rejects before that write.
2877        publish_recovered_completion_source(&store, &committed).await?;
2878        pending.strict_history_committed = true;
2879        Ok(())
2880    }
2881
2882    async fn confirm_durable_child_delivery(
2883        &self,
2884        session: &Session,
2885        activation_run_id: &str,
2886        save_succeeded: bool,
2887    ) -> Result<(), String> {
2888        if save_succeeded
2889            && session
2890                .metadata
2891                .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
2892        {
2893            let service = self
2894                .ticket_service
2895                .lock()
2896                .recover_poison()
2897                .clone()
2898                .ok_or_else(|| {
2899                    "Ticket authority unavailable for terminal checkpoint".to_string()
2900                })?;
2901            let runtime = self
2902                .session_inbox_runtime
2903                .lock()
2904                .recover_poison()
2905                .clone()
2906                .ok_or_else(|| "Ticket runtime unavailable for terminal checkpoint".to_string())?;
2907            crate::ticket_runtime::checkpoint_ticket_result(
2908                &service,
2909                runtime.storage.as_ref(),
2910                session,
2911                activation_run_id,
2912            )
2913            .await
2914            .map_err(|e| e.to_string())?;
2915        }
2916        let key = (
2917            session.id.clone(),
2918            session.created_at,
2919            activation_run_id.to_owned(),
2920        );
2921        let Some(mut pending) = self.pending_durable_links.lock().await.remove(&key) else {
2922            return Ok(());
2923        };
2924        // A later SDK timeout or Host postprocessing error cannot convert an
2925        // accepted Worker terminal into a different receipt status.
2926        let delivery = pending
2927            .link
2928            .durable_delivery_receipt()
2929            .ok_or_else(|| "broker terminal lost its exact durable receipt".to_string())?;
2930        let expected_status = effective_broker_terminal_status(
2931            delivery.terminal_status,
2932            pending.host_nested_wait_handoff,
2933        )?;
2934        if !save_succeeded || session.last_run_status().as_deref() != Some(expected_status) {
2935            return Ok(());
2936        }
2937        let store = self
2938            .actor_directory_store
2939            .lock()
2940            .recover_poison()
2941            .clone()
2942            .ok_or_else(|| "Host broker receipt store unavailable".to_string())?;
2943        let committed = if pending.strict_history_required {
2944            if !pending.strict_history_committed {
2945                return Err("Host strict completeness was not committed before ACK".into());
2946            }
2947            let history = pending
2948                .strict_history
2949                .as_ref()
2950                .ok_or_else(|| "Host strict terminal completeness proof missing".to_string())?;
2951            store
2952                .commit_broker_terminal_receipt_with_completeness(
2953                    session,
2954                    activation_run_id,
2955                    &history.proof,
2956                )
2957                .await
2958        } else {
2959            store
2960                .commit_broker_terminal_receipt(session, activation_run_id)
2961                .await
2962        }
2963        .map_err(|error| format!("Host broker receipt checkpoint unconfirmed: {error}"))?;
2964        if committed.broker_identity != delivery.broker_identity
2965            || committed.parent_mailbox != delivery.parent_mailbox
2966            || committed.broker_correlation_id != delivery.correlation_id
2967            || committed.message_ids != delivery.message_ids
2968            || committed.terminal_status != expected_status
2969        {
2970            return Err("Host broker receipt changed before ACK".into());
2971        }
2972        pending
2973            .link
2974            .acknowledge_durable_frames()
2975            .await
2976            .map_err(|error| format!("child broker durable ACK unconfirmed: {error}"))?;
2977        store
2978            .clear_acknowledged_broker_terminal_receipt(
2979                &session.id,
2980                session.created_at,
2981                activation_run_id,
2982                &delivery.parent_mailbox,
2983            )
2984            .await
2985            .map_err(|error| {
2986                format!("broker ACK succeeded but Host receipt cleanup failed: {error}")
2987            })
2988    }
2989
2990    #[tracing::instrument(
2991        name = "actor_child_run",
2992        level = "warn",
2993        skip_all,
2994        fields(child_session_id = %job.child_session_id, activation_run_id = tracing::field::Empty)
2995    )]
2996    async fn execute_external_child(
2997        &self,
2998        session: &mut Session,
2999        job: &SpawnJob,
3000        event_tx: mpsc::Sender<AgentEvent>,
3001        cancel_token: CancellationToken,
3002    ) -> crate::runtime::runner::Result<()> {
3003        crate::session_app::child_session::named_profile::validate_named_profile(session)
3004            .map_err(|error| AgentError::LLM(error.to_string()))?;
3005        // #68 CORRECTNESS CRUX: capture the per-run escalation bridge HERE, at the
3006        // moment this grandchild is spawned — while the parent run's bridge is
3007        // still in our slot — into an owned local handed to `drive()` for this
3008        // grandchild's whole lifetime. A fire-and-forget grandchild that OUTLIVES
3009        // the run that spawned it must NOT re-read `self.escalation_bridge` at
3010        // approval time: by then `run()` may have cleared/overwritten it (a worker
3011        // serves runs sequentially), and re-proxying through a closed bridge
3012        // fail-closed denies. Capturing at spawn pins the right bridge per run.
3013        let escalation = self.escalation_bridge.lock().recover_poison().clone();
3014        let session_inbox_runtime = self.session_inbox_runtime.lock().recover_poison().clone();
3015        let actor_directory_store = self.actor_directory_store.lock().recover_poison().clone();
3016        let canonical_subagent_tool = self.canonical_subagent_tool.lock().recover_poison().clone();
3017        let ticket_service = self.ticket_service.lock().recover_poison().clone();
3018        let actor_event_observer = self.actor_event_observer.lock().recover_poison().clone();
3019        let required_context = bamboo_domain::ChildContextBinding::from_session(session)
3020            .map_err(|error| AgentError::Budget(error.to_string()))?;
3021        if crate::session_app::child_session::named_profile::has_named_profile(session)
3022            && required_context.is_none()
3023        {
3024            return Err(AgentError::LLM(
3025                "named_profile_requires_strict_context_route".into(),
3026            ));
3027        }
3028        let assignment = required_context
3029            .as_ref()
3030            .map(|binding| binding.payload.required_assignment.clone())
3031            .unwrap_or_else(|| extract_assignment(session));
3032        let mut spec = self.build_live_spec(session, job).await;
3033        // Reject before remote routing, Actor claims, provisioning or Ticket
3034        // Run admission: resident workers cannot produce local owned stop proof.
3035        ensure_ticket_placement_is_enforceable(session, &spec)?;
3036        let scoped_route = self.scoped_placement_route(&spec)?;
3037        let strict_remote = scoped_route.as_ref();
3038        if strict_remote.is_some() && !matches!(spec.executor, ExecutorSpec::BambooRuntime) {
3039            return Err(remote_broker_unavailable());
3040        }
3041        let lineage = if strict_remote.is_some() {
3042            Some(
3043                remote_canonical_lineage(
3044                    session_inbox_runtime
3045                        .as_ref()
3046                        .ok_or_else(remote_broker_unavailable)?,
3047                    session,
3048                )
3049                .await?,
3050            )
3051        } else {
3052            None
3053        };
3054        let creation = if (matches!(spec.placement, Placement::Local) || strict_remote.is_some())
3055            && matches!(spec.executor, ExecutorSpec::BambooRuntime)
3056        {
3057            let binding = session_inbox_runtime.as_ref().ok_or_else(|| {
3058                AgentError::LLM(
3059                    "child creation identity unavailable: canonical host Storage required".into(),
3060                )
3061            })?;
3062            spec.capabilities.child_creation_identity = true;
3063            Some(canonical_child_creation(binding, session, job).await?)
3064        } else {
3065            None
3066        };
3067        // Mark the worker reusable + give it an idle timeout so it self-reaps if
3068        // orphaned. Warm bus workers are pooled per fingerprint and reused.
3069        spec.reusable = required_context.is_none();
3070        spec.capabilities.required_child_context = required_context.is_some();
3071        if required_context.is_some() {
3072            self.validate_required_child_context_route(session)
3073                .await
3074                .map_err(AgentError::LLM)?;
3075        }
3076        if required_context.is_some() {
3077            let names = self
3078                .native_tool_ceiling
3079                .as_ref()
3080                .unwrap()
3081                .observe(session)
3082                .await
3083                .map_err(AgentError::LLM)?;
3084            let birth = creation
3085                .as_ref()
3086                .ok_or_else(|| AgentError::LLM("native_tool_ceiling_birth_missing".into()))?;
3087            spec.capabilities.native_tool_ceiling_required = true;
3088            spec.capabilities.native_tool_ceiling =
3089                Some(bamboo_subagent::proto::NativeToolCeiling {
3090                    version: 1,
3091                    child_session_id: session.id.clone(),
3092                    parent_session_id: job.parent_session_id.clone(),
3093                    root_session_id: session.root_session_id.clone(),
3094                    created_at: birth.created_at,
3095                    spawn_depth: birth.spawn_depth,
3096                    project_id: project_id_for_actor_run(session)?,
3097                    tools: names,
3098                });
3099            // Positive native names are a leaf surface, never an expansion path.
3100            spec.capabilities.mcp = None;
3101            spec.capabilities.mcp_proxy = None;
3102            spec.capabilities.skills_dir = None;
3103            spec.capabilities.nested_spawn = false;
3104            spec.validate()
3105                .map_err(|_| AgentError::LLM("native_tool_ceiling_invalid".into()))?;
3106        }
3107        // Eligibility uses the actual Host callable ceiling, after the strict
3108        // built-in route and birth capability checks. A role label is not a grant.
3109        let first_reply_store = actor_directory_store.clone().filter(|_| {
3110            required_context.is_some()
3111                && crate::session_app::child_session::named_profile::has_named_profile(session)
3112                && matches!(spec.placement, Placement::Local)
3113                && matches!(spec.executor, ExecutorSpec::BambooRuntime)
3114                && spec.capabilities.native_tool_ceiling_required
3115                && spec
3116                    .capabilities
3117                    .native_tool_ceiling
3118                    .as_ref()
3119                    .is_some_and(|ceiling| {
3120                        ceiling.tools.is_empty()
3121                            || (spec.capabilities.read_only_enforced() && ceiling.tools == ["Glob"])
3122                    })
3123        });
3124        let plain_actor_store = if let Some(store) = first_reply_store {
3125            let root = bamboo_agent_core::storage::Storage::load_session(
3126                store.as_ref(),
3127                &session.root_session_id,
3128            )
3129            .await
3130            .map_err(|_| plain_actor_unsupported())?
3131            .ok_or_else(|| plain_actor_unsupported())?;
3132            if root.id != session.root_session_id || root.kind != bamboo_domain::SessionKind::Root {
3133                return Err(plain_actor_unsupported());
3134            }
3135            if root.root_orchestration_only_enabled() {
3136                if !plain_initial_history(session)
3137                    && (spec
3138                        .capabilities
3139                        .native_tool_ceiling
3140                        .as_ref()
3141                        .is_none_or(|ceiling| !ceiling.tools.is_empty())
3142                        || (!plain_completed_history(session) && pre_ack_input(session)?.is_none()))
3143                {
3144                    return Err(plain_actor_unsupported());
3145                }
3146                Some(store)
3147            } else if spec
3148                .capabilities
3149                .native_tool_ceiling
3150                .as_ref()
3151                .is_some_and(|ceiling| ceiling.tools == ["Glob"])
3152            {
3153                // Ordinary Glob remains legacy. Observe only its own mapped
3154                // initialization signals; never require a whole-tree snapshot.
3155                require_unowned_glob(store.as_ref(), session).await?;
3156                None
3157            } else {
3158                // Public bounded observation is only a no-replay check. It is
3159                // never an activation grant. Ordinary first runs stay legacy.
3160                let observed = store
3161                    .actor_subtree_snapshot(
3162                        ActorSnapshotPrincipal::host_owner(),
3163                        &session.root_session_id,
3164                        &session.id,
3165                        ActorSnapshotLimits::default(),
3166                    )
3167                    .await
3168                    .map_err(|_| plain_actor_unsupported())?;
3169                if observed
3170                    .nodes
3171                    .iter()
3172                    .any(|node| node.actor_id == session.id && node.activation.is_some())
3173                {
3174                    return Err(plain_actor_unsupported());
3175                }
3176                None
3177            }
3178        } else {
3179            None
3180        };
3181        let readonly_actor = plain_actor_store.is_some()
3182            && spec
3183                .capabilities
3184                .native_tool_ceiling
3185                .as_ref()
3186                .is_some_and(|ceiling| ceiling.tools == ["Glob"]);
3187        let local_history_tools = (plain_actor_store.is_none()
3188            && required_context.is_some()
3189            && (crate::session_app::child_session::named_profile::has_named_profile(session)
3190                || session
3191                    .metadata
3192                    .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY))
3193            && matches!(spec.placement, Placement::Local)
3194            && matches!(spec.executor, ExecutorSpec::BambooRuntime))
3195        .then_some(spec.capabilities.native_tool_ceiling.as_ref())
3196        .flatten()
3197        .filter(|ceiling| {
3198            bamboo_subagent::proto::LocalToolMessages::supports_tools(
3199                &ceiling.tools,
3200                spec.capabilities.read_only_enforced(),
3201            )
3202        })
3203        .map(|ceiling| ceiling.tools.clone());
3204        if plain_actor_store.is_some() && !readonly_actor {
3205            self.require_initial_input_release_worker().await?;
3206            spec.capabilities.initial_input_release_required = true;
3207            spec.validate().map_err(|_| plain_actor_unsupported())?;
3208        }
3209        let mut readonly_messages = Vec::new();
3210        let mut plain_activation = None;
3211        if spec.limits.idle_timeout_secs.is_none() {
3212            spec.limits.idle_timeout_secs = Some(POOLED_IDLE_TIMEOUT_SECS);
3213        }
3214        ensure_read_only_activation_is_enforceable(&spec)?;
3215        let pool_key = Self::fingerprint(&spec);
3216
3217        // The recommended provider URL is deliberately parent-loopback. A
3218        // resident remote worker would interpret 127.0.0.1 as itself, not this
3219        // server, so reject that ambiguous deployment instead of minting a
3220        // credential that can never authenticate to the intended parent.
3221        if executor_uses_bamboo_codex(&spec.executor) && !matches!(spec.placement, Placement::Local)
3222        {
3223            return Err(AgentError::LLM(
3224                "Codex auth mode 'bamboo' requires local actor placement; use custom mode with a reachable URL for remote workers"
3225                    .to_string(),
3226            ));
3227        }
3228        let project_id = project_id_for_actor_run(session)?;
3229        let requested_permission_mode = session
3230            .agent_runtime_state
3231            .as_ref()
3232            .map(|state| state.effective_permission_mode())
3233            .unwrap_or_default();
3234        // Policy is captured per activation (not only when a worker is
3235        // provisioned), so reused local workers and resident remote/broker
3236        // workers observe the latest durable revision and bypass flag at the
3237        // next run boundary. Session grants are intentionally not inherited.
3238        let permission_policy = if let Some(config) = self.permission_config.as_ref() {
3239            ensure_no_active_scoped_session_denies(config, &session.id)?;
3240            let read_only_overlay = session
3241                .agent_runtime_state
3242                .as_ref()
3243                .is_some_and(|state| state.plan_mode.is_some())
3244                || spec.capabilities.read_only_enforced()
3245                || executor_has_read_only_permission_profile(&spec.executor);
3246            let resolution = bamboo_domain::resolve_permission_mode_with_read_only(
3247                requested_permission_mode,
3248                config.mode(),
3249                read_only_overlay,
3250            );
3251            let policy = serde_json::to_value(config.to_serializable()).map_err(|error| {
3252                AgentError::LLM(format!(
3253                    "failed to serialize permission policy for external executor: {error}"
3254                ))
3255            })?;
3256            Some(PermissionPolicyContext {
3257                revision: config.policy_revision(),
3258                requested_mode: resolution.requested.as_str().to_string(),
3259                effective_mode: resolution.effective.as_str().to_string(),
3260                bypass_permissions: resolution.bypass_permissions(),
3261                auto_approve_permissions: resolution.suppress_approval_prompts(),
3262                session_id: session.id.clone(),
3263                workspace_path: session.workspace.clone(),
3264                environment_lease: None,
3265                inherit_session_grants: false,
3266                policy,
3267            })
3268        } else {
3269            None
3270        };
3271        let provisioned_permission =
3272            spec.capabilities.permission_resolution().map_err(|error| {
3273                AgentError::LLM(format!("invalid provisioned permission posture: {error}"))
3274            })?;
3275        let policy_resolution = permission_policy
3276            .as_ref()
3277            .map(|context| {
3278                context.resolved_modes().map(|(requested, effective)| {
3279                    bamboo_domain::PermissionModeResolution {
3280                        requested,
3281                        effective,
3282                    }
3283                })
3284            })
3285            .transpose()
3286            .map_err(|error| {
3287                AgentError::LLM(format!("invalid host permission posture: {error}"))
3288            })?;
3289        let host_audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata);
3290        let has_explicit_deny = self.permission_config.as_ref().is_some_and(|config| {
3291            bamboo_tools::permission::explicit_deny_policy_reason(&config.to_serializable())
3292                .is_some()
3293        });
3294        let expected_executor_mapping = expected_permission_executor_mapping(
3295            &spec.executor,
3296            policy_resolution.unwrap_or(provisioned_permission),
3297            has_explicit_deny,
3298        )?;
3299        let mut expected_permission_posture =
3300            expected_executor_mapping.map(|executor_mapping| ExpectedPermissionPosture {
3301                policy_revision: permission_policy
3302                    .as_ref()
3303                    .map(|context| context.revision)
3304                    .or_else(|| host_audit.as_ref().map(|audit| audit.policy_revision))
3305                    .unwrap_or_default(),
3306                resolution: policy_resolution.unwrap_or(provisioned_permission),
3307                expected_audit_revision: host_audit.as_ref().map(|audit| audit.audit_revision),
3308                executor_mapping,
3309            });
3310
3311        // Backpressure: hold a concurrency slot for the lifetime of the *run*
3312        // (cancellation still proceeds — the cancel branch in drive() runs while
3313        // we hold the permit). Released when this fn returns, i.e. once the worker
3314        // is parked back into the pool, so idle workers don't pin slots.
3315        let _slot = if strict_remote.is_some() {
3316            tokio::select! { biased;
3317                _ = cancel_token.cancelled() => return Err(AgentError::Cancelled),
3318                permit = self.concurrency.acquire() => permit.map_err(|_| remote_broker_unavailable())?,
3319            }
3320        } else {
3321            self.concurrency
3322                .acquire()
3323                .await
3324                .map_err(|_| AgentError::LLM("actor concurrency limiter closed".into()))?
3325        };
3326        let _remote_subscription = if strict_remote.is_some() {
3327            Some(tokio::select! { biased;
3328                _ = cancel_token.cancelled() => return Err(AgentError::Cancelled),
3329                guard = self.strict_remote_subscription.lock() => guard,
3330            })
3331        } else {
3332            None
3333        };
3334
3335        // Bamboo-as-provider credentials are minted at the activation boundary,
3336        // after backpressure admits the run and never at worker provisioning.
3337        // This is load-bearing for warm workers: a parked process must never
3338        // retain a token from its previous run. The guard revokes on every return
3339        // path (success, error, cancellation, dispatch failure, or first-frame
3340        // retry exhaustion).
3341        let (run_secrets, _codex_token_guard) = build_codex_run_secrets(
3342            &spec.executor,
3343            self.codex_run_tokens.clone(),
3344            &job.child_session_id,
3345        )?;
3346
3347        // Split LOCAL (spawn + warm-pool) from the two process-less remote paths
3348        // ONLY at the divergent spots — acquire/connect here and the park/retire at
3349        // the end. Everything between (Run dispatch, live-actor registration,
3350        // drive, the close) is identical for all three. `kind` is the single guard.
3351        //   - Local       (#0):  byte-for-byte the pre-#193 reuse-or-spawn path.
3352        //   - Remote       (#1311): connect to a scoped broker peer, no spawn.
3353        //   - Schedulable  (#181): resolve a live worker from the registry, connect.
3354        let kind = match spec.placement {
3355            Placement::Remote { .. } => PlacementKind::Remote,
3356            Placement::Schedulable { .. } => PlacementKind::Schedulable,
3357            Placement::Local => PlacementKind::Local,
3358        };
3359        let remote = !matches!(kind, PlacementKind::Local);
3360        let scoped_registry = if remote {
3361            let store = actor_directory_store
3362                .as_ref()
3363                .ok_or_else(remote_broker_unavailable)?;
3364            Some(
3365                bamboo_storage::v2::FileHostRegistry::new(store.bamboo_home_dir().to_path_buf())
3366                    .await
3367                    .map_err(|_| remote_broker_unavailable())?,
3368            )
3369        } else {
3370            None
3371        };
3372        let scoped_tools = scoped_route.as_ref().map(|route| {
3373            let mut tools = route.requirements.required_tools.clone();
3374            if let Some(ceiling) = &spec.capabilities.native_tool_ceiling {
3375                tools.extend(ceiling.tools.iter().cloned());
3376            }
3377            tools
3378        });
3379
3380        // Stamp WHICH machine this child runs on onto its session metadata, so the
3381        // UI can show it (mirrored into the session index → SessionSummary.placement).
3382        // Only remote/scheduled placements need a stamp — a Local child falls through
3383        // to the DTO default (this backend's own host). Persisted by the caller with
3384        // the rest of the child session after we return.
3385        if let Some(placement_meta) = self.placement_stamp_for(&spec) {
3386            session
3387                .metadata
3388                .insert("placement".to_string(), placement_meta);
3389        }
3390
3391        // Retry-once loop: a pooled local worker can die between its liveness
3392        // check and handling the Run (a tiny TOCTOU window) — its Run then sits
3393        // queued with no server. The first-frame watchdog in `drive` surfaces that
3394        // as `WorkerUnresponsive`; we reap the dead worker and re-acquire ONCE
3395        // (which spawns fresh / reuses the next live one). Remote/schedulable have
3396        // no spawn fallback, so they never retry.
3397        let mut attempt = 0u8;
3398        let (result, actor) = loop {
3399            let execution_epoch = self.next_execution_epoch.fetch_add(1, Ordering::Relaxed) + 1;
3400            let (actor, mut client) = match kind {
3401                PlacementKind::Remote => {
3402                    let route = scoped_route
3403                        .as_ref()
3404                        .ok_or_else(remote_broker_unavailable)?;
3405                    let mailbox_id = route
3406                        .pinned_mailbox
3407                        .clone()
3408                        .ok_or_else(remote_broker_unavailable)?;
3409                    let tls = bamboo_broker::client_config_trusting_cert(&route.ca_cert_file)
3410                        .map_err(|_| remote_broker_unavailable())?;
3411                    let connect =
3412                        bamboo_broker::BrokerChildLink::connect_strict_with_tls_environment_lease(
3413                            &route.endpoint,
3414                            route.parent.clone(),
3415                            &route.token,
3416                            bamboo_subagent::AgentRef {
3417                                session_id: mailbox_id.clone(),
3418                                role: Some(route.worker_role.clone()),
3419                            },
3420                            tls,
3421                        );
3422                    let link = tokio::select! { biased;
3423                        _ = cancel_token.cancelled() => return Err(AgentError::Cancelled),
3424                        result = tokio::time::timeout(Duration::from_secs(30), connect) =>
3425                            result.map_err(|_| remote_broker_unavailable())?.map_err(|_| remote_broker_unavailable())?,
3426                    };
3427                    let record = AgentRecord {
3428                        agent_id: job.child_session_id.clone(),
3429                        role: spec.identity.role.clone(),
3430                        labels: Vec::new(),
3431                        endpoint: String::new(),
3432                        pid: 0,
3433                        version: String::new(),
3434                        started_at: chrono::Utc::now(),
3435                        lease_expires_at: chrono::Utc::now(),
3436                    };
3437                    (
3438                        PooledWorker {
3439                            worker: SpawnedChild::remote(record),
3440                            mailbox_id,
3441                            parked_at: None,
3442                        },
3443                        Box::new(link) as Box<dyn bamboo_subagent::ChildLink>,
3444                    )
3445                }
3446                PlacementKind::Schedulable => {
3447                    let route = scoped_route
3448                        .as_ref()
3449                        .ok_or_else(remote_broker_unavailable)?;
3450                    let registry = scoped_registry
3451                        .as_ref()
3452                        .ok_or_else(remote_broker_unavailable)?;
3453                    let mailbox_id = self
3454                        .select_scoped_worker(
3455                            route,
3456                            registry,
3457                            &session.id,
3458                            project_id.clone(),
3459                            scoped_tools
3460                                .as_ref()
3461                                .ok_or_else(remote_broker_unavailable)?,
3462                        )
3463                        .await?;
3464                    let tls = bamboo_broker::client_config_trusting_cert(&route.ca_cert_file)
3465                        .map_err(|_| remote_broker_unavailable())?;
3466                    let connect =
3467                        bamboo_broker::BrokerChildLink::connect_strict_with_tls_environment_lease(
3468                            &route.endpoint,
3469                            route.parent.clone(),
3470                            &route.token,
3471                            bamboo_subagent::AgentRef {
3472                                session_id: mailbox_id.clone(),
3473                                role: Some(route.worker_role.clone()),
3474                            },
3475                            tls,
3476                        );
3477                    let link = tokio::select! { biased;
3478                        _ = cancel_token.cancelled() => return Err(AgentError::Cancelled),
3479                        result = tokio::time::timeout(Duration::from_secs(30), connect) =>
3480                            result.map_err(|_| remote_broker_unavailable())?.map_err(|_| remote_broker_unavailable())?,
3481                    };
3482                    let actor = PooledWorker {
3483                        worker: SpawnedChild::remote(AgentRecord {
3484                            agent_id: mailbox_id.clone(),
3485                            role: spec.identity.role.clone(),
3486                            labels: Vec::new(),
3487                            endpoint: route.endpoint.clone(),
3488                            pid: 0,
3489                            version: String::new(),
3490                            started_at: chrono::Utc::now(),
3491                            lease_expires_at: chrono::Utc::now(),
3492                        }),
3493                        mailbox_id,
3494                        parked_at: None,
3495                    };
3496                    let client: Box<dyn bamboo_subagent::ChildLink> = Box::new(link);
3497                    (actor, client)
3498                }
3499                PlacementKind::Local => {
3500                    // LOCAL = the mailbox bus (the unified transport): check out a warm
3501                    // pooled worker (reuse a live parked one, else spawn fresh) and
3502                    // drive it by mailbox id — no listen socket, no file discovery, no
3503                    // respawn-on-connect-miss (the broker queues the Run until the
3504                    // worker handles it). The legacy direct-WS path was retired; the bus
3505                    // is required.
3506                    let bus = self.bus.as_ref().ok_or_else(|| {
3507                        AgentError::LLM(
3508                            "local sub-agents require a mailbox bus (subagents.broker); none is \
3509                         configured and the bus could not be embedded"
3510                                .to_string(),
3511                        )
3512                    })?;
3513                    let actor = self.acquire_bus_worker(&pool_key, &spec).await?;
3514                    let parent = bamboo_subagent::AgentRef {
3515                        session_id: format!("p-{}", job.child_session_id),
3516                        role: None,
3517                    };
3518                    let link = bamboo_broker::BrokerChildLink::connect(
3519                        &bus.endpoint,
3520                        parent,
3521                        &bus.token,
3522                        actor.mailbox_id.clone(),
3523                    )
3524                    .await
3525                    .map_err(|e| {
3526                        AgentError::LLM(format!("broker child link connect failed: {e}"))
3527                    })?;
3528                    let client: Box<dyn bamboo_subagent::ChildLink> = Box::new(link);
3529                    (actor, client)
3530                }
3531            };
3532
3533            // Recover an older Run's already Host-checkpointed Event/Outcome
3534            // receipts before dispatching a successor. The private V2 ledger
3535            // binds exact Child birth, Run, transcript prefix and broker
3536            // Maildir identity. A different broker or an unproven final save
3537            // blocks this activation instead of deleting the wrong mailbox.
3538            if let Some(store) = actor_directory_store.as_ref() {
3539                let recovery = async {
3540                    let receipts = store
3541                        .recover_broker_terminal_receipts(session)
3542                        .await
3543                        .map_err(|error| {
3544                            format!("Host broker receipt recovery blocked: {error}")
3545                        })?;
3546                    for receipt in receipts {
3547                        // This path already owns a successor Run and its public
3548                        // running marker. Old committed receipts authorize ACK
3549                        // only; never publish the predecessor over that owner.
3550                        if client.broker_parent_mailbox() != Some(receipt.parent_mailbox.as_str()) {
3551                            return Err(
3552                                "old Child broker parent mailbox changed before ACK".to_string()
3553                            );
3554                        }
3555                        client
3556                            .acknowledge_recovered_durable_frames(
3557                                &receipt.broker_identity,
3558                                &receipt.message_ids,
3559                            )
3560                            .await
3561                            .map_err(|error| {
3562                                format!("old Child broker ACK unconfirmed: {error}")
3563                            })?;
3564                        store
3565                            .clear_acknowledged_broker_terminal_receipt(
3566                                &receipt.session_id,
3567                                receipt.created_at,
3568                                &receipt.activation_run_id,
3569                                &receipt.parent_mailbox,
3570                            )
3571                            .await
3572                            .map_err(|error| {
3573                                format!("old Child broker ACK cleanup failed: {error}")
3574                            })?;
3575                    }
3576                    Ok::<(), String>(())
3577                }
3578                .await;
3579                if let Err(error) = recovery {
3580                    if !remote {
3581                        actor.worker.kill().await;
3582                    }
3583                    return Err(AgentError::LLM(error));
3584                }
3585            }
3586
3587            // Publish the actor delivery owner and claim the complete bounded
3588            // authorized prefix before dispatching Run. These deliveries ride
3589            // inside RunSpec, so the worker durably enqueues them before its
3590            // first provider boundary rather than racing a later steer frame.
3591            let (delivery_tx, mut delivery_rx) = mpsc::unbounded_channel::<u64>();
3592            let bound_activation_run_id = match session_inbox_runtime.as_ref() {
3593                Some(binding) => {
3594                    let run_id = binding
3595                        .router
3596                        .attach_delivery_sink(&job.child_session_id, delivery_tx.clone())
3597                        .await;
3598                    if run_id.is_none() {
3599                        tracing::debug!(
3600                            session_id = %job.child_session_id,
3601                            "actor driver had no current SessionInbox activation owner to bind"
3602                        );
3603                    }
3604                    run_id
3605                }
3606                None => None,
3607            };
3608            tracing::Span::current().record(
3609                "activation_run_id",
3610                bound_activation_run_id.as_deref().unwrap_or("unbound"),
3611            );
3612            drop(delivery_tx);
3613            let initial_pairs = match (
3614                session_inbox_runtime.as_ref(),
3615                bound_activation_run_id.as_deref(),
3616            ) {
3617                (Some(binding), Some(run_id)) if plain_actor_store.is_none() && !remote => {
3618                    match claim_canonical_deliveries(binding, session, run_id, usize::MAX, None)
3619                        .await
3620                    {
3621                        Ok(deliveries) => deliveries,
3622                        Err(error) => {
3623                            binding
3624                                .router
3625                                .detach_delivery_sink(&job.child_session_id, run_id)
3626                                .await;
3627                            if !remote {
3628                                actor.worker.kill().await;
3629                            }
3630                            return Err(error);
3631                        }
3632                    }
3633                }
3634                _ => Vec::new(),
3635            };
3636            let mut initial_session_messages = initial_pairs
3637                .iter()
3638                .map(|(_, delivery)| delivery.clone())
3639                .collect::<Vec<_>>();
3640            let mut initial_inflight_claims = initial_pairs
3641                .into_iter()
3642                .map(|(claim, _)| claim)
3643                .collect::<VecDeque<_>>();
3644            // Recompute after claim reconciliation: a warm retry may have had a
3645            // canonical receipt whose transcript proof was restored above.
3646            let mut messages = if let Some(binding) = &required_context {
3647                binding
3648                    .validate_messages(&session.id, &session.messages)
3649                    .map_err(|error| AgentError::Budget(error.to_string()))?;
3650                session
3651                    .messages
3652                    .iter()
3653                    .map(serde_json::to_value)
3654                    .collect::<std::result::Result<Vec<_>, _>>()
3655                    .map_err(|_| {
3656                        AgentError::Budget(
3657                            bamboo_domain::ChildContextPacketError::Invalid.to_string(),
3658                        )
3659                    })?
3660            } else {
3661                session
3662                    .messages
3663                    .iter()
3664                    .filter_map(|message| serde_json::to_value(message).ok())
3665                    .collect()
3666            };
3667
3668            if strict_remote.is_some() {
3669                let current =
3670                    remote_canonical_lineage(session_inbox_runtime.as_ref().unwrap(), session)
3671                        .await;
3672                if current.as_ref().ok() != lineage.as_ref()
3673                    || bound_activation_run_id.as_deref().is_none_or(str::is_empty)
3674                    || cancel_token.is_cancelled()
3675                {
3676                    if let (Some(binding), Some(run_id)) = (
3677                        session_inbox_runtime.as_ref(),
3678                        bound_activation_run_id.as_deref(),
3679                    ) {
3680                        binding
3681                            .router
3682                            .detach_delivery_sink(&job.child_session_id, run_id)
3683                            .await;
3684                    }
3685                    return Err(if cancel_token.is_cancelled() {
3686                        AgentError::Cancelled
3687                    } else {
3688                        remote_broker_unavailable()
3689                    });
3690                }
3691            }
3692            if let Some(expected) = creation.as_ref() {
3693                let checked =
3694                    canonical_child_creation(session_inbox_runtime.as_ref().unwrap(), session, job)
3695                        .await;
3696                if checked.as_ref().ok() != Some(expected) {
3697                    if let (Some(binding), Some(run_id)) = (
3698                        session_inbox_runtime.as_ref(),
3699                        bound_activation_run_id.as_deref(),
3700                    ) {
3701                        binding
3702                            .router
3703                            .detach_delivery_sink(&job.child_session_id, run_id)
3704                            .await;
3705                    }
3706                    actor.worker.kill().await;
3707                    return Err(AgentError::LLM(
3708                        "child creation identity changed before dispatch".into(),
3709                    ));
3710                }
3711            }
3712            if let Some(store) = &plain_actor_store {
3713                let started = async {
3714                    // The Run posture was resolved from Host configuration
3715                    // before provisioning. Bind that exact revision and the
3716                    // Host-selected local placement before claiming authority;
3717                    // a worker event or display placement cannot supply either.
3718                    let policy = permission_policy
3719                        .as_ref()
3720                        .ok_or_else(|| plain_actor_unsupported())?;
3721                    let config = self
3722                        .permission_config
3723                        .as_ref()
3724                        .ok_or_else(|| plain_actor_unsupported())?;
3725                    if config.policy_revision() != policy.revision
3726                        || !matches!(spec.placement, Placement::Local)
3727                    {
3728                        return Err(plain_actor_unsupported());
3729                    }
3730                    store
3731                        .bind_control_plane(
3732                            &session.id,
3733                            bamboo_domain::ActorControlPlaneBinding {
3734                                policy_revision: Some(policy.revision),
3735                                placement_intent: Some(bamboo_domain::ActorPlacementIntent::Local),
3736                            },
3737                        )
3738                        .await
3739                        .map_err(|error| AgentError::LLM(error.to_string()))?;
3740                    PlainActorActivation::start_and_prepare(
3741                        store.clone(),
3742                        session,
3743                        session_inbox_runtime.as_ref().unwrap(),
3744                        bound_activation_run_id.as_deref(),
3745                        self.permission_config.clone(),
3746                        !readonly_actor,
3747                        spec.capabilities
3748                            .initial_input_release_required
3749                            .then_some(actor.mailbox_id.as_str()),
3750                    )
3751                    .await
3752                }
3753                .await;
3754                match started {
3755                    Ok((activation, prepared)) => {
3756                        if let Some((prefix, deliveries)) = prepared {
3757                            messages = prefix;
3758                            initial_session_messages = deliveries;
3759                        }
3760                        plain_activation = Some(activation);
3761                    }
3762                    Err(error) => {
3763                        if let Some(run_id) = bound_activation_run_id.as_deref() {
3764                            session_inbox_runtime
3765                                .as_ref()
3766                                .unwrap()
3767                                .router
3768                                .detach_delivery_sink(&job.child_session_id, run_id)
3769                                .await;
3770                        }
3771                        actor.worker.kill().await;
3772                        return Err(error);
3773                    }
3774                }
3775            }
3776            let mut logical_identity = logical_identity_for_actor_run(session, job);
3777            logical_identity.creation = creation.clone();
3778            let run_permission_policy = if strict_remote.is_some() {
3779                let prepared = async {
3780                    let mut policy = permission_policy.clone().ok_or_else(|| {
3781                        AgentError::LLM("remote_environment_permission_policy_missing".into())
3782                    })?;
3783                    let workspace = session.workspace.as_deref().ok_or_else(|| {
3784                        AgentError::LLM("remote_environment_workspace_missing".into())
3785                    })?;
3786                    let run_id = bound_activation_run_id.as_deref().ok_or_else(|| {
3787                        AgentError::LLM("remote_environment_activation_missing".into())
3788                    })?;
3789                    let lease = bamboo_subagent::environment::EnvironmentLease::capture(
3790                        workspace,
3791                        &session.id,
3792                        run_id,
3793                        execution_epoch,
3794                    )
3795                    .await
3796                    .map_err(|error| AgentError::LLM(error.into()))?;
3797                    policy.workspace_path = None;
3798                    policy.environment_lease = Some(lease);
3799                    Ok::<_, AgentError>(Some(policy))
3800                }
3801                .await;
3802                match prepared {
3803                    Ok(policy) => policy,
3804                    Err(error) => {
3805                        if let (Some(binding), Some(run_id)) = (
3806                            session_inbox_runtime.as_ref(),
3807                            bound_activation_run_id.as_deref(),
3808                        ) {
3809                            binding
3810                                .router
3811                                .detach_delivery_sink(&job.child_session_id, run_id)
3812                                .await;
3813                        }
3814                        return Err(error);
3815                    }
3816                }
3817            } else {
3818                permission_policy.clone()
3819            };
3820            if session
3821                .metadata
3822                .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
3823            {
3824                let admission = async {
3825                    let service = ticket_service
3826                        .as_ref()
3827                        .ok_or_else(|| AgentError::LLM("Ticket authority unavailable".into()))?;
3828                    let runtime = session_inbox_runtime.as_ref().ok_or_else(|| {
3829                        AgentError::LLM("Ticket activation runtime unavailable".into())
3830                    })?;
3831                    let run_id = bound_activation_run_id
3832                        .as_deref()
3833                        .ok_or_else(|| AgentError::LLM("Ticket activation owner missing".into()))?;
3834                    crate::ticket_runtime::admit_ticket_run(service, runtime, session, run_id)
3835                        .await
3836                        .map_err(|e| AgentError::LLM(e.to_string()))
3837                }
3838                .await;
3839                if let Err(error) = admission {
3840                    if let (Some(runtime), Some(run_id)) = (
3841                        session_inbox_runtime.as_ref(),
3842                        bound_activation_run_id.as_deref(),
3843                    ) {
3844                        runtime
3845                            .router
3846                            .detach_delivery_sink(&job.child_session_id, run_id)
3847                            .await;
3848                    }
3849                    actor.worker.kill().await;
3850                    return Err(error);
3851                }
3852            }
3853            let mut run_spec = RunSpec {
3854                // Cloned (not moved) so a retry can re-dispatch to a fresh worker.
3855                assignment: assignment.clone(),
3856                logical_session: Some(logical_identity),
3857                project_id: project_id.clone(),
3858                // Creation/update already resolves the child's explicit
3859                // selection and own role preference into this field. Do
3860                // not infer effort from a possibly inherited model_ref.
3861                reasoning_effort: if !remote && matches!(self.executor, ExecutorSpec::BambooRuntime)
3862                {
3863                    session
3864                        .reasoning_effort
3865                        .map(|effort| effort.as_str().to_owned())
3866                } else {
3867                    None
3868                },
3869                permission_policy: run_permission_policy,
3870                messages,
3871                activation_run_id: bound_activation_run_id.clone(),
3872                execution_epoch,
3873                initial_session_messages,
3874                secrets: run_secrets.clone(),
3875            };
3876            let mut active_lease = if remote {
3877                let reserve = ActivePlacementLease::reserve(
3878                    scoped_registry
3879                        .as_ref()
3880                        .ok_or_else(remote_broker_unavailable)?
3881                        .clone(),
3882                    actor_directory_store
3883                        .as_ref()
3884                        .ok_or_else(remote_broker_unavailable)?
3885                        .clone(),
3886                    scoped_route
3887                        .as_ref()
3888                        .ok_or_else(remote_broker_unavailable)?
3889                        .clone(),
3890                    actor.mailbox_id.clone(),
3891                    session.id.clone(),
3892                    bound_activation_run_id
3893                        .clone()
3894                        .ok_or_else(remote_broker_unavailable)?,
3895                    project_id.clone(),
3896                    scoped_tools.clone().ok_or_else(remote_broker_unavailable)?,
3897                    &cancel_token,
3898                )
3899                .await;
3900                match reserve {
3901                    Ok(lease) => Some(lease),
3902                    Err(error) => {
3903                        if let (Some(binding), Some(run_id)) = (
3904                            session_inbox_runtime.as_ref(),
3905                            bound_activation_run_id.as_deref(),
3906                        ) {
3907                            binding
3908                                .router
3909                                .detach_delivery_sink(&job.child_session_id, run_id)
3910                                .await;
3911                        }
3912                        return Err(error);
3913                    }
3914                }
3915            } else {
3916                None
3917            };
3918            if let Some(lease) = active_lease.as_mut() {
3919                let seeded = async {
3920                    let binding = session_inbox_runtime
3921                        .as_ref()
3922                        .ok_or_else(remote_broker_unavailable)?;
3923                    let run_id = bound_activation_run_id
3924                        .as_deref()
3925                        .ok_or_else(remote_broker_unavailable)?;
3926                    let pairs = claim_canonical_deliveries(
3927                        binding,
3928                        session,
3929                        run_id,
3930                        usize::MAX,
3931                        Some((lease.actor_store.as_ref(), &lease.fence)),
3932                    )
3933                    .await?;
3934                    if let Some(binding) = &required_context {
3935                        binding
3936                            .validate_messages(&session.id, &session.messages)
3937                            .map_err(|error| AgentError::Budget(error.to_string()))?;
3938                    }
3939                    run_spec.messages = session
3940                        .messages
3941                        .iter()
3942                        .map(serde_json::to_value)
3943                        .collect::<std::result::Result<Vec<_>, _>>()
3944                        .map_err(|_| remote_broker_unavailable())?;
3945                    run_spec.initial_session_messages =
3946                        pairs.iter().map(|(_, delivery)| delivery.clone()).collect();
3947                    initial_inflight_claims = pairs
3948                        .into_iter()
3949                        .map(|(claim, _)| claim)
3950                        .collect::<VecDeque<_>>();
3951                    Ok::<(), AgentError>(())
3952                }
3953                .await;
3954                if let Err(error) = seeded {
3955                    if let Err(rollback_error) = lease.abort_before_dispatch().await {
3956                        tracing::warn!(stage = "claim_pre_dispatch_rollback", reason = %rollback_error, "remote claim rollback failed");
3957                    }
3958                    if let (Some(binding), Some(run_id)) = (
3959                        session_inbox_runtime.as_ref(),
3960                        bound_activation_run_id.as_deref(),
3961                    ) {
3962                        binding
3963                            .router
3964                            .detach_delivery_sink(&job.child_session_id, run_id)
3965                            .await;
3966                    }
3967                    return Err(error);
3968                }
3969            }
3970            let mut run_send_invoked = false;
3971            let dispatch = async {
3972                if let Some(activation) = plain_activation
3973                    .as_ref()
3974                    .filter(|activation| !activation.initial_inputs.is_empty())
3975                {
3976                    activation.refresh_continuation_posture(
3977                        session,
3978                        &run_spec,
3979                        &mut expected_permission_posture,
3980                    )?;
3981                }
3982                send_actor_run(&mut *client, run_spec.clone(), &mut run_send_invoked).await
3983            }
3984            .await;
3985            if let Err(e) = dispatch {
3986                if remote {
3987                    tracing::warn!(stage = "run_dispatch", "remote placement admission failed");
3988                }
3989                if let (Some(binding), Some(run_id)) = (
3990                    session_inbox_runtime.as_ref(),
3991                    bound_activation_run_id.as_deref(),
3992                ) {
3993                    binding
3994                        .router
3995                        .detach_delivery_sink(&job.child_session_id, run_id)
3996                        .await;
3997                }
3998                if !remote {
3999                    actor.worker.kill().await;
4000                }
4001                if let Some(activation) = &plain_activation {
4002                    activation.finish(ActorActivationFinish::Failed).await?;
4003                }
4004                if let Some(lease) = active_lease.as_mut() {
4005                    match lease.abort_if_run_unsent(run_send_invoked).await {
4006                        Ok(false) => {
4007                            tracing::warn!(stage = "run_dispatch_unconfirmed", "remote Run send failed after publication became possible; retaining Actor activation and slot until terminal proof or expiry");
4008                        }
4009                        Ok(true) => {}
4010                        Err(rollback_error) => {
4011                            tracing::warn!(stage = "run_pre_dispatch_rollback", reason = %rollback_error, "remote Run rollback failed");
4012                        }
4013                    }
4014                }
4015                return Err(e);
4016            }
4017
4018            // Register as a live actor so send_message (running, no interrupt) can
4019            // steer this child in-band over the existing WS connection. The guard
4020            // unregisters on every exit path.
4021            let (live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
4022            let live_guard = super::live::register(
4023                &job.child_session_id,
4024                live_tx,
4025                attempt as u32,
4026                self.approval_registry.clone(),
4027            );
4028
4029            let mut strict_history = None;
4030            let result = drive(ActorDriveContext {
4031                client: &mut *client,
4032                parent_session_id: &job.parent_session_id,
4033                child_session_id: &job.child_session_id,
4034                child_attempt: attempt as u32,
4035                approval_registry: self.approval_registry.as_ref(),
4036                approval_decider: self.approval_decider.as_ref(),
4037                approval_reviewer: self.approval_reviewer.as_ref(),
4038                escalation_bridge: escalation.clone(),
4039                event_tx: &event_tx,
4040                cancel_token: active_lease
4041                    .as_ref()
4042                    .map_or(&cancel_token, |lease| &lease.run_cancel),
4043                live_rx: &mut live_rx,
4044                delivery_rx: &mut delivery_rx,
4045                logical_session: session,
4046                expected_permission_posture: expected_permission_posture.clone(),
4047                expected_creation: creation.as_ref(),
4048                session_inbox_runtime: session_inbox_runtime.as_ref(),
4049                actor_directory_store: actor_directory_store.as_deref(),
4050                canonical_subagent_tool: canonical_subagent_tool.clone(),
4051                ticket_service: ticket_service.clone(),
4052                activation_run_id: bound_activation_run_id.as_deref(),
4053                execution_epoch,
4054                expected_source_actor_id: &actor.mailbox_id,
4055                initial_inflight_claims,
4056                // First-frame watchdog for EVERY placement: a wedged-but-connected
4057                // worker (subscribed ≠ serving — e.g. stuck on a prior LLM call) emits
4058                // no first frame; without a deadline drive() blocks forever. Bounding it
4059                // turns the "running-but-unresponsive" hang into a recoverable
4060                // WorkerUnresponsive (reap+respawn local / re-pick schedulable / error
4061                // on a fixed remote endpoint).
4062                plain_actor: plain_activation.is_some(),
4063                remote_environment_lease: strict_remote.is_some(),
4064                readonly_output: if readonly_actor {
4065                    Some(&mut readonly_messages)
4066                } else {
4067                    None
4068                },
4069                local_history_tools: local_history_tools.as_deref(),
4070                local_history_read_only: spec.capabilities.read_only_enforced(),
4071                strict_history_output: Some(&mut strict_history),
4072                plain_input: plain_activation.as_ref(),
4073                canonical_activation: active_lease.as_ref().map(|lease| &lease.fence),
4074                canonical_placement_ref: active_lease.as_ref().map(|lease| &lease.placement_ref),
4075                actor_event_observer: actor_event_observer.as_deref(),
4076                // BrokerChildLink replaces the actual correlation for each Run;
4077                // direct legacy WS Terminal frames carry no such identity.
4078                plain_run: self
4079                    .bus
4080                    .as_ref()
4081                    .map(|_| (&run_spec, &self.next_execution_epoch)),
4082                first_frame_timeout: Some(WORKER_FIRST_FRAME_TIMEOUT),
4083            })
4084            .await;
4085            let mut result = if active_lease
4086                .as_ref()
4087                .is_some_and(ActivePlacementLease::lost)
4088            {
4089                Err(remote_broker_unavailable())
4090            } else {
4091                result
4092            };
4093            if local_history_tools.is_none() {
4094                let reply = result
4095                    .as_ref()
4096                    .ok()
4097                    .and_then(|reply| reply.as_deref())
4098                    .filter(|reply| !reply.is_empty())
4099                    .map(str::to_owned);
4100                if let (Some(lease), Some(reply)) = (active_lease.as_ref(), reply) {
4101                    if let Err(error) = append_fenced_remote_reply(
4102                        lease.actor_store.as_ref(),
4103                        &lease.fence,
4104                        session,
4105                        &reply,
4106                    )
4107                    .await
4108                    {
4109                        result = Err(error);
4110                    }
4111                }
4112            }
4113            if remote {
4114                if let Err(error) = &result {
4115                    let code = match error {
4116                        AgentError::LLM(message) if message.starts_with("actor event") => {
4117                            "actor_event"
4118                        }
4119                        AgentError::LLM(message) if message.starts_with("remote_actor") => {
4120                            "remote_actor"
4121                        }
4122                        AgentError::LLM(message) if message.starts_with("remote_environment") => {
4123                            if message.len() <= 128
4124                                && message.bytes().all(|byte| {
4125                                    byte.is_ascii_lowercase()
4126                                        || byte.is_ascii_digit()
4127                                        || byte == b'_'
4128                                })
4129                            {
4130                                message.as_str()
4131                            } else {
4132                                "environment"
4133                            }
4134                        }
4135                        AgentError::LLM(message) if message.contains("permission posture") => {
4136                            "permission_posture"
4137                        }
4138                        AgentError::LLM(message) if message.contains("broker") => "broker",
4139                        AgentError::LLM(_) => "other_llm",
4140                        AgentError::WorkerUnresponsive(_) => "unresponsive",
4141                        AgentError::Cancelled => "cancelled",
4142                        _ => "other",
4143                    };
4144                    tracing::warn!(stage = "actor_drive", code, "remote actor run failed");
4145                }
4146            }
4147            if let Some(lease) = active_lease.as_mut() {
4148                let accepted_terminal = client
4149                    .durable_delivery_receipt()
4150                    .map(|receipt| receipt.terminal_status);
4151                if let Err(error) = lease
4152                    .complete(
4153                        remote_activation_finish(&result, accepted_terminal),
4154                        client.has_pending_durable_terminal(),
4155                    )
4156                    .await
4157                {
4158                    tracing::warn!(
4159                        stage = "actor_complete",
4160                        "remote placement admission failed"
4161                    );
4162                    if let (Some(binding), Some(run_id)) = (
4163                        session_inbox_runtime.as_ref(),
4164                        bound_activation_run_id.as_deref(),
4165                    ) {
4166                        binding
4167                            .router
4168                            .detach_delivery_sink(&job.child_session_id, run_id)
4169                            .await;
4170                    }
4171                    drop(live_guard);
4172                    drop(client); // No Host checkpoint proof means no broker ACK.
4173                    drop(active_lease);
4174                    return Err(error);
4175                }
4176            }
4177            drop(active_lease);
4178            if let (Some(binding), Some(run_id)) = (
4179                session_inbox_runtime.as_ref(),
4180                bound_activation_run_id.as_deref(),
4181            ) {
4182                binding
4183                    .router
4184                    .detach_delivery_sink(&job.child_session_id, run_id)
4185                    .await;
4186            }
4187            // Unregister IMMEDIATELY: after drive returns nobody consumes live_rx,
4188            // so a send_message landing in the close/park window below must see
4189            // "not live" and take the durable-queue fallback instead of vanishing.
4190            // (Even if one slipped in earlier, send_message also appends it to the
4191            // durable transcript, so the next activation still rehydrates it.)
4192            drop(live_guard);
4193            // Keep only a fully processed broker terminal's exact mailbox
4194            // receipts alive until the SDK has saved this Child's canonical
4195            // transcript and final status. No run fence means no Host proof.
4196            if client.has_pending_durable_terminal() {
4197                if let Some(run_id) = bound_activation_run_id.as_deref() {
4198                    let key = (session.id.clone(), session.created_at, run_id.to_owned());
4199                    let host_nested_wait_handoff = session
4200                        .metadata
4201                        .get("runtime.actor_nested_wait_handoff")
4202                        .is_some_and(|value| value == "true");
4203                    let mut pending = self.pending_durable_links.lock().await;
4204                    if pending.contains_key(&key) {
4205                        return Err(AgentError::LLM(
4206                            "child broker terminal receipt already awaits Host checkpoint".into(),
4207                        ));
4208                    }
4209                    pending.insert(
4210                        key,
4211                        PendingDurableChildLink {
4212                            link: client,
4213                            host_nested_wait_handoff,
4214                            strict_history,
4215                            strict_history_required: local_history_tools.is_some(),
4216                            strict_history_committed: false,
4217                        },
4218                    );
4219                } else {
4220                    // A Worker-local transcript is not Host checkpoint proof.
4221                    drop(client);
4222                }
4223            } else {
4224                drop(client);
4225            }
4226
4227            // Retry only a local worker that we can kill. A silent remote
4228            // Worker may still be executing its first Run; starting the same
4229            // Actor on another Host before a terminal fence would duplicate it.
4230            if required_context.is_none()
4231                && attempt == 0
4232                && matches!(kind, PlacementKind::Local)
4233                && matches!(result, Err(AgentError::WorkerUnresponsive(_)))
4234            {
4235                tracing::warn!(
4236                    "actor child {} got no first frame; reaping the worker and respawning once",
4237                    job.child_session_id
4238                );
4239                actor.worker.kill().await;
4240                attempt += 1;
4241                continue;
4242            }
4243            break (result, actor);
4244        };
4245
4246        let result = if strict_remote.is_some() {
4247            result.map_err(|error| match &error {
4248                AgentError::Cancelled => error,
4249                AgentError::LLM(message) => remote_environment_error_code(message)
4250                    .map(|code| AgentError::LLM(code.to_owned()))
4251                    .unwrap_or_else(remote_broker_unavailable),
4252                _ => remote_broker_unavailable(),
4253            })
4254        } else {
4255            result
4256        };
4257
4258        // Park the warm worker for reuse on a clean run, or kill it on
4259        // error/cancel (a wedged worker must not be reused). Remote / schedulable
4260        // workers are registry-managed — never ours to pool/kill, just drop.
4261        if remote {
4262            drop(actor);
4263        } else if required_context.is_some() {
4264            if session
4265                .metadata
4266                .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
4267            {
4268                let service = self.ticket_service.lock().recover_poison().clone();
4269                let runtime = self.session_inbox_runtime.lock().recover_poison().clone();
4270                let owned_run_id = crate::ticket_runtime::read_dispatch(session)
4271                    .ok()
4272                    .flatten()
4273                    .and_then(|dispatch| dispatch.receipt.map(|receipt| receipt.run_id));
4274                match actor.worker.kill_confirmed().await {
4275                    Ok(proof) => {
4276                        if let (Some(service), Some(runtime), Some(run_id)) =
4277                            (service, runtime, owned_run_id.as_deref())
4278                        {
4279                            if let Err(error) = crate::ticket_runtime::checkpoint_owned_ticket_stop(
4280                                &service,
4281                                &runtime,
4282                                session,
4283                                run_id,
4284                                &proof,
4285                                result.is_ok(),
4286                            )
4287                            .await
4288                            {
4289                                // The canonical terminal save can retain output
4290                                // and retry this stop fact before broker ACK.
4291                                tracing::warn!(child_id = %session.id, %error, "Ticket owned-process stop publication retained for reconciliation");
4292                            }
4293                        }
4294                    }
4295                    Err(error) => {
4296                        tracing::warn!(child_id = %session.id, %error, "Ticket process stop remains unconfirmed; resources retained");
4297                    }
4298                }
4299            } else {
4300                actor.worker.kill().await;
4301            }
4302        } else {
4303            match &result {
4304                Ok(_) => self.release_bus_worker(&pool_key, actor).await,
4305                Err(_) => actor.worker.kill().await,
4306            }
4307        }
4308
4309        if let Some(activation) = plain_activation {
4310            return match result {
4311                Ok(Some(text)) if !text.is_empty() => {
4312                    if readonly_actor {
4313                        let messages = std::mem::take(&mut readonly_messages);
4314                        let committed = activation
4315                            .store
4316                            .append_actor_transcript(bamboo_storage::ActorTranscriptAppend {
4317                                fence: activation.fence.clone(),
4318                                expected_created_at: activation.created_at,
4319                                expected_messages: session.messages.clone(),
4320                                expected_provider_transcript: activation
4321                                    .provider_transcript
4322                                    .clone(),
4323                                messages: messages.clone(),
4324                                native_groups: Vec::new(),
4325                            })
4326                            .await;
4327                        return match committed {
4328                            Ok(committed) => {
4329                                *session = committed;
4330                                for message in messages {
4331                                    let _ = event_tx
4332                                        .send(AgentEvent::message_appended(&session.id, &message))
4333                                        .await;
4334                                }
4335                                activation
4336                                    .finish_with_observer(
4337                                        ActorActivationFinish::Succeeded,
4338                                        actor_event_observer.as_deref(),
4339                                    )
4340                                    .await
4341                            }
4342                            Err(error) => {
4343                                let _ = activation.finish(ActorActivationFinish::Failed).await;
4344                                Err(AgentError::LLM(format!(
4345                                    "actor reply commit failed: {error}"
4346                                )))
4347                            }
4348                        };
4349                    }
4350                    match activation.append_reply(session, text, &event_tx).await {
4351                        Ok(()) => {
4352                            activation
4353                                .finish_with_observer(
4354                                    ActorActivationFinish::Succeeded,
4355                                    actor_event_observer.as_deref(),
4356                                )
4357                                .await
4358                        }
4359                        Err(error) => {
4360                            // OutcomeUnconfirmed is an error, never ordinary-save fallback.
4361                            let _ = activation.finish(ActorActivationFinish::Failed).await;
4362                            Err(AgentError::LLM(format!(
4363                                "actor reply commit failed: {error}"
4364                            )))
4365                        }
4366                    }
4367                }
4368                other => {
4369                    let outcome = if matches!(&other, Err(AgentError::Cancelled)) {
4370                        ActorActivationFinish::Cancelled
4371                    } else {
4372                        ActorActivationFinish::Failed
4373                    };
4374                    activation.finish(outcome).await?;
4375                    match other {
4376                        Err(error) => Err(error),
4377                        _ => Err(plain_actor_unsupported()),
4378                    }
4379                }
4380            };
4381        }
4382
4383        // Write-back: persist the actor's final reply onto the child session so
4384        // the transcript survives and the NEXT activation sees it as history.
4385        // (run_child_spawn saves the session right after we return.)
4386        match result {
4387            Ok(Some(text)) => {
4388                if !remote && local_history_tools.is_none() && !text.is_empty() {
4389                    session.add_message(bamboo_agent_core::Message::assistant(text, None));
4390                }
4391                Ok(())
4392            }
4393            Ok(None) => Ok(()),
4394            Err(e) => Err(e),
4395        }
4396    }
4397}
4398
4399// This is a conservative no-replay observation, not Actor authority. The
4400// canonical mapping comes from Storage; record/marker names match its writer.
4401async fn require_unowned_glob(
4402    store: &bamboo_storage::SessionStoreV2,
4403    session: &Session,
4404) -> Result<(), AgentError> {
4405    let rel = store
4406        .resolve_rel_path(&session.id)
4407        .await
4408        .ok_or_else(|| plain_actor_unsupported())?;
4409    if !store.bamboo_home_dir().is_absolute()
4410        || rel
4411            != format!(
4412                "sessions/{}/children/{}",
4413                session.root_session_id, session.id
4414            )
4415    {
4416        return Err(plain_actor_unsupported());
4417    }
4418    let directory = store.bamboo_home_dir().join(rel);
4419    let mut checked = PathBuf::new();
4420    for component in directory.components() {
4421        if !matches!(
4422            component,
4423            std::path::Component::RootDir | std::path::Component::Normal(_)
4424        ) {
4425            return Err(plain_actor_unsupported());
4426        }
4427        checked.push(component.as_os_str());
4428        if !tokio::fs::symlink_metadata(&checked)
4429            .await
4430            .map_err(|_| plain_actor_unsupported())?
4431            .is_dir()
4432        {
4433            return Err(plain_actor_unsupported());
4434        }
4435    }
4436    for name in ["actor-authority.json", "actor-authority.initialized.json"] {
4437        match tokio::fs::symlink_metadata(directory.join(name)).await {
4438            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
4439            _ => return Err(plain_actor_unsupported()),
4440        }
4441    }
4442    Ok(())
4443}
4444
4445// Zero-tool or read-only Glob named Child of a durable Ultra Root.
4446// Glob uses its typed tail; zero-tool supports new input after a plain completion
4447// as well as two bounded corrections within a Running activation.
4448// An expired initial owner can be replaced with one new input through the same
4449// checkpoint/release boundary. No lease renewal, raw steering or remote activation.
4450struct PlainActorActivation {
4451    store: Arc<bamboo_storage::SessionStoreV2>,
4452    fence: ActorActivationFence,
4453    inbox_generation: u64,
4454    created_at: chrono::DateTime<chrono::Utc>,
4455    provider_transcript: bamboo_domain::ProviderTranscriptState,
4456    input_consumer: SessionInboxConsumerId,
4457    input_deadline: chrono::DateTime<chrono::Utc>,
4458    actor_deadline: chrono::DateTime<chrono::Utc>,
4459    initial_release_required: bool,
4460    input_inbox: bamboo_storage::FileSessionInbox,
4461    permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
4462    initial_inputs: Vec<SessionInboxOwnedClaim>,
4463    initial_input_count: usize,
4464    recovering_pre_ack: bool,
4465    completion_router: std::sync::Mutex<Option<ActorEventRouter>>,
4466}
4467type PlainInitialDelivery = (Vec<serde_json::Value>, SessionMessageDelivery);
4468type PlainInitialBatch = (Vec<serde_json::Value>, Vec<SessionMessageDelivery>);
4469#[track_caller]
4470fn plain_actor_unsupported() -> AgentError {
4471    let source = std::panic::Location::caller();
4472    tracing::warn!(
4473        process_id = std::process::id(),
4474        source_file = source.file(),
4475        source_line = source.line(),
4476        source_column = source.column(),
4477        "plain Actor continuation unsupported"
4478    );
4479    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())
4480}
4481fn plain_initial_history(session: &Session) -> bool {
4482    !session.messages.iter().any(|message| {
4483        matches!(message.role, Role::Assistant | Role::Tool)
4484            || message
4485                .metadata
4486                .as_ref()
4487                .is_some_and(|metadata| metadata.get("_bamboo_owned_input_checkpoint").is_some())
4488    }) && session.provider_transcript.is_empty()
4489        && session.session_inbox_admission().is_none()
4490        && session.pending_injected_messages().is_none()
4491}
4492// Selection only. Canonical terminal status/new input are checked at start;
4493// the existing fenced input checkpoint validates the complete durable prefix.
4494fn plain_completed_history(session: &Session) -> bool {
4495    session.provider_transcript.is_empty()
4496        && session.pending_injected_messages().is_none()
4497        && session
4498            .messages
4499            .last()
4500            .is_some_and(|message| message.role == Role::Assistant && !message.content.is_empty())
4501        && !session
4502            .messages
4503            .iter()
4504            .any(|message| message.role == Role::Tool || message.tool_calls.is_some())
4505}
4506// Selection only. Storage's closed raw bookkeeper/current-prefix validator is
4507// still mandatory after the actual replacement claim; this metadata is no grant.
4508fn pre_ack_input(
4509    session: &Session,
4510) -> Result<Option<(bamboo_domain::SessionMessageId, u64)>, AgentError> {
4511    let Some(message) = session.messages.last() else {
4512        return Ok(None);
4513    };
4514    let Some(proof) = message
4515        .metadata
4516        .as_ref()
4517        .and_then(|v| v.get("_bamboo_owned_input_checkpoint"))
4518    else {
4519        return Ok(None);
4520    };
4521    let id = bamboo_domain::SessionMessageId::parse(message.id.clone())
4522        .map_err(|_| plain_actor_unsupported())?;
4523    let generation = proof
4524        .get("generation")
4525        .and_then(serde_json::Value::as_u64)
4526        .filter(|g| *g > 0)
4527        .ok_or_else(|| plain_actor_unsupported())?;
4528    if message.role != Role::User
4529        || session
4530            .messages
4531            .iter()
4532            .filter(|m| m.id == message.id)
4533            .count()
4534            != 1
4535        || !session
4536            .session_inbox_admission()
4537            .is_some_and(|cursor| cursor.contains(&id))
4538        || !session.provider_transcript.is_empty()
4539        || session.pending_injected_messages().is_some()
4540        || session
4541            .messages
4542            .iter()
4543            .any(|m| m.role == Role::Tool || m.tool_calls.is_some())
4544    {
4545        return Err(plain_actor_unsupported());
4546    }
4547    Ok(Some((id, generation)))
4548}
4549fn qualified_pre_ack_owner(
4550    entry: &bamboo_domain::ActorDirectoryEntry,
4551    now: chrono::DateTime<chrono::Utc>,
4552) -> bool {
4553    let Some(old) = entry.activation.as_ref() else {
4554        return false;
4555    };
4556    let recoverable = (entry.actor.state == ActorLogicalState::Failed
4557        && old.status == bamboo_domain::ActorActivationStatus::Failed)
4558        || (entry.actor.state == ActorLogicalState::Active
4559            && old.status.is_live()
4560            && old.lease_expires_at <= now);
4561    let trusted = old.placement_ref.as_ref().is_some_and(|placement| {
4562        placement.class == bamboo_domain::ActorPlacementClass::Local
4563            && placement
4564                .lease_id
4565                .strip_prefix("owned-initial-release-v1:")
4566                .is_some_and(|worker| {
4567                    !worker.is_empty()
4568                        && worker.len() <= 256
4569                        && !worker.contains("..")
4570                        && worker.bytes().all(|b| {
4571                            b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.'
4572                        })
4573                })
4574    });
4575    recoverable && trusted
4576}
4577async fn expired_pre_ack_input(
4578    inbox: &bamboo_storage::FileSessionInbox,
4579    session: &Session,
4580) -> Result<Option<(bamboo_domain::SessionMessageId, u64)>, AgentError> {
4581    let Some((id, generation)) = pre_ack_input(session)? else {
4582        return Ok(None);
4583    };
4584    let backlog = inbox
4585        .inspect(&session.id)
4586        .await
4587        .map_err(|_| plain_actor_unsupported())?;
4588    let leases = inbox
4589        .inspect_owned_leases(&session.id, 2, chrono::Utc::now())
4590        .await
4591        .map_err(|_| plain_actor_unsupported())?;
4592    if backlog.pending != 0
4593        || backlog.claimed != 1
4594        || backlog.generation != generation
4595        || backlog.oldest_generation != Some(generation)
4596        || !backlog.activation_pending()
4597        || leases.len() != 1
4598        || !leases[0].expired
4599        || leases[0].generation != generation
4600        || inbox
4601            .was_admitted(&session.id, &id)
4602            .await
4603            .map_err(|_| plain_actor_unsupported())?
4604    {
4605        return Err(plain_actor_unsupported());
4606    }
4607    Ok(Some((id, generation)))
4608}
4609impl PlainActorActivation {
4610    // Callee-side boxing keeps this storage chain out of the already large
4611    // external-runner future without changing its cancellation semantics.
4612    fn start<'a>(
4613        store: Arc<bamboo_storage::SessionStoreV2>,
4614        session: &'a Session,
4615        binding: &'a SessionInboxRuntimeBinding,
4616        run_id: Option<&'a str>,
4617        permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
4618        allow_failed_retry: bool,
4619        release_worker: Option<&'a str>,
4620    ) -> std::pin::Pin<Box<impl std::future::Future<Output = Result<Self, AgentError>> + Send + 'a>>
4621    {
4622        Box::pin(async move {
4623            let run_id = run_id.ok_or_else(|| plain_actor_unsupported())?;
4624            let backlog = binding
4625                .inbox
4626                .inspect(&session.id)
4627                .await
4628                .map_err(|_| plain_actor_unsupported())?;
4629            let entry = store
4630                .ensure_actor(&session.id)
4631                .await
4632                .map_err(|error| AgentError::LLM(error.to_string()))?;
4633            let config = permission_config
4634                .as_ref()
4635                .ok_or_else(|| plain_actor_unsupported())?;
4636            if entry.actor.policy_revision != Some(config.policy_revision())
4637                || entry.actor.placement_intent != Some(bamboo_domain::ActorPlacementIntent::Local)
4638            {
4639                return Err(plain_actor_unsupported());
4640            }
4641            let fresh = entry.actor.current_attempt == 0
4642                && entry.actor.state == ActorLogicalState::Cold
4643                && entry.activation.is_none()
4644                && backlog.pending == 0
4645                && backlog.claimed == 0
4646                && backlog.generation == 0;
4647            let new_input = backlog.pending == 1
4648                && backlog.claimed == 0
4649                && backlog.oldest_generation == Some(backlog.generation)
4650                && backlog.activation_pending();
4651            let retry = allow_failed_retry
4652                && entry.actor.state == ActorLogicalState::Failed
4653                && entry.actor.current_attempt > 0
4654                && entry.activation.as_ref().is_some_and(|old| {
4655                    old.status == bamboo_domain::ActorActivationStatus::Failed
4656                        && backlog.generation > old.inbox_generation
4657                })
4658                && new_input;
4659            // Only completed plain local history gains a bounded initial batch.
4660            // Retry, expired owner and pre-ACK recovery retain their single-input
4661            // contracts. QueueOnly alone still cannot authorize a cold start.
4662            let new_batch = backlog.pending > 1
4663                && backlog.pending <= bamboo_domain::SessionInboxLimits::default().max_claim_batch
4664                && backlog.claimed == 0
4665                && backlog
4666                    .oldest_generation
4667                    .is_some_and(|oldest| oldest < backlog.generation)
4668                && backlog.activation_pending();
4669            let continuation = allow_failed_retry
4670                && release_worker.is_some()
4671                && entry.actor.state == ActorLogicalState::Cold
4672                && entry.actor.current_attempt > 0
4673                && entry.activation.as_ref().is_some_and(|old| {
4674                    old.status == bamboo_domain::ActorActivationStatus::Succeeded
4675                        && backlog
4676                            .oldest_generation
4677                            .is_some_and(|oldest| oldest > old.inbox_generation)
4678                })
4679                && (new_input || new_batch)
4680                && plain_completed_history(session);
4681            let input_inbox = bamboo_storage::FileSessionInbox::new(
4682                store.clone(),
4683                bamboo_domain::SessionInboxLimits::default(),
4684            );
4685            let now = chrono::Utc::now();
4686            // The old initial Run may still be executing physically. Its final
4687            // append remains fenced; only a new owned input can release this
4688            // replacement worker. Previously admitted history is not replayed.
4689            let replacement = allow_failed_retry
4690                && release_worker.is_some()
4691                && entry.actor.state == ActorLogicalState::Active
4692                && qualified_pre_ack_owner(&entry, now)
4693                && entry.activation.as_ref().is_some_and(|old| {
4694                    old.status.is_live() && old.lease_expires_at <= now && old.inbox_generation == 0
4695                })
4696                && new_input
4697                && plain_initial_history(session);
4698            let recovery = if allow_failed_retry
4699                && release_worker.is_some()
4700                && qualified_pre_ack_owner(&entry, now)
4701            {
4702                expired_pre_ack_input(&input_inbox, session).await?
4703            } else {
4704                None
4705            };
4706            if (recovery.is_some() || continuation || replacement)
4707                && (!entry.actor.matches_session(session)
4708                    || entry.actor.project_id
4709                        != project_id_for_actor_run(session)?.map(|id| id.to_string()))
4710            {
4711                return Err(plain_actor_unsupported());
4712            }
4713            if recovery.is_none()
4714                && !continuation
4715                && !replacement
4716                && ((!fresh && !retry) || !plain_initial_history(session))
4717            {
4718                return Err(plain_actor_unsupported());
4719            }
4720            let duration = crate::runtime::execution::spawn::watchdog_policy_for_session(session)
4721                .max_total_secs
4722                .checked_add(60)
4723                .and_then(chrono::Duration::try_seconds)
4724                .ok_or_else(|| plain_actor_unsupported())?;
4725            let expires = now
4726                .checked_add_signed(duration)
4727                .ok_or_else(|| plain_actor_unsupported())?;
4728            let input_consumer = SessionInboxConsumerId::new();
4729            let input_deadline = expires.min(now + chrono::Duration::hours(1));
4730            let recovered = if let Some((id, generation)) = &recovery {
4731                if !binding.router.owns_run(&session.id, run_id).await {
4732                    return Err(plain_actor_unsupported());
4733                }
4734                let lease = SessionInboxLeaseRequest {
4735                    consumer: input_consumer.clone(),
4736                    now,
4737                    duration: input_deadline - now,
4738                };
4739                let mut claims = input_inbox
4740                    .claim_owned(&session.id, 1, Some(run_id), &lease)
4741                    .await
4742                    .map_err(|_| plain_actor_unsupported())?;
4743                let claim = claims.pop().ok_or_else(|| plain_actor_unsupported())?;
4744                if claim.claim.envelope.id != *id
4745                    || claim.claim.generation != *generation
4746                    || !matches!(&claim.claim.envelope.body, bamboo_domain::SessionMessageBody::Content(content)
4747                        if content.parts.is_empty() && !content.text.trim().is_empty() && content.text.len() <= 8192)
4748                {
4749                    return Err(plain_actor_unsupported());
4750                }
4751                Some(claim)
4752            } else {
4753                None
4754            };
4755            let claimed = store
4756                .claim_activation(&ActorActivationClaim {
4757                    actor_id: session.id.clone(),
4758                    run_id: run_id.into(),
4759                    lease_owner: input_consumer.as_str().into(),
4760                    lease_expires_at: expires,
4761                    inbox_generation: recovered
4762                        .as_ref()
4763                        .map_or(backlog.generation, |claim| claim.claim.generation),
4764                    placement_ref: release_worker.map(|worker| bamboo_domain::ActorPlacementRef {
4765                        class: bamboo_domain::ActorPlacementClass::Local,
4766                        // Written only after the real worker capability probe
4767                        // and spawn. Opaque provenance, never a live-owner grant.
4768                        lease_id: format!("owned-initial-release-v1:{worker}"),
4769                        slot_epoch: None,
4770                    }),
4771                    now,
4772                })
4773                .await
4774                .map_err(|error| AgentError::LLM(error.to_string()))?;
4775            let activation = Self {
4776                store,
4777                fence: claimed.fence(),
4778                inbox_generation: claimed.inbox_generation,
4779                created_at: session.created_at,
4780                provider_transcript: session.provider_transcript.clone(),
4781                input_consumer,
4782                input_deadline,
4783                actor_deadline: claimed.lease_expires_at,
4784                initial_release_required: release_worker.is_some(),
4785                input_inbox,
4786                permission_config,
4787                initial_inputs: recovered.into_iter().collect(),
4788                initial_input_count: if continuation {
4789                    backlog.pending
4790                } else {
4791                    usize::from(claimed.inbox_generation > 0)
4792                },
4793                recovering_pre_ack: recovery.is_some(),
4794                completion_router: std::sync::Mutex::new(None),
4795            };
4796            if let Err(error) = activation
4797                .store
4798                .start_activation(&activation.fence, chrono::Utc::now())
4799                .await
4800            {
4801                let _ = activation.finish(ActorActivationFinish::Failed).await;
4802                return Err(AgentError::LLM(error.to_string()));
4803            }
4804            Ok(activation)
4805        })
4806    }
4807    fn start_and_prepare<'a>(
4808        store: Arc<bamboo_storage::SessionStoreV2>,
4809        session: &'a mut Session,
4810        binding: &'a SessionInboxRuntimeBinding,
4811        run_id: Option<&'a str>,
4812        permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
4813        allow_failed_retry: bool,
4814        release_worker: Option<&'a str>,
4815    ) -> std::pin::Pin<
4816        Box<
4817            impl std::future::Future<Output = Result<(Self, Option<PlainInitialBatch>), AgentError>>
4818                + Send
4819                + 'a,
4820        >,
4821    > {
4822        Box::pin(async move {
4823            let mut activation = Self::start(
4824                store,
4825                session,
4826                binding,
4827                run_id,
4828                permission_config,
4829                allow_failed_retry,
4830                release_worker,
4831            )
4832            .await?;
4833            if activation.inbox_generation == 0 {
4834                return Ok((activation, None));
4835            }
4836            let prepared = async {
4837                let run_id = run_id.ok_or_else(|| plain_actor_unsupported())?;
4838                let backlog = activation
4839                    .input_inbox
4840                    .inspect(&session.id)
4841                    .await
4842                    .map_err(|_| plain_actor_unsupported())?;
4843                let expected = if activation.recovering_pre_ack {
4844                    (0, 1)
4845                } else {
4846                    (activation.initial_input_count, 0)
4847                };
4848                if (backlog.pending, backlog.claimed) != expected
4849                    || backlog.generation != activation.inbox_generation
4850                {
4851                    return Err(plain_actor_unsupported());
4852                }
4853                let claims = if activation.initial_inputs.is_empty() {
4854                    // claim_owned is idempotent for this consumer. Repeating
4855                    // limit=1 would return the same first claim, not the next.
4856                    // Select once under the existing bounded queue transaction.
4857                    activation
4858                        .claim_inputs(binding, session, run_id, activation.initial_input_count)
4859                        .await?
4860                } else {
4861                    activation.initial_inputs.clone()
4862                };
4863                if claims.len() != activation.initial_input_count
4864                    || claims.last().map(|claim| claim.claim.generation)
4865                        != Some(activation.inbox_generation)
4866                    || claims
4867                        .windows(2)
4868                        .any(|pair| pair[0].claim.generation >= pair[1].claim.generation)
4869                {
4870                    return Err(plain_actor_unsupported());
4871                }
4872                // Validate/claim the entire bounded selection before publishing
4873                // any input. Each checkpoint still uses its own physical lease,
4874                // complete current prefix and actual Actor fence. A partial or
4875                // uncertain checkpoint never dispatches a Run or ACKs a sibling.
4876                let mut prefix = None;
4877                let mut deliveries = Vec::with_capacity(claims.len());
4878                for claim in &claims {
4879                    let (messages, delivery) =
4880                        activation.checkpoint_input(session, run_id, claim).await?;
4881                    prefix.get_or_insert(messages);
4882                    deliveries.push(delivery);
4883                }
4884                activation.initial_inputs = claims;
4885                Ok((prefix.ok_or_else(|| plain_actor_unsupported())?, deliveries))
4886            }
4887            .await;
4888            match prepared {
4889                Ok(delivery) => Ok((activation, Some(delivery))),
4890                Err(error) => {
4891                    activation.finish(ActorActivationFinish::Failed).await?;
4892                    Err(error)
4893                }
4894            }
4895        })
4896    }
4897    fn claim_input<'a>(
4898        &'a self,
4899        binding: &'a SessionInboxRuntimeBinding,
4900        session: &'a Session,
4901        run_id: &'a str,
4902    ) -> std::pin::Pin<
4903        Box<
4904            impl std::future::Future<Output = Result<Option<SessionInboxOwnedClaim>, AgentError>>
4905                + Send
4906                + 'a,
4907        >,
4908    > {
4909        Box::pin(async move { Ok(self.claim_inputs(binding, session, run_id, 1).await?.pop()) })
4910    }
4911    fn claim_inputs<'a>(
4912        &'a self,
4913        binding: &'a SessionInboxRuntimeBinding,
4914        session: &'a Session,
4915        run_id: &'a str,
4916        limit: usize,
4917    ) -> std::pin::Pin<
4918        Box<
4919            impl std::future::Future<Output = Result<Vec<SessionInboxOwnedClaim>, AgentError>>
4920                + Send
4921                + 'a,
4922        >,
4923    > {
4924        Box::pin(async move {
4925            if limit == 0 || limit > bamboo_domain::SessionInboxLimits::default().max_claim_batch {
4926                return Err(plain_actor_unsupported());
4927            }
4928            if !self
4929                .input_inbox
4930                .inspect(&session.id)
4931                .await
4932                .map_err(|_| plain_actor_unsupported())?
4933                .activation_pending()
4934            {
4935                return Ok(Vec::new());
4936            }
4937            if run_id != self.fence.run_id || !binding.router.owns_run(&session.id, run_id).await {
4938                return Err(plain_actor_unsupported());
4939            }
4940            let now = chrono::Utc::now();
4941            if now >= self.input_deadline {
4942                return Err(plain_actor_unsupported());
4943            }
4944            let lease = SessionInboxLeaseRequest {
4945                consumer: self.input_consumer.clone(),
4946                now,
4947                duration: self.input_deadline - now,
4948            };
4949            let claims = self
4950                .input_inbox
4951                .claim_owned(&session.id, limit, Some(run_id), &lease)
4952                .await
4953                .map_err(|error| {
4954                    tracing::warn!(%error, "Actor correction claim unconfirmed");
4955                    AgentError::LLM(
4956                        "Actor correction claim unconfirmed; durable input is preserved".into(),
4957                    )
4958                })?;
4959            for claim in &claims {
4960                if !matches!(&claim.claim.envelope.body, bamboo_domain::SessionMessageBody::Content(content)
4961                    if content.parts.is_empty() && !content.text.trim().is_empty() && content.text.len() <= 8192)
4962                {
4963                    self.input_inbox
4964                        .fail_owned(
4965                            &session.id,
4966                            claim,
4967                            &SessionInboxFailureReport {
4968                                now: chrono::Utc::now(),
4969                                error_code: "unsupported_actor_input".into(),
4970                            },
4971                        )
4972                        .await
4973                        .map_err(|error| {
4974                            tracing::warn!(%error, "Actor correction failure report unconfirmed");
4975                            AgentError::LLM(
4976                                "Actor correction failure report unconfirmed; durable input is preserved".into(),
4977                            )
4978                        })?;
4979                    return Err(plain_actor_unsupported());
4980                }
4981            }
4982            Ok(claims)
4983        })
4984    }
4985    fn checkpoint_input<'a>(
4986        &'a self,
4987        session: &'a mut Session,
4988        run_id: &'a str,
4989        claim: &'a SessionInboxOwnedClaim,
4990    ) -> std::pin::Pin<
4991        Box<
4992            impl std::future::Future<Output = Result<PlainInitialDelivery, AgentError>> + Send + 'a,
4993        >,
4994    > {
4995        Box::pin(async move {
4996            // The worker receives the committed pre-input prefix. Only typed
4997            // delivery adds the User; the Host-private bookkeeper is not a grant.
4998            let request = bamboo_storage::ActorInputCheckpoint {
4999                fence: self.fence.clone(),
5000                expected_created_at: self.created_at,
5001                claim: claim.clone(),
5002                expected_messages: session.messages.clone(),
5003                expected_provider_transcript: session.provider_transcript.clone(),
5004                expected_admission: session.session_inbox_admission().cloned(),
5005            };
5006            let mut retries = 0;
5007            let committed = loop {
5008                let result = self.input_inbox.checkpoint_actor_input(request.clone()).await;
5009                if matches!(&result, Err(bamboo_storage::ActorInputCheckpointError::PrefixConflict))
5010                    && retries < 3
5011                {
5012                    // A Parent can save ordinary tool/wait state while this
5013                    // Child checkpoints. PrefixConflict guarantees no publish;
5014                    // retry the SAME Child prefix, claim and fence, with every
5015                    // storage authority check repeated. Never adopt a changed
5016                    // Child transcript or retry an unconfirmed publication.
5017                    tokio::time::sleep(Duration::from_millis(10 << retries)).await;
5018                    retries += 1;
5019                    continue;
5020                }
5021                break result;
5022            }.map_err(|error| {
5023                tracing::warn!(%error, "Actor correction checkpoint rejected or unconfirmed; no worker dispatch or ACK");
5024                AgentError::LLM("Actor correction checkpoint unconfirmed; durable Inbox and history are preserved".into())
5025            })?;
5026            if self.recovering_pre_ack
5027                && committed.status
5028                    != bamboo_storage::ActorInputCheckpointStatus::AlreadyCheckpointed
5029            {
5030                return Err(plain_actor_unsupported());
5031            }
5032            let messages = session
5033                .messages
5034                .iter()
5035                .map(|message| {
5036                    let mut worker = message.clone();
5037                    if committed.status
5038                        == bamboo_storage::ActorInputCheckpointStatus::AlreadyCheckpointed
5039                        && worker.id == committed.envelope.id.as_str()
5040                    {
5041                        worker
5042                            .metadata
5043                            .as_mut()
5044                            .and_then(serde_json::Value::as_object_mut)
5045                            .ok_or_else(|| plain_actor_unsupported())?
5046                            .remove("_bamboo_owned_input_checkpoint");
5047                        if !bamboo_domain::is_matching_session_message(&worker, &committed.envelope)
5048                        {
5049                            return Err(plain_actor_unsupported());
5050                        }
5051                    }
5052                    serde_json::to_value(worker).map_err(|_| plain_actor_unsupported())
5053                })
5054                .collect::<Result<Vec<_>, _>>()?;
5055            *session = committed.session;
5056            Ok((
5057                messages,
5058                SessionMessageDelivery {
5059                    target_session_id: session.id.clone(),
5060                    envelope: committed.envelope,
5061                    canonical_claim_generation: committed.generation,
5062                    activation_run_id: run_id.into(),
5063                    activation_policy: claim.claim.activation_policy,
5064                },
5065            ))
5066        })
5067    }
5068    fn append_reply<'a>(
5069        &'a self,
5070        session: &'a mut Session,
5071        text: String,
5072        event_tx: &'a mpsc::Sender<AgentEvent>,
5073    ) -> std::pin::Pin<Box<impl std::future::Future<Output = Result<(), AgentError>> + Send + 'a>>
5074    {
5075        Box::pin(async move {
5076            let message = bamboo_agent_core::Message::assistant(text, None);
5077            let request = bamboo_storage::ActorTranscriptAppend {
5078                fence: self.fence.clone(),
5079                expected_created_at: self.created_at,
5080                expected_messages: session.messages.clone(),
5081                expected_provider_transcript: self.provider_transcript.clone(),
5082                messages: vec![message.clone()],
5083                native_groups: Vec::new(),
5084            };
5085            let mut retries = 0;
5086            *session = loop {
5087                let result = self.store.append_actor_transcript(request.clone()).await;
5088                if matches!(
5089                    &result,
5090                    Err(bamboo_storage::ActorTranscriptAppendError::PrefixConflict)
5091                ) && retries < 3
5092                {
5093                    // Parent writes may change the ancestor witness before
5094                    // publication. Retry this exact reply and Child prefix;
5095                    // never reload/adopt a different transcript or replay an
5096                    // unconfirmed write. Storage rechecks the same live fence.
5097                    tokio::time::sleep(Duration::from_millis(10 << retries)).await;
5098                    retries += 1;
5099                    continue;
5100                }
5101                break result;
5102            }
5103            .map_err(|error| {
5104                tracing::warn!(
5105                    %error,
5106                    "Actor reply checkpoint rejected or unconfirmed; durable history is preserved"
5107                );
5108                AgentError::LLM(format!("actor reply commit failed: {error}"))
5109            })?;
5110            let _ = event_tx
5111                .send(AgentEvent::message_appended(&session.id, &message))
5112                .await;
5113            Ok(())
5114        })
5115    }
5116    // First Terminal is a real completed plain reply, not a cache event. Commit
5117    // it before appending the typed correction, then use the existing startup
5118    // admission barrier on a second correlated Run under the SAME Host fence.
5119    fn continue_input<'a>(
5120        &'a self,
5121        client: &'a mut dyn bamboo_subagent::ChildLink,
5122        binding: &'a SessionInboxRuntimeBinding,
5123        session: &'a mut Session,
5124        run_id: &'a str,
5125        run: Option<(&'a RunSpec, &'a AtomicU64)>,
5126        expected_posture: &'a mut Option<ExpectedPermissionPosture>,
5127        text: String,
5128        event_tx: &'a mpsc::Sender<AgentEvent>,
5129    ) -> std::pin::Pin<
5130        Box<
5131            impl std::future::Future<
5132                    Output = Result<Option<(SessionInboxOwnedClaim, u64)>, AgentError>,
5133                > + Send
5134                + 'a,
5135        >,
5136    > {
5137        Box::pin(async move {
5138            let Some(claim) = self.claim_input(binding, session, run_id).await? else {
5139                return Ok(None);
5140            };
5141            let (run, epochs) = run.ok_or_else(|| plain_actor_unsupported())?;
5142            self.append_reply(session, text, event_tx).await?;
5143            let (messages, delivery) = self.checkpoint_input(session, run_id, &claim).await?;
5144            // Fresh actual Host audit, unchanged permission/ceiling checks.
5145            self.refresh_continuation_posture(session, run, expected_posture)?;
5146            let epoch = epochs
5147                .fetch_add(1, Ordering::Relaxed)
5148                .checked_add(1)
5149                .ok_or_else(|| plain_actor_unsupported())?;
5150            let mut next = run.clone();
5151            next.messages = messages;
5152            next.initial_session_messages = vec![delivery];
5153            next.execution_epoch = epoch;
5154            client.send(ParentFrame::Run(next)).await.map_err(|_| {
5155                AgentError::LLM(
5156                    "Actor correction dispatch unconfirmed; durable input is preserved".into(),
5157                )
5158            })?;
5159            Ok(Some((claim, epoch)))
5160        })
5161    }
5162    fn refresh_continuation_posture(
5163        &self,
5164        session: &Session,
5165        run: &RunSpec,
5166        expected: &mut Option<ExpectedPermissionPosture>,
5167    ) -> Result<(), AgentError> {
5168        let Some(expected) = expected.as_mut() else {
5169            return Err(plain_actor_unsupported());
5170        };
5171        let audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
5172            .ok_or_else(|| plain_actor_unsupported())?;
5173        if audit.policy_revision != expected.policy_revision
5174            || audit.resolution != expected.resolution
5175            || audit.executor_mapping != expected.executor_mapping
5176        {
5177            return Err(plain_actor_unsupported());
5178        }
5179        match (&self.permission_config, &run.permission_policy) {
5180            (Some(config), Some(policy)) => {
5181                ensure_no_active_scoped_session_denies(config, &session.id)?;
5182                if config.policy_revision() != policy.revision
5183                    || policy.revision != audit.policy_revision
5184                    || policy.session_id != session.id
5185                    || policy.workspace_path != session.workspace
5186                    || policy.inherit_session_grants
5187                    || serde_json::to_value(config.to_serializable())
5188                        .map_err(|_| plain_actor_unsupported())?
5189                        != policy.policy
5190                {
5191                    return Err(plain_actor_unsupported());
5192                }
5193            }
5194            (None, None) => {}
5195            _ => return Err(plain_actor_unsupported()),
5196        }
5197        expected.expected_audit_revision = Some(audit.audit_revision);
5198        Ok(())
5199    }
5200    fn release_initial_input<'a>(
5201        &'a self,
5202        binding: &'a SessionInboxRuntimeBinding,
5203        session: &'a Session,
5204        run: &'a RunSpec,
5205        epoch: u64,
5206        claim: &'a SessionInboxOwnedClaim,
5207        request: &'a bamboo_subagent::proto::InitialInputReleaseRequest,
5208        expected: &'a mut Option<ExpectedPermissionPosture>,
5209        previous: Option<&'a bamboo_subagent::proto::InitialInputRelease>,
5210    ) -> std::pin::Pin<
5211        Box<
5212            impl std::future::Future<
5213                    Output = Result<bamboo_subagent::proto::InitialInputRelease, AgentError>,
5214                > + Send
5215                + 'a,
5216        >,
5217    > {
5218        Box::pin(async move {
5219            if !self.initial_release_required {
5220                return Err(plain_actor_unsupported());
5221            }
5222            let delivery = SessionMessageDelivery {
5223                target_session_id: session.id.clone(),
5224                envelope: claim.claim.envelope.clone(),
5225                canonical_claim_generation: claim.claim.generation,
5226                activation_run_id: self.fence.run_id.clone(),
5227                activation_policy: claim.claim.activation_policy,
5228            };
5229            let mut current_run = run.clone();
5230            current_run.execution_epoch = epoch;
5231            let exact = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
5232                &current_run,
5233                &delivery,
5234                request.nonce.clone(),
5235            )
5236            .map_err(|_| plain_actor_unsupported())?;
5237            if &exact != request
5238                || request.child_id != session.id
5239                || request.created_at != session.created_at
5240                || request.spawn_depth != session.spawn_depth
5241                || request.parent_id != session.parent_session_id.as_deref().unwrap_or_default()
5242                || request.root_id != session.root_session_id
5243                || request.project_id != project_id_for_actor_run(session)?
5244                || !binding
5245                    .router
5246                    .owns_run(&session.id, &self.fence.run_id)
5247                    .await
5248            {
5249                return Err(plain_actor_unsupported());
5250            }
5251            let deadline = self
5252                .actor_deadline
5253                .min(self.input_deadline)
5254                .min(claim.lease.expires_at);
5255            if chrono::Utc::now() >= deadline {
5256                return Err(plain_actor_unsupported());
5257            }
5258            self.store
5259                .validate_fence(&self.fence, chrono::Utc::now())
5260                .await
5261                .map_err(|_| plain_actor_unsupported())?;
5262            let actual =
5263                bamboo_agent_core::storage::Storage::load_session(self.store.as_ref(), &session.id)
5264                    .await
5265                    .map_err(|_| plain_actor_unsupported())?
5266                    .ok_or_else(|| plain_actor_unsupported())?;
5267            if actual.created_at != session.created_at
5268                || actual.root_session_id != session.root_session_id
5269                || actual.parent_session_id != session.parent_session_id
5270                || actual.spawn_depth != session.spawn_depth
5271                || project_id_for_actor_run(&actual)? != request.project_id
5272                || serde_json::to_value(&actual.messages).map_err(|_| plain_actor_unsupported())?
5273                    != serde_json::to_value(&session.messages)
5274                        .map_err(|_| plain_actor_unsupported())?
5275                || serde_json::to_value(&actual.provider_transcript)
5276                    .map_err(|_| plain_actor_unsupported())?
5277                    != serde_json::to_value(&session.provider_transcript)
5278                        .map_err(|_| plain_actor_unsupported())?
5279                || serde_json::to_value(actual.session_inbox_admission())
5280                    .map_err(|_| plain_actor_unsupported())?
5281                    != serde_json::to_value(session.session_inbox_admission())
5282                        .map_err(|_| plain_actor_unsupported())?
5283            {
5284                return Err(plain_actor_unsupported());
5285            }
5286            self.refresh_continuation_posture(&actual, &current_run, expected)?;
5287            if let Some(previous) = previous {
5288                if !previous.permits(request, chrono::Utc::now())
5289                    || previous.expires_at != deadline
5290                    || !self
5291                        .input_inbox
5292                        .was_admitted(&session.id, &claim.claim.envelope.id)
5293                        .await
5294                        .map_err(|_| plain_actor_unsupported())?
5295                {
5296                    return Err(plain_actor_unsupported());
5297                }
5298            } else {
5299                // Full current-prefix AlreadyCheckpointed readback still checks
5300                // the actual physical owner. A local worker receipt is insufficient.
5301                let checkpoint = bamboo_storage::ActorInputCheckpoint {
5302                    fence: self.fence.clone(),
5303                    expected_created_at: self.created_at,
5304                    claim: claim.clone(),
5305                    expected_messages: actual.messages.clone(),
5306                    expected_provider_transcript: actual.provider_transcript.clone(),
5307                    expected_admission: actual.session_inbox_admission().cloned(),
5308                };
5309                let mut retries = 0;
5310                loop {
5311                    let result = self
5312                        .input_inbox
5313                        .checkpoint_actor_input(checkpoint.clone())
5314                        .await;
5315                    if matches!(
5316                        &result,
5317                        Err(bamboo_storage::ActorInputCheckpointError::PrefixConflict)
5318                    ) && retries < 3
5319                    {
5320                        // As at initial checkpoint, this error guarantees no
5321                        // publication. Repeat the identical prefix, claim and
5322                        // fence; a Parent's ordinary save grants no authority.
5323                        tracing::warn!(retries, "Actor initial release prefix changed before publication; retrying same claim");
5324                        tokio::time::sleep(Duration::from_millis(10 << retries)).await;
5325                        retries += 1;
5326                        continue;
5327                    }
5328                    result.map_err(|error| {
5329                        tracing::warn!(%error, "Actor initial release checkpoint rejected or unconfirmed");
5330                        plain_actor_unsupported()
5331                    })?;
5332                    break;
5333                }
5334                self.confirm_input(
5335                    binding,
5336                    &actual,
5337                    &self.fence.run_id,
5338                    claim,
5339                    &bamboo_subagent::proto::SessionMessageAdmissionConfirmation {
5340                        target_session_id: request.child_id.clone(),
5341                        envelope_id: request.envelope_id.clone(),
5342                        canonical_claim_generation: request.generation,
5343                        activation_run_id: request.activation_run_id.clone(),
5344                    },
5345                )
5346                .await?;
5347            }
5348            // ACK's terminal receipt is idempotent even after expiry. It cannot
5349            // replace these final current-owner/deadline/policy checks.
5350            if chrono::Utc::now() >= deadline
5351                || !binding
5352                    .router
5353                    .owns_run(&session.id, &self.fence.run_id)
5354                    .await
5355            {
5356                return Err(plain_actor_unsupported());
5357            }
5358            self.store
5359                .validate_fence(&self.fence, chrono::Utc::now())
5360                .await
5361                .map_err(|_| plain_actor_unsupported())?;
5362            let final_session =
5363                bamboo_agent_core::storage::Storage::load_session(self.store.as_ref(), &session.id)
5364                    .await
5365                    .map_err(|_| plain_actor_unsupported())?
5366                    .ok_or_else(|| plain_actor_unsupported())?;
5367            self.store
5368                .validate_fence(&self.fence, chrono::Utc::now())
5369                .await
5370                .map_err(|_| plain_actor_unsupported())?;
5371            self.refresh_continuation_posture(&final_session, &current_run, expected)?;
5372            if chrono::Utc::now() >= deadline {
5373                return Err(plain_actor_unsupported());
5374            }
5375            Ok(bamboo_subagent::proto::InitialInputRelease {
5376                request: request.clone(),
5377                expires_at: deadline,
5378            })
5379        })
5380    }
5381    async fn confirm_input(
5382        &self,
5383        binding: &SessionInboxRuntimeBinding,
5384        session: &Session,
5385        run_id: &str,
5386        claim: &SessionInboxOwnedClaim,
5387        confirmation: &bamboo_subagent::proto::SessionMessageAdmissionConfirmation,
5388    ) -> Result<(), AgentError> {
5389        if confirmation.target_session_id != session.id
5390            || confirmation.envelope_id != claim.claim.envelope.id.as_str()
5391            || confirmation.canonical_claim_generation != claim.claim.generation
5392            || confirmation.activation_run_id != run_id
5393            || run_id != self.fence.run_id
5394            || !binding.router.owns_run(&session.id, run_id).await
5395        {
5396            return Err(plain_actor_unsupported());
5397        }
5398        self.input_inbox
5399            .ack_owned(&session.id, claim, chrono::Utc::now())
5400            .await
5401            .map_err(|error| {
5402                tracing::warn!(%error, "Actor correction ACK unresolved");
5403                AgentError::LLM(
5404                    "Actor correction ACK unresolved; durable input is preserved".into(),
5405                )
5406            })
5407    }
5408    async fn finish(&self, outcome: ActorActivationFinish) -> Result<(), AgentError> {
5409        self.finish_with_observer(outcome, None).await
5410    }
5411
5412    async fn finish_with_observer(
5413        &self,
5414        outcome: ActorActivationFinish,
5415        observer: Option<&dyn ActorEventObserver>,
5416    ) -> Result<(), AgentError> {
5417        let committed = self
5418            .store
5419            .finish_activation(&self.fence, chrono::Utc::now(), outcome)
5420            .await
5421            .map_err(|error| AgentError::LLM(format!("Actor completion unconfirmed: {error}")))?;
5422        // A validated final Terminal hands off this one router. Failed writes
5423        // never publish, and neither a retry nor another finish can reuse it.
5424        let router = self.completion_router.lock().recover_poison().take();
5425        if let (Some(observer), Some(router)) = (observer, router) {
5426            // The Directory may already hold a successor by this point. Never
5427            // relabel the old completion, and never turn an already committed
5428            // success into an execution failure if best-effort delivery is lost.
5429            match self.store.inspect_actor(&self.fence.actor_id).await {
5430                Ok(entry) => match router.into_host_completion(&entry, &committed) {
5431                    Ok(event) => observer.publish(event),
5432                    Err(_) => tracing::debug!(actor_id = %self.fence.actor_id,
5433                        "Actor completion notification superseded"),
5434                },
5435                Err(error) => tracing::warn!(actor_id = %self.fence.actor_id, %error,
5436                    "Actor completion committed; notification snapshot unavailable"),
5437            }
5438        }
5439        Ok(())
5440    }
5441}
5442fn local_tool_history_unsupported() -> AgentError {
5443    AgentError::LLM("local_tool_history_unsupported".into())
5444}
5445
5446#[derive(Clone, Copy)]
5447enum LocalToolTerminal<'a> {
5448    Completed(Option<&'a str>),
5449    // Only the selected Cancelled terminal may finish a validated Read/Glob
5450    // prefix. This is Host control flow, never a Worker DATA authority flag.
5451    Cancelled,
5452}
5453
5454impl LocalToolTerminal<'_> {
5455    fn permits_checkpoint(self, cancel: &CancellationToken) -> bool {
5456        match self {
5457            Self::Completed(_) => !cancel.is_cancelled(),
5458            Self::Cancelled => cancel.is_cancelled(),
5459        }
5460    }
5461}
5462
5463#[derive(Default)]
5464struct LocalToolCollector {
5465    messages: Option<Vec<bamboo_agent_core::Message>>,
5466    starts: HashMap<String, (String, serde_json::Value)>,
5467    outcomes: HashMap<String, (bool, String)>,
5468    // Installed only by the Host's exact Task callback receipt. Worker event
5469    // JSON and transcript metadata cannot grant this terminal exception.
5470    ticket_yield: Option<(String, bamboo_tickets::PendingRequest)>,
5471}
5472
5473impl LocalToolCollector {
5474    fn ticket_event(
5475        &self,
5476        value: &serde_json::Value,
5477        committed: &HashMap<String, serde_json::Value>,
5478    ) -> Result<bool, AgentError> {
5479        if value["type"] == bamboo_subagent::proto::LocalToolMessages::TYPE {
5480            return Ok(true);
5481        }
5482        match serde_json::from_value::<AgentEvent>(value.clone())
5483            .map_err(|_| local_tool_history_unsupported())?
5484        {
5485            AgentEvent::ToolStart { tool_name, .. }
5486                if !matches!(tool_name.as_str(), "Task" | "Read" | "Write") =>
5487            {
5488                Err(local_tool_history_unsupported())
5489            }
5490            AgentEvent::ToolComplete {
5491                tool_call_id,
5492                result,
5493            } if result.success => {
5494                let Some((name, args)) = self.starts.get(&tool_call_id) else {
5495                    return Err(local_tool_history_unsupported());
5496                };
5497                let valid = if name == "Task" {
5498                    committed.get(&tool_call_id) == Some(args)
5499                } else {
5500                    committed.get(&tool_call_id).is_some_and(|proof| {
5501                        proof["file_tool"] == *name
5502                            && proof["arguments"] == *args
5503                            && proof["result"] == result.result
5504                    })
5505                };
5506                if !valid {
5507                    return Err(AgentError::LLM(
5508                        "LocalPlan success has no exact Host receipt".into(),
5509                    ));
5510                }
5511                Ok(true)
5512            }
5513            // Only the committed Host projection may publish a plan update.
5514            AgentEvent::TaskListUpdated { .. } => Ok(false),
5515            _ => Ok(true),
5516        }
5517    }
5518
5519    fn event(&mut self, value: &serde_json::Value) -> Result<bool, AgentError> {
5520        use bamboo_subagent::proto::LocalToolMessages;
5521        if self.messages.is_some() {
5522            return Err(local_tool_history_unsupported());
5523        }
5524        if value["type"] == LocalToolMessages::TYPE {
5525            let observation: LocalToolMessages = serde_json::from_value(value.clone())
5526                .map_err(|_| local_tool_history_unsupported())?;
5527            self.messages = Some(
5528                observation
5529                    .validate()
5530                    .map_err(|_| local_tool_history_unsupported())?,
5531            );
5532            return Ok(false);
5533        }
5534        let event: AgentEvent =
5535            serde_json::from_value(value.clone()).map_err(|_| local_tool_history_unsupported())?;
5536        match event {
5537            AgentEvent::ToolStart {
5538                tool_call_id,
5539                tool_name,
5540                arguments,
5541            } => {
5542                if !arguments.is_object()
5543                    || !matches!(tool_name.as_str(), "Read" | "Glob" | "Write" | "Task")
5544                    || tool_call_id.is_empty()
5545                    || tool_call_id.len() > 128
5546                    || self.starts.len() >= LocalToolMessages::MAX_PAIRS
5547                    || self.outcomes.contains_key(&tool_call_id)
5548                    || self
5549                        .starts
5550                        .insert(tool_call_id, (tool_name, arguments))
5551                        .is_some()
5552                {
5553                    return Err(local_tool_history_unsupported());
5554                }
5555            }
5556            AgentEvent::ToolComplete {
5557                tool_call_id,
5558                result,
5559            } => {
5560                if !result.images.is_empty() {
5561                    return Err(local_tool_history_unsupported());
5562                }
5563                self.outcome(tool_call_id, result.success, result.result)?;
5564            }
5565            AgentEvent::ToolError {
5566                tool_call_id,
5567                error,
5568            } => {
5569                self.outcome(tool_call_id, false, format!("Error: {error}"))?;
5570            }
5571            AgentEvent::ToolToken { tool_call_id, .. } => {
5572                if !self.starts.contains_key(&tool_call_id)
5573                    || self.outcomes.contains_key(&tool_call_id)
5574                {
5575                    return Err(local_tool_history_unsupported());
5576                }
5577            }
5578            AgentEvent::ToolLifecycle {
5579                tool_call_id,
5580                tool_name,
5581                is_mutating,
5582                auto_approved,
5583                phase,
5584                ..
5585            } => {
5586                if self.starts.get(&tool_call_id).map(|(name, _)| name) != Some(&tool_name)
5587                    || is_mutating != matches!(tool_name.as_str(), "Write" | "Task")
5588                    || auto_approved != !matches!(tool_name.as_str(), "Write" | "Task")
5589                    || !matches!(phase.as_str(), "begin" | "finished" | "error" | "cancelled")
5590                {
5591                    return Err(local_tool_history_unsupported());
5592                }
5593            }
5594            AgentEvent::ReasoningToken { .. } => {
5595                // Text reasoning is a stream observation. Its complete value
5596                // is preserved by the bounded typed completion, which still
5597                // rejects signatures and unsupported provider state.
5598            }
5599            AgentEvent::ContextSummarized { .. }
5600            | AgentEvent::ContextArchived { .. }
5601            | AgentEvent::ContextCompressionStatus { .. }
5602            | AgentEvent::NeedClarification { .. }
5603            | AgentEvent::SubAgentStarted { .. }
5604            | AgentEvent::SubAgentEvent { .. } => return Err(local_tool_history_unsupported()),
5605            // Worker commit events are observations, not canonical Host commits.
5606            AgentEvent::SessionHistoryCommitted { .. } | AgentEvent::MessageAppended { .. } => {
5607                return Ok(false)
5608            }
5609            _ => {}
5610        }
5611        Ok(true)
5612    }
5613
5614    fn outcome(&mut self, id: String, success: bool, text: String) -> Result<(), AgentError> {
5615        if id.is_empty()
5616            || id.len() > 128
5617            || text.len() > bamboo_subagent::proto::LocalToolMessages::MAX_BYTES
5618            || self.outcomes.len() >= bamboo_subagent::proto::LocalToolMessages::MAX_PAIRS
5619            || self.outcomes.insert(id, (success, text)).is_some()
5620        {
5621            return Err(local_tool_history_unsupported());
5622        }
5623        Ok(())
5624    }
5625
5626    fn suffix(
5627        self,
5628        host: &Session,
5629        tools: &[String],
5630        read_only: bool,
5631        terminal: Option<&str>,
5632    ) -> Result<Vec<bamboo_agent_core::Message>, AgentError> {
5633        self.suffix_for_terminal(
5634            host,
5635            tools,
5636            read_only,
5637            LocalToolTerminal::Completed(terminal),
5638        )
5639    }
5640
5641    fn suffix_for_terminal(
5642        self,
5643        host: &Session,
5644        tools: &[String],
5645        read_only: bool,
5646        terminal: LocalToolTerminal<'_>,
5647    ) -> Result<Vec<bamboo_agent_core::Message>, AgentError> {
5648        use bamboo_subagent::proto::LocalToolMessages;
5649        let cancelled = matches!(terminal, LocalToolTerminal::Cancelled);
5650        if cancelled
5651            && (!read_only
5652                || tools.is_empty()
5653                || tools
5654                    .iter()
5655                    .any(|name| !matches!(name.as_str(), "Read" | "Glob"))
5656                || self.ticket_yield.is_some())
5657        {
5658            return Err(local_tool_history_unsupported());
5659        }
5660        let data = self.messages.ok_or_else(local_tool_history_unsupported)?;
5661        let candidate: Vec<_> = data.iter().skip_while(|m| m.role == Role::System).collect();
5662        let prefix: Vec<_> = host
5663            .messages
5664            .iter()
5665            .filter(|m| m.role != Role::System)
5666            .collect();
5667        if !host.provider_transcript.groups().is_empty()
5668            || candidate.len() < prefix.len()
5669            || (!cancelled && candidate.len() == prefix.len())
5670            || serde_json::to_value(&candidate[..prefix.len()])
5671                .map_err(|_| local_tool_history_unsupported())?
5672                != serde_json::to_value(&prefix).map_err(|_| local_tool_history_unsupported())?
5673        {
5674            return Err(local_tool_history_unsupported());
5675        }
5676        let mut ids = std::collections::HashSet::new();
5677        let mut calls = std::collections::HashSet::new();
5678        for message in &host.messages {
5679            if !ids.insert(message.id.clone()) {
5680                return Err(local_tool_history_unsupported());
5681            }
5682            for call in message.tool_calls.iter().flatten() {
5683                calls.insert(call.id.clone());
5684            }
5685            if let Some(id) = &message.tool_call_id {
5686                calls.insert(id.clone());
5687            }
5688        }
5689        let suffix: Vec<_> = candidate[prefix.len()..]
5690            .iter()
5691            .map(|message| (**message).clone())
5692            .collect();
5693        let mut pending = HashMap::new();
5694        let mut used = std::collections::HashSet::new();
5695        for message in &suffix {
5696            if !ids.insert(message.id.clone()) || message.never_compress {
5697                return Err(local_tool_history_unsupported());
5698            }
5699            match message.role {
5700                Role::Assistant => {
5701                    if !pending.is_empty()
5702                        || message.metadata.is_some()
5703                        || message.tool_call_id.is_some()
5704                        || message.tool_success.is_some()
5705                    {
5706                        return Err(local_tool_history_unsupported());
5707                    }
5708                    if let Some(batch) = &message.tool_calls {
5709                        if batch.is_empty() {
5710                            return Err(local_tool_history_unsupported());
5711                        }
5712                        for call in batch {
5713                            let arguments =
5714                                serde_json::from_str::<serde_json::Value>(&call.function.arguments)
5715                                    .ok()
5716                                    .filter(|arguments| arguments.is_object())
5717                                    .ok_or_else(local_tool_history_unsupported)?;
5718                            if call.id.is_empty()
5719                                || call.id.len() > 128
5720                                || call.tool_type != "function"
5721                                || !calls.insert(call.id.clone())
5722                                || calls.len() > LocalToolMessages::MAX_MESSAGES
5723                                || !matches!(
5724                                    call.function.name.as_str(),
5725                                    "Read" | "Glob" | "Write" | "Task"
5726                                )
5727                                || (cancelled
5728                                    && !matches!(call.function.name.as_str(), "Read" | "Glob"))
5729                                || self.starts.get(&call.id).is_some_and(|(name, actual)| {
5730                                    name != &call.function.name || actual != &arguments
5731                                })
5732                            {
5733                                return Err(local_tool_history_unsupported());
5734                            }
5735                            pending.insert(call.id.clone(), call.function.name.clone());
5736                        }
5737                    } else if message.content.trim().is_empty() {
5738                        return Err(local_tool_history_unsupported());
5739                    }
5740                }
5741                Role::Tool => {
5742                    let id = message
5743                        .tool_call_id
5744                        .as_ref()
5745                        .ok_or_else(local_tool_history_unsupported)?;
5746                    let name = pending
5747                        .remove(id)
5748                        .ok_or_else(local_tool_history_unsupported)?;
5749                    let success = message
5750                        .tool_success
5751                        .ok_or_else(local_tool_history_unsupported)?;
5752                    if message.tool_calls.is_some()
5753                        || message.phase.is_some()
5754                        || self.outcomes.get(id) != Some(&(success, message.content.clone()))
5755                        || !used.insert(id.clone())
5756                        || used.len() > LocalToolMessages::MAX_PAIRS
5757                    {
5758                        return Err(local_tool_history_unsupported());
5759                    }
5760                    let denied_write = name == "Write" && (read_only || !tools.contains(&name));
5761                    if denied_write {
5762                        if success
5763                            || !(message.content.contains("native_tool_ceiling_denied")
5764                                || message
5765                                    .content
5766                                    .contains("not callable at the current conversation position"))
5767                        {
5768                            return Err(local_tool_history_unsupported());
5769                        }
5770                    } else if !tools.contains(&name)
5771                        || self.starts.get(id).map(|(actual, _)| actual) != Some(&name)
5772                    {
5773                        return Err(local_tool_history_unsupported());
5774                    }
5775                    if let Some(metadata) = &message.metadata {
5776                        let object = metadata
5777                            .as_object()
5778                            .ok_or_else(local_tool_history_unsupported)?;
5779                        if object.len() != 5
5780                            || metadata["elapsed_ms"].as_u64().is_none()
5781                            || metadata["tool_name"].as_str() != Some(name.as_str())
5782                            || metadata["is_mutating"].as_bool()
5783                                != Some(matches!(name.as_str(), "Write" | "Task"))
5784                            || metadata["auto_approved"].as_bool()
5785                                != Some(!matches!(name.as_str(), "Write" | "Task"))
5786                            || metadata["success"].as_bool() != Some(success)
5787                        {
5788                            return Err(local_tool_history_unsupported());
5789                        }
5790                    }
5791                }
5792                _ => return Err(local_tool_history_unsupported()),
5793            }
5794        }
5795        let last = suffix.last();
5796        let completed_text = match terminal {
5797            LocalToolTerminal::Completed(text) => text,
5798            LocalToolTerminal::Cancelled => None,
5799        };
5800        let question_terminal =
5801            self.ticket_yield.as_ref().is_some_and(|(id, question)| {
5802                last.is_some_and(|last| last.role == Role::Tool
5803                && last.tool_call_id.as_ref() == Some(id)
5804                && last.tool_success == Some(true)
5805                && self.starts.get(id).is_some_and(|(name, _)| name == "Task")
5806                && completed_text.is_none_or(str::is_empty)
5807                && serde_json::from_str::<serde_json::Value>(&last.content).ok()
5808                    == Some(serde_json::json!({"status":"waiting_for_answer", "request":question})))
5809            });
5810        let report_terminal = last.is_some_and(|last| {
5811            last.role == Role::Assistant
5812                && last.tool_calls.is_none()
5813                && last.phase != Some(bamboo_domain::MessagePhase::Commentary)
5814                && completed_text == Some(last.content.as_str())
5815        });
5816        if !pending.is_empty()
5817            || used.len() != self.outcomes.len()
5818            || self.starts.keys().any(|id| !used.contains(id))
5819            || !(cancelled || report_terminal || question_terminal)
5820        {
5821            return Err(local_tool_history_unsupported());
5822        }
5823        Ok(suffix)
5824    }
5825}
5826
5827async fn validate_local_tool_checkpoint(
5828    session: &Session,
5829    binding: &SessionInboxRuntimeBinding,
5830    run_id: &str,
5831    terminal: LocalToolTerminal<'_>,
5832    cancel: &CancellationToken,
5833) -> Result<(), AgentError> {
5834    if !terminal.permits_checkpoint(cancel) || !binding.router.owns_run(&session.id, run_id).await {
5835        return Err(local_tool_history_unsupported());
5836    }
5837    if matches!(terminal, LocalToolTerminal::Cancelled) {
5838        let backlog = binding
5839            .inbox
5840            .inspect(&session.id)
5841            .await
5842            .map_err(|_| local_tool_history_unsupported())?;
5843        if backlog.pending != 0 || backlog.claimed != 0 || backlog.activation_pending() {
5844            // An unclaimed correction can race its delivery notification.
5845            // Inspect only: neither admission nor ACK belongs to cancellation.
5846            return Err(local_tool_history_unsupported());
5847        }
5848    }
5849    Ok(())
5850}
5851
5852async fn commit_local_tool_history(
5853    collector: LocalToolCollector,
5854    session: &mut Session,
5855    binding: &SessionInboxRuntimeBinding,
5856    run_id: &str,
5857    selection: (&[String], bool),
5858    terminal: LocalToolTerminal<'_>,
5859    cancel: &CancellationToken,
5860) -> Result<(), AgentError> {
5861    validate_local_tool_checkpoint(session, binding, run_id, terminal, cancel).await?;
5862    let latest = binding
5863        .storage
5864        .load_session(&session.id)
5865        .await
5866        .map_err(|_| local_tool_history_unsupported())?
5867        .ok_or_else(local_tool_history_unsupported)?;
5868    if latest.id != session.id
5869        || latest.kind != session.kind
5870        || latest.created_at != session.created_at
5871        || latest.root_session_id != session.root_session_id
5872        || latest.parent_session_id != session.parent_session_id
5873        || latest.spawn_depth != session.spawn_depth
5874        || latest.project_id_meta() != session.project_id_meta()
5875        || serde_json::to_value(&latest.provider_transcript)
5876            .map_err(|_| local_tool_history_unsupported())?
5877            != serde_json::to_value(&session.provider_transcript)
5878                .map_err(|_| local_tool_history_unsupported())?
5879    {
5880        return Err(local_tool_history_unsupported());
5881    }
5882    let suffix = match terminal {
5883        LocalToolTerminal::Completed(text) => {
5884            collector.suffix(&latest, selection.0, selection.1, text)?
5885        }
5886        LocalToolTerminal::Cancelled => {
5887            collector.suffix_for_terminal(&latest, selection.0, selection.1, terminal)?
5888        }
5889    };
5890    let before = session.clone();
5891    let mut staged = session.clone();
5892    staged.messages = latest.messages.clone();
5893    staged.messages.extend(suffix.iter().cloned());
5894    staged.updated_at = chrono::Utc::now();
5895    let cancelled_messages = if matches!(terminal, LocalToolTerminal::Cancelled) {
5896        Some(serde_json::to_value(&staged.messages).map_err(|_| local_tool_history_unsupported())?)
5897    } else {
5898        None
5899    };
5900    validate_local_tool_checkpoint(session, binding, run_id, terminal, cancel).await?;
5901    binding
5902        .persistence
5903        .checkpoint_runtime_session(&mut staged)
5904        .await
5905        .map_err(|_| local_tool_history_unsupported())?;
5906    let saved = binding
5907        .storage
5908        .load_session(&session.id)
5909        .await
5910        .map_err(|_| local_tool_history_unsupported())?
5911        .ok_or_else(local_tool_history_unsupported)?;
5912    if serde_json::to_value(&saved.provider_transcript)
5913        .map_err(|_| local_tool_history_unsupported())?
5914        != serde_json::to_value(&latest.provider_transcript)
5915            .map_err(|_| local_tool_history_unsupported())?
5916    {
5917        return Err(local_tool_history_unsupported());
5918    }
5919    let start = match suffix.first() {
5920        Some(first) => saved
5921            .messages
5922            .iter()
5923            .position(|message| message.id == first.id)
5924            .ok_or_else(local_tool_history_unsupported)?,
5925        None => latest.messages.len(),
5926    };
5927    if cancelled_messages.as_ref().is_some_and(|expected| {
5928        serde_json::to_value(&saved.messages).ok().as_ref() != Some(expected)
5929    }) {
5930        // A new canonical input during cancellation has not been applied by
5931        // this Worker. Preserve it, but do not certify it with the old terminal.
5932        return Err(local_tool_history_unsupported());
5933    }
5934    if saved.messages.len() < latest.messages.len()
5935        || serde_json::to_value(&saved.messages[..latest.messages.len()])
5936            .map_err(|_| local_tool_history_unsupported())?
5937            != serde_json::to_value(&latest.messages)
5938                .map_err(|_| local_tool_history_unsupported())?
5939        || serde_json::to_value(saved.messages.get(start..start + suffix.len()))
5940            .map_err(|_| local_tool_history_unsupported())?
5941            != serde_json::to_value(Some(&suffix[..]))
5942                .map_err(|_| local_tool_history_unsupported())?
5943    {
5944        return Err(local_tool_history_unsupported());
5945    }
5946    // Append-safe reconciliation may add a concurrent Host User; it may never
5947    // rewrite the validated Host prefix or any worker row being committed.
5948    let expected = latest.messages.iter().chain(&suffix);
5949    for message in expected {
5950        let matches: Vec<_> = saved
5951            .messages
5952            .iter()
5953            .filter(|current| current.id == message.id)
5954            .collect();
5955        if matches.len() != 1
5956            || serde_json::to_value(matches[0]).map_err(|_| local_tool_history_unsupported())?
5957                != serde_json::to_value(message).map_err(|_| local_tool_history_unsupported())?
5958        {
5959            *session = before;
5960            return Err(local_tool_history_unsupported());
5961        }
5962    }
5963    validate_local_tool_checkpoint(session, binding, run_id, terminal, cancel).await?;
5964    staged.messages = saved.messages;
5965    *session = staged;
5966    Ok(())
5967}
5968
5969#[derive(Default)]
5970struct ReadOnlyActorCollector {
5971    start: Option<(String, serde_json::Value)>,
5972    result: Option<String>,
5973    messages: Option<Vec<bamboo_agent_core::Message>>,
5974}
5975impl ReadOnlyActorCollector {
5976    fn event(&mut self, value: &serde_json::Value) -> Result<bool, AgentError> {
5977        if self.messages.is_some() {
5978            return Err(plain_actor_unsupported());
5979        }
5980        if value["type"] == "owned_readonly_transcript" {
5981            let tail: bamboo_subagent::proto::ReadOnlyActorTranscript =
5982                serde_json::from_value(value.clone()).map_err(|_| plain_actor_unsupported())?;
5983            tail.validate().map_err(|_| plain_actor_unsupported())?;
5984            let bamboo_subagent::proto::ReadOnlyActorTranscript::Complete { messages } = tail;
5985            let typed: Vec<bamboo_agent_core::Message> = messages
5986                .iter()
5987                .map(|raw| {
5988                    let message: bamboo_agent_core::Message =
5989                        serde_json::from_value(raw.clone())
5990                            .map_err(|_| plain_actor_unsupported())?;
5991                    if serde_json::to_value(&message).map_err(|_| plain_actor_unsupported())?
5992                        != *raw
5993                    {
5994                        return Err(plain_actor_unsupported());
5995                    }
5996                    Ok(message)
5997                })
5998                .collect::<Result<_, _>>()?;
5999            let (id, args) = self
6000                .start
6001                .as_ref()
6002                .ok_or_else(|| plain_actor_unsupported())?;
6003            let call = typed[0]
6004                .tool_calls
6005                .as_ref()
6006                .and_then(|calls| {
6007                    if calls.len() == 1 {
6008                        calls.first()
6009                    } else {
6010                        None
6011                    }
6012                })
6013                .ok_or_else(|| plain_actor_unsupported())?;
6014            if call.id != *id
6015                || call.function.name != "Glob"
6016                || serde_json::from_str::<serde_json::Value>(&call.function.arguments)
6017                    .map_err(|_| plain_actor_unsupported())?
6018                    != *args
6019                || typed[1].tool_call_id.as_ref() != Some(id)
6020                || typed[1].tool_success != Some(true)
6021                || self.result.as_ref() != Some(&typed[1].content)
6022            {
6023                return Err(plain_actor_unsupported());
6024            }
6025            self.messages = Some(typed);
6026            return Ok(false);
6027        }
6028        let event: AgentEvent =
6029            serde_json::from_value(value.clone()).map_err(|_| plain_actor_unsupported())?;
6030        match event {
6031            AgentEvent::ToolStart {
6032                tool_call_id,
6033                tool_name,
6034                arguments,
6035            } => {
6036                if tool_name != "Glob"
6037                    || tool_call_id.trim().is_empty()
6038                    || self.start.is_some()
6039                    || !arguments.is_object()
6040                {
6041                    return Err(plain_actor_unsupported());
6042                }
6043                self.start = Some((tool_call_id, arguments));
6044                Ok(true)
6045            }
6046            AgentEvent::ToolComplete {
6047                tool_call_id,
6048                result,
6049            } => {
6050                if self.start.as_ref().map(|s| &s.0) != Some(&tool_call_id)
6051                    || self.result.is_some()
6052                    || !result.success
6053                    || !result.images.is_empty()
6054                {
6055                    return Err(plain_actor_unsupported());
6056                }
6057                self.result = Some(result.result);
6058                Ok(true)
6059            }
6060            AgentEvent::ToolToken { tool_call_id, .. } => {
6061                if self.start.as_ref().map(|s| &s.0) != Some(&tool_call_id) || self.result.is_some()
6062                {
6063                    return Err(plain_actor_unsupported());
6064                }
6065                Ok(true)
6066            }
6067            AgentEvent::ToolLifecycle {
6068                tool_call_id,
6069                tool_name,
6070                is_mutating,
6071                phase,
6072                error,
6073                ..
6074            } => {
6075                if tool_name != "Glob"
6076                    || is_mutating
6077                    || !matches!(phase.as_str(), "begin" | "finished")
6078                    || error.is_some()
6079                    || self.start.as_ref().map(|s| &s.0) != Some(&tool_call_id)
6080                {
6081                    return Err(plain_actor_unsupported());
6082                }
6083                Ok(true)
6084            }
6085            _ => plain_actor_event(value),
6086        }
6087    }
6088    fn finish(self, terminal: Option<&str>) -> Result<Vec<bamboo_agent_core::Message>, AgentError> {
6089        let messages = self.messages.ok_or_else(|| plain_actor_unsupported())?;
6090        if terminal != Some(messages[2].content.as_str()) || messages[2].content.trim().is_empty() {
6091            return Err(plain_actor_unsupported());
6092        }
6093        Ok(messages)
6094    }
6095}
6096
6097fn plain_actor_event(value: &serde_json::Value) -> Result<bool, AgentError> {
6098    let event: AgentEvent =
6099        serde_json::from_value(value.clone()).map_err(|_| plain_actor_unsupported())?;
6100    match event {
6101        AgentEvent::ReasoningToken { .. }
6102        | AgentEvent::ToolToken { .. }
6103        | AgentEvent::ToolStart { .. }
6104        | AgentEvent::ToolComplete { .. }
6105        | AgentEvent::ToolError { .. }
6106        | AgentEvent::ToolLifecycle { .. }
6107        | AgentEvent::ContextSummarized { .. }
6108        | AgentEvent::ContextArchived { .. }
6109        | AgentEvent::ContextCompressionStatus { .. }
6110        | AgentEvent::NeedClarification { .. }
6111        | AgentEvent::SubAgentStarted { .. }
6112        | AgentEvent::SubAgentEvent { .. }
6113        | AgentEvent::ChildApprovalRequested { .. } => Err(plain_actor_unsupported()),
6114        // Worker cache commits are not host Store commits. Publish only the
6115        // actual guarded host append after it returns a committed snapshot.
6116        AgentEvent::MessageAppended { .. }
6117        | AgentEvent::SessionHistoryCommitted { .. }
6118        | AgentEvent::VisibleMessageStart { .. }
6119        | AgentEvent::VisibleMessageDiscard { .. } => Ok(false),
6120        event if event.is_durable_change() => Ok(false),
6121        _ => Ok(true),
6122    }
6123}
6124
6125/// The `{kind,host}` placement descriptor stamped onto a child session's metadata
6126/// under `"placement"` — read back by the storage index → `SessionSummary.placement`
6127/// → the UI's machine badge. `None` for `Local` (those fall through to the DTO's
6128/// default of this backend's own host). The value is a JSON string matching
6129/// `bamboo_storage::SessionPlacement { kind, host }`.
6130fn placement_metadata(placement: &Placement, host_label: Option<&str>) -> Option<String> {
6131    // Prefer the cluster node's own label/host (its metadata) when the placement
6132    // maps to a node; else fall back to the raw endpoint host / pool name.
6133    let value = match placement {
6134        Placement::Local => return None,
6135        Placement::Remote { endpoint } => serde_json::json!({
6136            "kind": "remote",
6137            "host": host_label.map(str::to_string).unwrap_or_else(|| host_of_endpoint(endpoint)),
6138        }),
6139        Placement::Schedulable { pool } => serde_json::json!({
6140            "kind": "remote",
6141            "host": host_label.unwrap_or(pool),
6142        }),
6143    };
6144    serde_json::to_string(&value).ok()
6145}
6146
6147/// Extract the host from a `ws[s]://host:port[/path]` bus endpoint, for display.
6148fn host_of_endpoint(endpoint: &str) -> String {
6149    endpoint
6150        .trim()
6151        .trim_start_matches("wss://")
6152        .trim_start_matches("ws://")
6153        .split(['/', ':'])
6154        .next()
6155        .unwrap_or(endpoint)
6156        .to_string()
6157}
6158
6159async fn reconcile_already_admitted_claim(
6160    binding: &SessionInboxRuntimeBinding,
6161    session: &mut Session,
6162    claim: &SessionInboxClaim,
6163) -> crate::runtime::runner::Result<()> {
6164    let latest = binding
6165        .storage
6166        .load_session(&session.id)
6167        .await
6168        .map_err(|error| {
6169            AgentError::LLM(format!(
6170                "load canonical SessionInbox checkpoint for {}: {error}",
6171                session.id
6172            ))
6173        })?
6174        .ok_or_else(|| {
6175            AgentError::LLM(format!(
6176                "canonical SessionInbox target disappeared: {}",
6177                session.id
6178            ))
6179        })?;
6180    let Some(message) = latest
6181        .messages
6182        .iter()
6183        .find(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
6184        .cloned()
6185    else {
6186        return Err(AgentError::LLM(format!(
6187            "canonical admitted receipt for {} exists without transcript message {}",
6188            session.id, claim.envelope.id
6189        )));
6190    };
6191    if let Some(existing) = session
6192        .messages
6193        .iter_mut()
6194        .find(|existing| existing.id == message.id)
6195    {
6196        *existing = message;
6197    } else {
6198        session.add_message(message);
6199    }
6200    bamboo_domain::merge_session_inbox_admission(session, &latest);
6201    binding
6202        .inbox
6203        .ack(&session.id, claim)
6204        .await
6205        .map_err(|error| {
6206            AgentError::LLM(format!(
6207                "ack recovered canonical SessionInbox claim {}: {error}",
6208                claim.envelope.id
6209            ))
6210        })
6211}
6212
6213/// Checkpoint a worker-confirmed envelope into the canonical logical Session,
6214/// then create the permanent host receipt/remove its exact claim. This order is
6215/// the actor-side equivalent of the local state_bridge crash boundary.
6216async fn checkpoint_and_ack_canonical_claim(
6217    binding: &SessionInboxRuntimeBinding,
6218    session: &mut Session,
6219    claim: &SessionInboxClaim,
6220    fenced: Option<(&bamboo_storage::SessionStoreV2, &ActorActivationFence)>,
6221) -> crate::runtime::runner::Result<()> {
6222    if claim.envelope.target_session_id != session.id {
6223        return Err(AgentError::LLM(format!(
6224            "canonical SessionInbox claim target {} does not match active logical session {}",
6225            claim.envelope.target_session_id, session.id
6226        )));
6227    }
6228    if binding
6229        .inbox
6230        .was_admitted(&session.id, &claim.envelope.id)
6231        .await
6232        .map_err(|error| {
6233            AgentError::LLM(format!(
6234                "inspect canonical admitted receipt {}: {error}",
6235                claim.envelope.id
6236            ))
6237        })?
6238    {
6239        return reconcile_already_admitted_claim(binding, session, claim).await;
6240    }
6241
6242    if let Some((store, fence)) = fenced {
6243        let committed = store
6244            .confirm_actor_claim_context(
6245                concrete_file_inbox(binding)?,
6246                bamboo_storage::ActorClaimContextSeed {
6247                    fence: fence.clone(),
6248                    expected_created_at: session.created_at,
6249                    expected_messages: session.messages.clone(),
6250                    expected_provider_transcript: session.provider_transcript.clone(),
6251                    expected_admission: session.session_inbox_admission().cloned(),
6252                    claims: vec![claim.clone()],
6253                },
6254            )
6255            .await;
6256        let committed = match committed {
6257            Ok(committed) => committed,
6258            Err(error) => {
6259                // An error after atomic replacement may still have advanced
6260                // Main. Keep the claim unacked and use that canonical context
6261                // for the SDK's terminal status save.
6262                adopt_latest_actor_context(store, session).await;
6263                return Err(AgentError::LLM(format!(
6264                    "confirm fenced Actor claim context unconfirmed: {error}"
6265                )));
6266            }
6267        };
6268        bamboo_domain::merge_session_inbox_admission(session, &committed);
6269        session.messages = committed.messages;
6270        session.provider_transcript = committed.provider_transcript;
6271        session.updated_at = committed.updated_at;
6272        if !session
6273            .messages
6274            .iter()
6275            .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
6276            || !session
6277                .session_inbox_admission()
6278                .is_some_and(|cursor| cursor.contains(&claim.envelope.id))
6279        {
6280            return Err(AgentError::LLM(
6281                "fenced Actor claim confirmation lost durable proof".into(),
6282            ));
6283        }
6284        return binding
6285            .inbox
6286            .ack(&session.id, claim)
6287            .await
6288            .map_err(|error| {
6289                AgentError::LLM(format!(
6290                    "ack fenced Actor claim {} after confirmation: {error}",
6291                    claim.envelope.id
6292                ))
6293            });
6294    }
6295
6296    let transcript_has_id = session
6297        .messages
6298        .iter()
6299        .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope));
6300    if session
6301        .messages
6302        .iter()
6303        .any(|message| message.id == claim.envelope.id.as_str())
6304        && !transcript_has_id
6305    {
6306        return Err(AgentError::LLM(format!(
6307            "canonical SessionInbox id {} collides with a non-matching transcript message",
6308            claim.envelope.id
6309        )));
6310    }
6311    let cursor_has_id = session
6312        .session_inbox_admission()
6313        .is_some_and(|state| state.contains(&claim.envelope.id));
6314    if cursor_has_id && !transcript_has_id {
6315        return Err(AgentError::LLM(format!(
6316            "canonical SessionInbox cursor exists without transcript message {}",
6317            claim.envelope.id
6318        )));
6319    }
6320    let before = session.clone();
6321    if !transcript_has_id {
6322        let message = claim.envelope.to_provider_message().map_err(|error| {
6323            AgentError::LLM(format!(
6324                "translate canonical SessionInbox envelope {}: {error}",
6325                claim.envelope.id
6326            ))
6327        })?;
6328        session.add_message(message);
6329    }
6330    session
6331        .session_inbox_admission_mut()
6332        .record(claim.envelope.id.clone(), claim.generation);
6333    session.updated_at = chrono::Utc::now();
6334
6335    if let Err(error) = binding
6336        .persistence
6337        .checkpoint_runtime_session(session)
6338        .await
6339    {
6340        *session = before;
6341        return Err(AgentError::LLM(format!(
6342            "checkpoint canonical SessionInbox claim {}: {error}",
6343            claim.envelope.id
6344        )));
6345    }
6346    if !session
6347        .messages
6348        .iter()
6349        .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
6350    {
6351        *session = before;
6352        return Err(AgentError::LLM(format!(
6353            "canonical SessionInbox checkpoint lost typed transcript proof for {}",
6354            claim.envelope.id
6355        )));
6356    }
6357    binding
6358        .inbox
6359        .ack(&session.id, claim)
6360        .await
6361        .map_err(|error| {
6362            AgentError::LLM(format!(
6363                "ack canonical SessionInbox claim {} after checkpoint: {error}",
6364                claim.envelope.id
6365            ))
6366        })
6367}
6368
6369fn concrete_file_inbox(
6370    binding: &SessionInboxRuntimeBinding,
6371) -> Result<bamboo_storage::FileSessionInbox, AgentError> {
6372    binding
6373        .inbox
6374        .as_any()
6375        .and_then(|inbox| inbox.downcast_ref::<bamboo_storage::FileSessionInbox>())
6376        .cloned()
6377        .ok_or_else(|| AgentError::LLM("remote Actor requires canonical FileSessionInbox".into()))
6378}
6379
6380async fn adopt_latest_actor_context(store: &bamboo_storage::SessionStoreV2, session: &mut Session) {
6381    if let Ok(Some(latest)) = store.load_session(&session.id).await {
6382        if latest.created_at == session.created_at {
6383            bamboo_domain::merge_session_inbox_admission(session, &latest);
6384            session.messages = latest.messages;
6385            session.provider_transcript = latest.provider_transcript;
6386            session.updated_at = latest.updated_at;
6387        }
6388    }
6389}
6390
6391/// Durably seed claimed typed messages into the canonical host transcript
6392/// before dispatching them to any actor worker, while deliberately leaving the
6393/// admission cursor and `cur/` claims untouched.
6394///
6395/// This is the cross-placement lost-confirmation invariant: if worker A admits
6396/// and reasons over the batch but its confirmations are lost, a retry on worker
6397/// B receives a host snapshot already containing each stable typed message
6398/// exactly once. Worker B's local safe boundary then records/acks the same ids
6399/// without appending duplicates. Only an exact worker confirmation advances the
6400/// host cursor; only a durable cursor checkpoint precedes host ack.
6401async fn checkpoint_claim_context_before_dispatch(
6402    binding: &SessionInboxRuntimeBinding,
6403    session: &mut Session,
6404    claims: &[SessionInboxClaim],
6405    fenced: Option<(&bamboo_storage::SessionStoreV2, &ActorActivationFence)>,
6406) -> crate::runtime::runner::Result<()> {
6407    if claims.is_empty() {
6408        return Ok(());
6409    }
6410    let before = session.clone();
6411    let mut additions = Vec::new();
6412    for claim in claims {
6413        if claim.envelope.target_session_id != session.id {
6414            return Err(AgentError::LLM(format!(
6415                "canonical SessionInbox claim target {} does not match actor session {}",
6416                claim.envelope.target_session_id, session.id
6417            )));
6418        }
6419        let matching = session
6420            .messages
6421            .iter()
6422            .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope));
6423        if session
6424            .messages
6425            .iter()
6426            .any(|message| message.id == claim.envelope.id.as_str())
6427            && !matching
6428        {
6429            return Err(AgentError::LLM(format!(
6430                "canonical SessionInbox id {} collides before actor dispatch",
6431                claim.envelope.id
6432            )));
6433        }
6434        if session
6435            .session_inbox_admission()
6436            .is_some_and(|state| state.contains(&claim.envelope.id))
6437            && !matching
6438        {
6439            return Err(AgentError::LLM(format!(
6440                "canonical SessionInbox cursor exists without transcript proof for {}",
6441                claim.envelope.id
6442            )));
6443        }
6444        if !matching {
6445            let message = claim.envelope.to_provider_message().map_err(|error| {
6446                AgentError::LLM(format!(
6447                    "translate canonical SessionInbox envelope {} before actor dispatch: {error}",
6448                    claim.envelope.id
6449                ))
6450            })?;
6451            additions.push(message);
6452        }
6453    }
6454    let fenced_seed = fenced.is_some();
6455    if let Some((store, fence)) = fenced {
6456        let committed = store
6457            .seed_actor_claim_context(
6458                concrete_file_inbox(binding)?,
6459                bamboo_storage::ActorClaimContextSeed {
6460                    fence: fence.clone(),
6461                    expected_created_at: session.created_at,
6462                    expected_messages: session.messages.clone(),
6463                    expected_provider_transcript: session.provider_transcript.clone(),
6464                    expected_admission: session.session_inbox_admission().cloned(),
6465                    claims: claims.to_vec(),
6466                },
6467            )
6468            .await;
6469        let committed = match committed {
6470            Ok(committed) => committed,
6471            Err(error) => {
6472                // Publication may have occurred before an unconfirmed
6473                // readback. Do not dispatch or ACK; adopt only the current
6474                // canonical context so SDK can still save an error status.
6475                adopt_latest_actor_context(store, session).await;
6476                return Err(AgentError::LLM(format!(
6477                    "seed fenced Actor claim context unconfirmed: {error}"
6478                )));
6479            }
6480        };
6481        bamboo_domain::merge_session_inbox_admission(session, &committed);
6482        session.messages = committed.messages;
6483        session.provider_transcript = committed.provider_transcript;
6484        session.updated_at = committed.updated_at;
6485    } else {
6486        for message in additions {
6487            session.add_message(message);
6488        }
6489        session.updated_at = chrono::Utc::now();
6490        if let Err(error) = binding
6491            .persistence
6492            .checkpoint_runtime_session(session)
6493            .await
6494        {
6495            *session = before;
6496            return Err(AgentError::LLM(format!(
6497                "checkpoint canonical SessionInbox actor context: {error}"
6498            )));
6499        }
6500    }
6501    for claim in claims {
6502        if !session
6503            .messages
6504            .iter()
6505            .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
6506        {
6507            if !fenced_seed {
6508                *session = before;
6509            }
6510            return Err(AgentError::LLM(format!(
6511                "actor context checkpoint lost typed transcript proof for {}",
6512                claim.envelope.id
6513            )));
6514        }
6515    }
6516    Ok(())
6517}
6518
6519async fn claim_canonical_deliveries(
6520    binding: &SessionInboxRuntimeBinding,
6521    session: &mut Session,
6522    activation_run_id: &str,
6523    limit: usize,
6524    fenced: Option<(&bamboo_storage::SessionStoreV2, &ActorActivationFence)>,
6525) -> crate::runtime::runner::Result<Vec<(SessionInboxClaim, SessionMessageDelivery)>> {
6526    let claims = binding
6527        .inbox
6528        .claim_for_turn(&session.id, limit, Some(activation_run_id))
6529        .await
6530        .map_err(|error| {
6531            AgentError::LLM(format!(
6532                "claim canonical SessionInbox for active actor {}: {error}",
6533                session.id
6534            ))
6535        })?;
6536    if claims.is_empty() {
6537        return Ok(Vec::new());
6538    }
6539    let mut unconfirmed = Vec::with_capacity(claims.len());
6540    for claim in claims {
6541        if binding
6542            .inbox
6543            .was_admitted(&session.id, &claim.envelope.id)
6544            .await
6545            .map_err(|error| {
6546                AgentError::LLM(format!(
6547                    "inspect canonical SessionInbox claim {}: {error}",
6548                    claim.envelope.id
6549                ))
6550            })?
6551        {
6552            reconcile_already_admitted_claim(binding, session, &claim).await?;
6553            continue;
6554        }
6555        unconfirmed.push(claim);
6556    }
6557    checkpoint_claim_context_before_dispatch(binding, session, &unconfirmed, fenced).await?;
6558
6559    let mut deliveries = Vec::with_capacity(unconfirmed.len());
6560    for claim in unconfirmed {
6561        // Cursor+transcript without the permanent tombstone is the recoverable
6562        // crash window after an exact worker confirmation was checkpointed but
6563        // before host ack removed `cur/`. Finish that ack without exposing the
6564        // message to another provider run.
6565        if session
6566            .session_inbox_admission()
6567            .is_some_and(|state| state.contains(&claim.envelope.id))
6568        {
6569            binding
6570                .inbox
6571                .ack(&session.id, &claim)
6572                .await
6573                .map_err(|error| {
6574                    AgentError::LLM(format!(
6575                        "finish confirmed canonical SessionInbox ack {}: {error}",
6576                        claim.envelope.id
6577                    ))
6578                })?;
6579            continue;
6580        }
6581        let delivery = SessionMessageDelivery {
6582            target_session_id: session.id.clone(),
6583            envelope: claim.envelope.clone(),
6584            canonical_claim_generation: claim.generation,
6585            activation_run_id: activation_run_id.to_string(),
6586            activation_policy: claim.activation_policy,
6587        };
6588        deliveries.push((claim, delivery));
6589    }
6590    Ok(deliveries)
6591}
6592
6593async fn forward_next_canonical_claim(
6594    client: &mut dyn bamboo_subagent::ChildLink,
6595    binding: &SessionInboxRuntimeBinding,
6596    session: &mut Session,
6597    activation_run_id: &str,
6598    inflight: &mut VecDeque<SessionInboxClaim>,
6599    fenced: Option<(&bamboo_storage::SessionStoreV2, &ActorActivationFence)>,
6600) -> crate::runtime::runner::Result<()> {
6601    if !inflight.is_empty() {
6602        return Ok(());
6603    }
6604    let Some((claim, delivery)) =
6605        claim_canonical_deliveries(binding, session, activation_run_id, 1, fenced)
6606            .await?
6607            .pop()
6608    else {
6609        return Ok(());
6610    };
6611    client
6612        .send(ParentFrame::SessionMessage { delivery })
6613        .await
6614        .map_err(|error| {
6615            AgentError::LLM(format!(
6616                "forward canonical SessionInbox claim {} to active actor: {error}",
6617                claim.envelope.id
6618            ))
6619        })?;
6620    inflight.push_back(claim);
6621    Ok(())
6622}
6623
6624/// Borrowed and per-run-owned inputs for one actor frame pump.
6625struct ActorDriveContext<'a> {
6626    client: &'a mut dyn bamboo_subagent::ChildLink,
6627    parent_session_id: &'a str,
6628    child_session_id: &'a str,
6629    child_attempt: u32,
6630    approval_registry: Option<&'a super::approval_registry::SharedApprovalRegistry>,
6631    approval_decider: Option<&'a Arc<dyn ChildApprovalDecider>>,
6632    approval_reviewer: Option<&'a Arc<dyn ChildApprovalReviewer>>,
6633    escalation_bridge: Option<bamboo_subagent::executor::HostBridge>,
6634    event_tx: &'a mpsc::Sender<AgentEvent>,
6635    cancel_token: &'a CancellationToken,
6636    live_rx: &'a mut mpsc::UnboundedReceiver<ParentFrame>,
6637    delivery_rx: &'a mut mpsc::UnboundedReceiver<u64>,
6638    logical_session: &'a mut Session,
6639    expected_permission_posture: Option<ExpectedPermissionPosture>,
6640    expected_creation: Option<&'a bamboo_subagent::proto::ChildCreationIdentity>,
6641    session_inbox_runtime: Option<&'a SessionInboxRuntimeBinding>,
6642    actor_directory_store: Option<&'a bamboo_storage::SessionStoreV2>,
6643    canonical_subagent_tool: Option<Arc<dyn bamboo_agent_core::tools::Tool>>,
6644    ticket_service: Option<Arc<bamboo_tickets::TicketService>>,
6645    activation_run_id: Option<&'a str>,
6646    execution_epoch: u64,
6647    expected_source_actor_id: &'a str,
6648    initial_inflight_claims: VecDeque<SessionInboxClaim>,
6649    plain_actor: bool,
6650    remote_environment_lease: bool,
6651    readonly_output: Option<&'a mut Vec<bamboo_agent_core::Message>>,
6652    local_history_tools: Option<&'a [String]>,
6653    local_history_read_only: bool,
6654    strict_history_output: Option<&'a mut Option<StrictTerminalHistory>>,
6655    plain_input: Option<&'a PlainActorActivation>,
6656    canonical_activation: Option<&'a ActorActivationFence>,
6657    canonical_placement_ref: Option<&'a bamboo_domain::ActorPlacementRef>,
6658    actor_event_observer: Option<&'a dyn ActorEventObserver>,
6659    plain_run: Option<(&'a RunSpec, &'a AtomicU64)>,
6660    first_frame_timeout: Option<Duration>,
6661}
6662
6663#[derive(Debug, Clone, PartialEq, Eq)]
6664struct ExpectedPermissionPosture {
6665    policy_revision: u64,
6666    resolution: bamboo_domain::PermissionModeResolution,
6667    expected_audit_revision: Option<u64>,
6668    executor_mapping: String,
6669}
6670
6671#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6672enum PermissionPostureHandshake {
6673    /// Legacy/custom actors were dispatched without a typed posture contract.
6674    NotRequired,
6675    /// The host dispatched an exact posture and no matching, durably recorded
6676    /// worker activation has arrived yet.
6677    Awaiting,
6678    /// One worker posture matched and its host-owned audit write succeeded.
6679    Confirmed,
6680}
6681
6682impl PermissionPostureHandshake {
6683    fn new(expected: Option<&ExpectedPermissionPosture>) -> Self {
6684        if expected.is_some() {
6685            Self::Awaiting
6686        } else {
6687            Self::NotRequired
6688        }
6689    }
6690
6691    fn is_awaiting(self) -> bool {
6692        self == Self::Awaiting
6693    }
6694
6695    fn posture_was_confirmed(self) -> bool {
6696        self == Self::Confirmed
6697    }
6698}
6699
6700fn permission_posture_seed_from_event(
6701    session: &Session,
6702    event: &AgentEvent,
6703) -> Result<Option<bamboo_domain::PermissionAuditSeed>, String> {
6704    let AgentEvent::PermissionPostureActivated {
6705        session_id,
6706        policy_revision,
6707        requested_mode,
6708        effective_mode,
6709        executor_mapping,
6710    } = event
6711    else {
6712        return Ok(None);
6713    };
6714    if session_id != &session.id {
6715        return Err("permission posture event targets a different logical session".to_string());
6716    }
6717    let requested = bamboo_domain::SessionPermissionMode::from_audit_str(requested_mode)
6718        .ok_or_else(|| "permission posture event has an invalid requested mode".to_string())?;
6719    let effective = bamboo_domain::PermissionMode::from_audit_str(effective_mode)
6720        .ok_or_else(|| "permission posture event has an invalid effective mode".to_string())?;
6721    let resolution = bamboo_domain::PermissionModeResolution {
6722        requested,
6723        effective,
6724    };
6725    if !resolution.is_consistent() {
6726        return Err("permission posture event has an inconsistent mode pair".to_string());
6727    }
6728    let current_requested = session
6729        .agent_runtime_state
6730        .as_ref()
6731        .map(|state| state.effective_permission_mode())
6732        .unwrap_or_default();
6733    if current_requested != requested {
6734        return Err("permission posture event is stale for the host typed mode".to_string());
6735    }
6736    let mapping_chars = executor_mapping.chars().count();
6737    if mapping_chars == 0 || mapping_chars > bamboo_domain::MAX_PERMISSION_EXECUTOR_MAPPING_CHARS {
6738        return Err("permission posture event has an invalid executor mapping".to_string());
6739    }
6740    Ok(Some(bamboo_domain::PermissionAuditSeed::new(
6741        *policy_revision,
6742        resolution,
6743        executor_mapping,
6744    )))
6745}
6746
6747fn actor_event_logical_identity(
6748    session: &Session,
6749    parent_session_id: &str,
6750    creation: Option<&bamboo_subagent::proto::ChildCreationIdentity>,
6751) -> LogicalSessionIdentity {
6752    LogicalSessionIdentity {
6753        creation: creation.cloned(),
6754        session_id: session.id.clone(),
6755        parent_session_id: session
6756            .parent_session_id
6757            .clone()
6758            .or_else(|| Some(parent_session_id.to_string())),
6759        root_session_id: if session.root_session_id.trim().is_empty() {
6760            parent_session_id.to_string()
6761        } else {
6762            session.root_session_id.clone()
6763        },
6764    }
6765}
6766
6767fn validate_strict_history_terminal(
6768    watermark: Option<&bamboo_subagent::ActorEventWatermark>,
6769    session: &Session,
6770    parent_session_id: &str,
6771    activation_run_id: Option<&str>,
6772    execution_epoch: u64,
6773    creation: Option<&bamboo_subagent::proto::ChildCreationIdentity>,
6774    consumed_seq: u64,
6775) -> Result<(), AgentError> {
6776    let identity = actor_event_logical_identity(session, parent_session_id, creation);
6777    if watermark
6778        .zip(activation_run_id)
6779        .is_some_and(|(watermark, run_id)| {
6780            watermark.matches_consumed_run(&identity, run_id, execution_epoch, consumed_seq)
6781        })
6782    {
6783        Ok(())
6784    } else {
6785        Err(AgentError::LLM("actor_history_tail_incomplete".into()))
6786    }
6787}
6788
6789fn validate_actor_event_batch(
6790    batch: &ActorEventBatch,
6791    logical_session: &Session,
6792    parent_session_id: &str,
6793    activation_run_id: Option<&str>,
6794    execution_epoch: u64,
6795    expected_source_actor_id: &str,
6796    expected_creation: Option<&bamboo_subagent::proto::ChildCreationIdentity>,
6797) -> Result<(), AgentError> {
6798    batch
6799        .validate()
6800        .map_err(|error| AgentError::LLM(format!("actor emitted invalid event batch: {error}")))?;
6801    let identity = batch.logical_session.as_ref().ok_or_else(|| {
6802        AgentError::LLM("actor event batch omitted its logical session identity".to_string())
6803    })?;
6804    // Fence against the exact canonical identity used to build RunSpec. Some
6805    // migrated/test sessions have not yet materialized parent ancestry on the
6806    // in-memory Session, so comparing the batch directly with those raw fields
6807    // can reject the identity the host itself just dispatched.
6808    let expected_identity =
6809        actor_event_logical_identity(logical_session, parent_session_id, expected_creation);
6810    if identity != &expected_identity {
6811        return Err(AgentError::LLM(
6812            "actor event batch targets a different logical session".to_string(),
6813        ));
6814    }
6815    if batch.activation_id.as_deref() != activation_run_id {
6816        return Err(AgentError::LLM(
6817            "actor event batch belongs to a stale activation".to_string(),
6818        ));
6819    }
6820    if batch.execution_epoch != execution_epoch {
6821        return Err(AgentError::LLM(
6822            "actor event batch belongs to a stale execution epoch".to_string(),
6823        ));
6824    }
6825    if batch
6826        .source_actor_id
6827        .as_deref()
6828        .is_some_and(|source| source != expected_source_actor_id)
6829    {
6830        return Err(AgentError::LLM(
6831            "actor event batch declares a different physical actor".to_string(),
6832        ));
6833    }
6834    Ok(())
6835}
6836
6837/// The plain Actor path already owns a durable Directory activation. Check that
6838/// exact owner before admitting either batched or rolling-upgrade live frames;
6839/// worker-provided batch identity alone is never an activation authority.
6840async fn validate_directory_actor_event(
6841    store: &bamboo_storage::SessionStoreV2,
6842    fence: &ActorActivationFence,
6843    session: &Session,
6844    run_id: Option<&str>,
6845) -> Result<bamboo_domain::ActorDirectoryEntry, AgentError> {
6846    store
6847        .validate_fence(fence, chrono::Utc::now())
6848        .await
6849        .map_err(|_| AgentError::LLM("actor event owner is stale".into()))?;
6850    let entry = store
6851        .inspect_actor(&fence.actor_id)
6852        .await
6853        .map_err(|_| AgentError::LLM("actor event authority is unavailable".into()))?;
6854    if !entry.actor.matches_session(session)
6855        || entry.actor.project_id != project_id_for_actor_run(session)?.map(|id| id.to_string())
6856        || !entry.activation.as_ref().is_some_and(|current| {
6857            current.matches_fence(fence)
6858                && current.status == bamboo_domain::ActorActivationStatus::Running
6859        })
6860        || run_id.is_some_and(|id| id != fence.run_id)
6861    {
6862        return Err(AgentError::LLM("actor event authority changed".into()));
6863    }
6864    Ok(entry)
6865}
6866
6867/// The Worker supplies only a cursor. All identity and scope inputs come from
6868/// this Host's active drive and its canonical durable Session store.
6869async fn inspect_active_owned_tree(
6870    store: &bamboo_storage::SessionStoreV2,
6871    binding: &SessionInboxRuntimeBinding,
6872    run_id: &str,
6873    child_id: &str,
6874    parent_id: &str,
6875    logical_session: &Session,
6876    creation: &bamboo_subagent::proto::ChildCreationIdentity,
6877    plain_input: Option<&PlainActorActivation>,
6878    cursor: Option<&str>,
6879) -> Option<serde_json::Value> {
6880    use bamboo_agent_core::storage::Storage;
6881    let current = match store.load_session(child_id).await {
6882        Ok(Some(current)) => current,
6883        _ => {
6884            tracing::warn!("owned tree denied: canonical caller unavailable");
6885            return None;
6886        }
6887    };
6888    let current_project = match project_id_for_actor_run(&current) {
6889        Ok(project) => project,
6890        Err(_) => {
6891            tracing::warn!("owned tree denied: invalid canonical Project");
6892            return None;
6893        }
6894    };
6895    let logical_project = match project_id_for_actor_run(logical_session) {
6896        Ok(project) => project,
6897        Err(_) => {
6898            tracing::warn!("owned tree denied: invalid logical Project");
6899            return None;
6900        }
6901    };
6902    if current.id != child_id
6903        || current.kind != bamboo_domain::SessionKind::Child
6904        || current.parent_session_id.as_deref() != Some(parent_id)
6905        || current.parent_session_id != logical_session.parent_session_id
6906        || current.root_session_id != logical_session.root_session_id
6907        || current.created_at != creation.created_at
6908        || current.created_at != logical_session.created_at
6909        || current.spawn_depth != creation.spawn_depth
6910        || current.spawn_depth != logical_session.spawn_depth
6911        || current_project != logical_project
6912    {
6913        tracing::warn!("owned tree denied: caller lifetime or Project changed");
6914        return None;
6915    }
6916    if let Some(activation) = plain_input {
6917        validate_directory_actor_event(
6918            activation.store.as_ref(),
6919            &activation.fence,
6920            &current,
6921            Some(run_id),
6922        )
6923        .await
6924        .ok()?;
6925    } else if !binding.router.owns_run(child_id, run_id).await {
6926        tracing::warn!("owned tree denied: activation owner changed");
6927        return None;
6928    }
6929    let page = match crate::session_app::child_session::owned_tree::inspect_owned_tree(
6930        store, child_id, cursor,
6931    )
6932    .await
6933    {
6934        Ok(page) => page,
6935        Err(error) => {
6936            tracing::warn!(?error, "owned tree denied: canonical tree read failed");
6937            return None;
6938        }
6939    };
6940    let again = match store.load_session(child_id).await {
6941        Ok(Some(again)) => again,
6942        _ => {
6943            tracing::warn!("owned tree denied: canonical caller disappeared");
6944            return None;
6945        }
6946    };
6947    if again.created_at != current.created_at
6948        || again.parent_session_id != current.parent_session_id
6949        || again.root_session_id != current.root_session_id
6950        || again.spawn_depth != current.spawn_depth
6951        || project_id_for_actor_run(&again).ok()? != current_project
6952    {
6953        tracing::warn!("owned tree denied: caller changed during inspection");
6954        return None;
6955    }
6956    if let Some(activation) = plain_input {
6957        validate_directory_actor_event(
6958            activation.store.as_ref(),
6959            &activation.fence,
6960            &again,
6961            Some(run_id),
6962        )
6963        .await
6964        .ok()?;
6965    } else if !binding.router.owns_run(child_id, run_id).await {
6966        tracing::warn!("owned tree denied: activation owner changed after inspection");
6967        return None;
6968    }
6969    serde_json::to_vec(&page)
6970        .ok()
6971        .filter(|bytes| bytes.len() <= 8192)?;
6972    Some(page)
6973}
6974
6975/// Resolve the caller of a mutating nested SubAgent RPC entirely from the
6976/// Host's active drive and canonical store. The frame never supplies identity,
6977/// Project, ancestry, or a run fence.
6978async fn load_active_subagent_caller(
6979    store: &bamboo_storage::SessionStoreV2,
6980    binding: &SessionInboxRuntimeBinding,
6981    run_id: &str,
6982    child_id: &str,
6983    parent_id: &str,
6984    logical_session: &Session,
6985    creation: &bamboo_subagent::proto::ChildCreationIdentity,
6986    plain_input: Option<&PlainActorActivation>,
6987) -> Option<Session> {
6988    use bamboo_agent_core::storage::Storage;
6989    let current = store.load_session(child_id).await.ok().flatten()?;
6990    let current_project = project_id_for_actor_run(&current).ok()?;
6991    let logical_project = project_id_for_actor_run(logical_session).ok()?;
6992    if current.id != child_id
6993        || current.kind != bamboo_domain::SessionKind::Child
6994        || current.parent_session_id.as_deref() != Some(parent_id)
6995        || current.parent_session_id != logical_session.parent_session_id
6996        || current.root_session_id != logical_session.root_session_id
6997        || current.created_at != creation.created_at
6998        || current.created_at != logical_session.created_at
6999        || current.spawn_depth != creation.spawn_depth
7000        || current.spawn_depth != logical_session.spawn_depth
7001        || current_project != logical_project
7002        || bamboo_domain::ActorSession::from_session(&current).is_err()
7003    {
7004        return None;
7005    }
7006    if let Some(activation) = plain_input {
7007        validate_directory_actor_event(
7008            activation.store.as_ref(),
7009            &activation.fence,
7010            &current,
7011            Some(run_id),
7012        )
7013        .await
7014        .ok()?;
7015    } else if !binding.router.owns_run(child_id, run_id).await {
7016        return None;
7017    }
7018    Some(current)
7019}
7020
7021async fn invoke_active_subagent(
7022    tool: &dyn bamboo_agent_core::tools::Tool,
7023    caller: &Session,
7024    args: serde_json::Value,
7025    tool_call_id: &str,
7026) -> serde_json::Value {
7027    use bamboo_agent_core::tools::{ToolCtx, ToolOutcome};
7028    let mut ctx = ToolCtx::none(tool_call_id.to_owned());
7029    ctx.session_id = Some(Arc::from(caller.id.as_str()));
7030    match tool.invoke(args, ctx).await {
7031        Ok(ToolOutcome::Completed(result))
7032            if result.images.is_empty()
7033                && serde_json::to_vec(&result).is_ok_and(|bytes| bytes.len() <= 16 * 1024) =>
7034        {
7035            serde_json::json!({"result":result})
7036        }
7037        _ => serde_json::json!({
7038            "error":"SubAgent operation failed or its result exceeded the limit; inspect logical child state before retrying"
7039        }),
7040    }
7041}
7042
7043/// Commit one already-persisted Worker clarification under the Host's current
7044/// Child birth and Run fence. Only the Host supplies caller/parent identity.
7045#[allow(clippy::too_many_arguments)]
7046async fn checkpoint_active_parent_question(
7047    store: &bamboo_storage::SessionStoreV2,
7048    binding: &SessionInboxRuntimeBinding,
7049    run_id: &str,
7050    child_id: &str,
7051    parent_id: &str,
7052    logical_session: &mut Session,
7053    creation: &bamboo_subagent::proto::ChildCreationIdentity,
7054    plain_input: Option<&PlainActorActivation>,
7055    observation: ParentQuestionCheckpointV1,
7056) -> Option<String> {
7057    use bamboo_agent_core::storage::Storage;
7058    if plain_input.is_some() || observation.validate_shape().is_err() {
7059        return None;
7060    }
7061    // SessionRepository's final save and the direct-parent answer resolver
7062    // use this same Host-owned lock. Hold it across the V2 read/CAS/save so
7063    // neither can read an older Child and overwrite the checkpoint afterward.
7064    let _session_guard = binding
7065        .parent_question_lock
7066        .as_ref()?
7067        .acquire_lock(child_id)
7068        .await;
7069    let caller = load_active_subagent_caller(
7070        store,
7071        binding,
7072        run_id,
7073        child_id,
7074        parent_id,
7075        logical_session,
7076        creation,
7077        plain_input,
7078    )
7079    .await?;
7080    let parent = store.load_session(parent_id).await.ok().flatten()?;
7081    if parent.created_at > caller.created_at || !binding.router.owns_run(child_id, run_id).await {
7082        return None;
7083    }
7084    let (saved, question) = store
7085        .checkpoint_parent_question(&caller, &parent, &observation)
7086        .await
7087        .ok()
7088        .flatten()?;
7089    // The current Run's local snapshot must carry the committed canonical
7090    // prefix into finalization; sdk/spawn rechecks disk before its last save.
7091    logical_session.messages = saved.messages;
7092    logical_session.pending_question = saved.pending_question;
7093    if let Some(reason) = saved.metadata.get("runtime.suspend_reason") {
7094        logical_session
7095            .metadata
7096            .insert("runtime.suspend_reason".into(), reason.clone());
7097    }
7098    if let Some(request) = saved
7099        .metadata
7100        .get(bamboo_domain::PARENT_QUESTION_REQUEST_KEY)
7101    {
7102        logical_session.metadata.insert(
7103            bamboo_domain::PARENT_QUESTION_REQUEST_KEY.into(),
7104            request.clone(),
7105        );
7106    }
7107    logical_session.metadata.insert(
7108        "runtime.actor_parent_question_handoff".into(),
7109        "true".into(),
7110    );
7111    Some(question.id.as_str().to_owned())
7112}
7113
7114/// An actor Worker cannot see the Host's canonical descendants. At the end of
7115/// an otherwise completed Child turn, apply the same orphan-child safety net
7116/// used by the in-process root loop before publishing this Child as terminal.
7117/// The existing Host wait, including an explicit `any` policy, always wins.
7118async fn ensure_active_nested_wait(
7119    tool: &dyn bamboo_agent_core::tools::Tool,
7120    store: &bamboo_storage::SessionStoreV2,
7121    binding: &SessionInboxRuntimeBinding,
7122    run_id: &str,
7123    child_id: &str,
7124    parent_id: &str,
7125    logical_session: &Session,
7126    creation: &bamboo_subagent::proto::ChildCreationIdentity,
7127    plain_input: Option<&PlainActorActivation>,
7128) -> Result<bool, AgentError> {
7129    use bamboo_agent_core::storage::Storage;
7130    use bamboo_agent_core::tools::{ToolCtx, ToolOutcome};
7131
7132    let caller = load_active_subagent_caller(
7133        store,
7134        binding,
7135        run_id,
7136        child_id,
7137        parent_id,
7138        logical_session,
7139        creation,
7140        plain_input,
7141    )
7142    .await
7143    .ok_or_else(|| AgentError::LLM("nested wait caller is no longer active".into()))?;
7144    if caller
7145        .agent_runtime_state
7146        .as_ref()
7147        .and_then(|state| state.waiting_for_children.as_ref())
7148        .is_some()
7149    {
7150        return Ok(true);
7151    }
7152
7153    let active = store
7154        .list_child_run_statuses(child_id)
7155        .await
7156        .map_err(|error| AgentError::LLM(format!("nested child status scan failed: {error}")))?
7157        .into_iter()
7158        .filter(|(_, status)| {
7159            !status.as_deref().is_some_and(|status| {
7160                matches!(
7161                    status,
7162                    "completed" | "error" | "timeout" | "cancelled" | "skipped"
7163                )
7164            })
7165        })
7166        .map(|(id, _)| id)
7167        .collect::<Vec<_>>();
7168    if active.is_empty() {
7169        return Ok(false);
7170    }
7171
7172    let mut ctx = ToolCtx::none(format!("host-nested-orphan-wait:{run_id}"));
7173    ctx.session_id = Some(Arc::from(caller.id.as_str()));
7174    let outcome = tokio::time::timeout(
7175        Duration::from_secs(30),
7176        tool.invoke(
7177            serde_json::json!({"action":"wait", "child_session_ids":active}),
7178            ctx,
7179        ),
7180    )
7181    .await
7182    .map_err(|_| AgentError::LLM("nested wait registration timed out".into()))?
7183    .map_err(|error| AgentError::LLM(format!("nested wait registration failed: {error}")))?;
7184    let wait_registered = match outcome {
7185        ToolOutcome::Completed(result) if result.success => {
7186            let payload: serde_json::Value = serde_json::from_str(&result.result)
7187                .map_err(|_| AgentError::LLM("nested wait result is invalid".into()))?;
7188            payload["status"] == "waiting"
7189        }
7190        _ => {
7191            return Err(AgentError::LLM(
7192                "nested wait registration was not completed".into(),
7193            ))
7194        }
7195    };
7196
7197    // A grandchild may finish while the wait is being registered and clear it.
7198    // A clear wait is safe only when no canonical descendants remain active.
7199    let latest = load_active_subagent_caller(
7200        store,
7201        binding,
7202        run_id,
7203        child_id,
7204        parent_id,
7205        logical_session,
7206        creation,
7207        plain_input,
7208    )
7209    .await
7210    .ok_or_else(|| AgentError::LLM("nested wait caller changed during registration".into()))?;
7211    if latest
7212        .agent_runtime_state
7213        .as_ref()
7214        .and_then(|state| state.waiting_for_children.as_ref())
7215        .is_some()
7216    {
7217        return Ok(true);
7218    }
7219    let still_active = store
7220        .list_child_run_statuses(child_id)
7221        .await
7222        .map_err(|error| {
7223            AgentError::LLM(format!("nested child wait verification failed: {error}"))
7224        })?
7225        .into_iter()
7226        .any(|(_, status)| {
7227            !status.as_deref().is_some_and(|status| {
7228                matches!(
7229                    status,
7230                    "completed" | "error" | "timeout" | "cancelled" | "skipped"
7231                )
7232            })
7233        });
7234    if still_active {
7235        return Err(AgentError::LLM(
7236            "nested child remains active without a durable parent wait".into(),
7237        ));
7238    }
7239    // Keep the current Run's suspend intent even if the last grandchild
7240    // finished and cleared the Host wait before SDK finalization. Its outcome
7241    // has already admitted a successor; publishing this Run as completed
7242    // would release the grandparent on the Child's interim response.
7243    Ok(wait_registered)
7244}
7245
7246fn actor_event_route_error(error: ActorEventRouteError) -> AgentError {
7247    let message = match error {
7248        ActorEventRouteError::SequenceGap { .. } => {
7249            "actor event sequence gap; authoritative session snapshot is required"
7250        }
7251        ActorEventRouteError::LifecycleMissing => {
7252            "actor emitted live content before its activation lifecycle event"
7253        }
7254        ActorEventRouteError::ForgedSession => {
7255            "actor event payload targets a different canonical session"
7256        }
7257        ActorEventRouteError::ConflictingReplay => {
7258            "actor replay reused an event sequence with different content"
7259        }
7260        ActorEventRouteError::StaleAuthority => "actor event owner is stale",
7261        ActorEventRouteError::SequenceExhausted => "actor event sequence is exhausted",
7262    };
7263    AgentError::LLM(message.into())
7264}
7265
7266const MAX_DISPLAY_CALLS: usize = 4_096;
7267const MAX_NESTED_DISPLAY_DEPTH: usize = 8;
7268const MAX_DISPLAY_ID_BYTES: usize = 256;
7269const MAX_DISPLAY_HOOK_RESULT_DEPTH: usize = 16;
7270
7271fn hook_result_for_display(result: HookResult, depth: usize) -> Option<HookResult> {
7272    if depth >= MAX_DISPLAY_HOOK_RESULT_DEPTH {
7273        return None;
7274    }
7275    Some(match result {
7276        HookResult::Continue => HookResult::Continue,
7277        HookResult::Mutated => HookResult::Mutated,
7278        HookResult::Allow => HookResult::Allow,
7279        HookResult::Ask => HookResult::Ask,
7280        HookResult::Deny { .. } => HookResult::Deny {
7281            reason: "Hook denied".into(),
7282        },
7283        HookResult::InjectContext { .. } => HookResult::InjectContext {
7284            text: "Hook context hidden".into(),
7285        },
7286        HookResult::WithContext { result, .. } => HookResult::WithContext {
7287            result: Box::new(hook_result_for_display(*result, depth + 1)?),
7288            text: "Hook context hidden".into(),
7289        },
7290        HookResult::Suspend { .. } => HookResult::Suspend {
7291            reason: "Hook suspended".into(),
7292        },
7293        HookResult::Abort { .. } => HookResult::Abort {
7294            reason: "Hook aborted".into(),
7295        },
7296    })
7297}
7298
7299#[derive(Clone, Copy, PartialEq, Eq)]
7300enum DisplayCallKind {
7301    Ordinary,
7302    PrivateBrowser,
7303    Reused,
7304}
7305
7306#[derive(Clone, Copy)]
7307struct DisplayCall {
7308    kind: DisplayCallKind,
7309    started: bool,
7310}
7311
7312#[derive(Default)]
7313struct ActorEventDisplay {
7314    calls: HashMap<String, DisplayCall>,
7315    hook_names: HashMap<String, usize>,
7316    children: HashMap<(String, String), Box<ActorEventDisplay>>,
7317    // Shared by every nested child: a per-level cap alone permits an
7318    // exponential number of identities across an actor event tree.
7319    identities: Arc<AtomicUsize>,
7320    overflowed: bool,
7321    nested_overflowed: bool,
7322}
7323
7324fn is_browser_tool_name(tool_name: &str) -> bool {
7325    tool_name
7326        .trim()
7327        .rsplit("::")
7328        .next()
7329        .is_some_and(|name| name.eq_ignore_ascii_case("browser"))
7330}
7331
7332fn known_browser_action(args: &serde_json::Value) -> bool {
7333    matches!(
7334        args.get("action").and_then(serde_json::Value::as_str),
7335        Some(
7336            "tabs"
7337                | "new_tab"
7338                | "activate_tab"
7339                | "close_tab"
7340                | "navigate"
7341                | "history"
7342                | "viewport"
7343                | "snapshot"
7344                | "click"
7345                | "click_at"
7346                | "hover"
7347                | "drag"
7348                | "fill"
7349                | "select_option"
7350                | "set_file_input"
7351                | "type"
7352                | "press"
7353                | "key"
7354                | "scroll"
7355                | "download"
7356                | "screenshot"
7357                | "dialog_respond"
7358        )
7359    )
7360}
7361
7362fn is_private_browser_call(tool_name: &str, args: &serde_json::Value) -> bool {
7363    is_browser_tool_name(tool_name)
7364        && (!known_browser_action(args)
7365            || args
7366                .get("action")
7367                .and_then(serde_json::Value::as_str)
7368                .is_some_and(|action| {
7369                    action.eq_ignore_ascii_case("download") || action == "set_file_input"
7370                })
7371            || args.get("data_base64").is_some())
7372}
7373
7374fn actor_arguments_for_display(tool_name: &str, args: &serde_json::Value) -> serde_json::Value {
7375    if is_browser_tool_name(tool_name) {
7376        // A malformed or unknown action has no reliable privacy class. A
7377        // mixed-version actor may emit it before its schema rejects it.
7378        if !known_browser_action(args) {
7379            return serde_json::json!({"details":"[redacted]"});
7380        }
7381    }
7382    tool_start_arguments_for_display(tool_name, args)
7383}
7384
7385impl ActorEventDisplay {
7386    fn fail_closed(&mut self) {
7387        self.identities
7388            .store(MAX_DISPLAY_CALLS + 1, Ordering::Relaxed);
7389        self.overflowed = true;
7390        self.calls.clear();
7391        self.hook_names.clear();
7392        self.nested_overflowed = true;
7393        self.children.clear();
7394    }
7395
7396    fn reserve_identity(&mut self) -> bool {
7397        if self.identities.fetch_add(1, Ordering::Relaxed) < MAX_DISPLAY_CALLS {
7398            true
7399        } else {
7400            self.fail_closed();
7401            false
7402        }
7403    }
7404
7405    fn remember_start(&mut self, id: &str, private: bool) -> DisplayCallKind {
7406        if self.overflowed || id.is_empty() || id.len() > MAX_DISPLAY_ID_BYTES {
7407            return DisplayCallKind::Reused;
7408        }
7409        let kind = if private {
7410            DisplayCallKind::PrivateBrowser
7411        } else {
7412            DisplayCallKind::Ordinary
7413        };
7414        if let Some(call) = self.calls.get_mut(id) {
7415            if call.started || call.kind != kind {
7416                call.kind = DisplayCallKind::Reused;
7417            }
7418            call.started = true;
7419            return call.kind;
7420        }
7421        if !self.reserve_identity() {
7422            return DisplayCallKind::Reused;
7423        }
7424        self.calls.insert(
7425            id.to_string(),
7426            DisplayCall {
7427                kind,
7428                started: true,
7429            },
7430        );
7431        kind
7432    }
7433
7434    fn remember_approval(&mut self, id: &str, private: bool) -> DisplayCallKind {
7435        if self.overflowed || id.is_empty() || id.len() > MAX_DISPLAY_ID_BYTES {
7436            return DisplayCallKind::Reused;
7437        }
7438        let kind = if private {
7439            DisplayCallKind::PrivateBrowser
7440        } else {
7441            DisplayCallKind::Ordinary
7442        };
7443        if let Some(call) = self.calls.get_mut(id) {
7444            if call.kind != kind {
7445                call.kind = DisplayCallKind::Reused;
7446            }
7447            return call.kind;
7448        }
7449        if !self.reserve_identity() {
7450            return DisplayCallKind::Reused;
7451        }
7452        self.calls.insert(
7453            id.to_string(),
7454            DisplayCall {
7455                kind,
7456                started: false,
7457            },
7458        );
7459        kind
7460    }
7461
7462    fn known_kind(&self, id: &str) -> Option<DisplayCallKind> {
7463        if self.overflowed || self.identities.load(Ordering::Relaxed) > MAX_DISPLAY_CALLS {
7464            return None;
7465        }
7466        self.calls
7467            .get(id)
7468            .filter(|call| call.started)
7469            .map(|call| call.kind)
7470    }
7471
7472    fn display_hook_name(&mut self, hook_name: &str) -> Option<String> {
7473        if self.overflowed || hook_name.is_empty() || hook_name.len() > MAX_DISPLAY_ID_BYTES {
7474            self.fail_closed();
7475            return None;
7476        }
7477        if let Some(index) = self.hook_names.get(hook_name) {
7478            return Some(format!("actor-hook-{index}"));
7479        }
7480        if !self.reserve_identity() {
7481            return None;
7482        }
7483        let index = self.hook_names.len() + 1;
7484        self.hook_names.insert(hook_name.to_string(), index);
7485        Some(format!("actor-hook-{index}"))
7486    }
7487
7488    fn project(&mut self, event: AgentEvent, depth: usize) -> Option<AgentEvent> {
7489        let call_id = match &event {
7490            AgentEvent::ToolStart { tool_call_id, .. }
7491            | AgentEvent::ToolApprovalRequested { tool_call_id, .. }
7492            | AgentEvent::ToolToken { tool_call_id, .. }
7493            | AgentEvent::ToolComplete { tool_call_id, .. }
7494            | AgentEvent::ToolError { tool_call_id, .. }
7495            | AgentEvent::ToolLifecycle { tool_call_id, .. } => Some(tool_call_id),
7496            _ => None,
7497        };
7498        if call_id.is_some_and(|id| id.is_empty() || id.len() > MAX_DISPLAY_ID_BYTES) {
7499            self.fail_closed();
7500            return None;
7501        }
7502        if self.identities.load(Ordering::Relaxed) > MAX_DISPLAY_CALLS {
7503            self.fail_closed();
7504        }
7505        Some(match event {
7506            AgentEvent::ToolStart {
7507                tool_call_id,
7508                tool_name,
7509                arguments,
7510            } => {
7511                let private = is_private_browser_call(&tool_name, &arguments);
7512                let kind = self.remember_start(&tool_call_id, private);
7513                let arguments = if kind == DisplayCallKind::Reused {
7514                    serde_json::json!({"details":"[redacted]"})
7515                } else {
7516                    actor_arguments_for_display(&tool_name, &arguments)
7517                };
7518                AgentEvent::ToolStart {
7519                    tool_call_id,
7520                    tool_name: match kind {
7521                        DisplayCallKind::Reused => "unknown".into(),
7522                        _ if is_browser_tool_name(&tool_name) => "browser".into(),
7523                        _ => tool_name,
7524                    },
7525                    arguments,
7526                }
7527            }
7528            AgentEvent::ToolApprovalRequested {
7529                tool_call_id,
7530                tool_name,
7531                parameters,
7532            } => {
7533                let private = is_private_browser_call(&tool_name, &parameters);
7534                let kind = self.remember_approval(&tool_call_id, private);
7535                let parameters = if kind == DisplayCallKind::Reused {
7536                    serde_json::json!({"details":"[redacted]"})
7537                } else {
7538                    actor_arguments_for_display(&tool_name, &parameters)
7539                };
7540                AgentEvent::ToolApprovalRequested {
7541                    tool_call_id,
7542                    tool_name: match kind {
7543                        DisplayCallKind::Reused => "unknown".into(),
7544                        _ if is_browser_tool_name(&tool_name) => "browser".into(),
7545                        _ => tool_name,
7546                    },
7547                    parameters,
7548                }
7549            }
7550            AgentEvent::ToolToken {
7551                tool_call_id,
7552                content,
7553            } => {
7554                let content = match self.known_kind(&tool_call_id) {
7555                    Some(DisplayCallKind::Ordinary) => content,
7556                    Some(DisplayCallKind::PrivateBrowser) => "Browser tool output hidden".into(),
7557                    _ => "Tool output hidden".into(),
7558                };
7559                AgentEvent::ToolToken {
7560                    tool_call_id,
7561                    content,
7562                }
7563            }
7564            AgentEvent::ToolComplete {
7565                tool_call_id,
7566                result,
7567            } => {
7568                let result = match self.known_kind(&tool_call_id) {
7569                    Some(DisplayCallKind::Ordinary) => result,
7570                    Some(DisplayCallKind::PrivateBrowser) => {
7571                        bamboo_agent_core::tools::ToolResult::text(
7572                            result.success,
7573                            "Browser tool result hidden",
7574                        )
7575                    }
7576                    _ => bamboo_agent_core::tools::ToolResult::text(
7577                        result.success,
7578                        "Tool result hidden",
7579                    ),
7580                };
7581                AgentEvent::ToolComplete {
7582                    tool_call_id,
7583                    result,
7584                }
7585            }
7586            AgentEvent::ToolError {
7587                tool_call_id,
7588                error,
7589            } => {
7590                let error = match self.known_kind(&tool_call_id) {
7591                    Some(DisplayCallKind::Ordinary) => error,
7592                    Some(DisplayCallKind::PrivateBrowser) => "Browser tool failed".into(),
7593                    _ => "Tool failed".into(),
7594                };
7595                AgentEvent::ToolError {
7596                    tool_call_id,
7597                    error,
7598                }
7599            }
7600            AgentEvent::ToolLifecycle {
7601                tool_call_id,
7602                tool_name,
7603                phase,
7604                elapsed_ms,
7605                is_mutating,
7606                auto_approved,
7607                summary,
7608                error,
7609            } => {
7610                let kind = self.known_kind(&tool_call_id);
7611                let (summary, error) = match kind {
7612                    Some(DisplayCallKind::Ordinary) => (summary, error),
7613                    Some(DisplayCallKind::PrivateBrowser) => (
7614                        summary.map(|_| "Browser tool activity hidden".into()),
7615                        error.map(|_| "Browser tool failed".into()),
7616                    ),
7617                    _ => (
7618                        summary.map(|_| "Tool activity hidden".into()),
7619                        error.map(|_| "Tool failed".into()),
7620                    ),
7621                };
7622                AgentEvent::ToolLifecycle {
7623                    tool_call_id,
7624                    tool_name: match kind {
7625                        Some(DisplayCallKind::Ordinary) => tool_name,
7626                        Some(DisplayCallKind::PrivateBrowser) => "browser".into(),
7627                        _ => "unknown".into(),
7628                    },
7629                    phase: if kind == Some(DisplayCallKind::Ordinary)
7630                        || matches!(phase.as_str(), "begin" | "finished" | "error" | "cancelled")
7631                    {
7632                        phase
7633                    } else {
7634                        "event".into()
7635                    },
7636                    elapsed_ms,
7637                    is_mutating,
7638                    auto_approved,
7639                    summary,
7640                    error,
7641                }
7642            }
7643            AgentEvent::HookLifecycle {
7644                hook_name,
7645                point,
7646                phase,
7647                duration_ms,
7648                decision,
7649            } => {
7650                let Some(decision) = hook_result_for_display(decision, 0) else {
7651                    self.fail_closed();
7652                    return None;
7653                };
7654                let hook_name = self.display_hook_name(&hook_name)?;
7655                let phase = match phase.as_str() {
7656                    "completed" | "started" | "error" => phase,
7657                    _ => "event".into(),
7658                };
7659                AgentEvent::HookLifecycle {
7660                    hook_name,
7661                    point,
7662                    phase,
7663                    duration_ms,
7664                    decision,
7665                }
7666            }
7667            AgentEvent::ChildApprovalRequested {
7668                child_session_id,
7669                request_id,
7670                tool_name,
7671                permission,
7672                resource,
7673            } => {
7674                let (tool_name, permission, resource) = if is_browser_tool_name(&tool_name) {
7675                    (
7676                        "browser".into(),
7677                        "Browser interaction approval".into(),
7678                        "[redacted]".into(),
7679                    )
7680                } else {
7681                    (tool_name, permission, resource)
7682                };
7683                AgentEvent::ChildApprovalRequested {
7684                    child_session_id,
7685                    request_id,
7686                    tool_name,
7687                    permission,
7688                    resource,
7689                }
7690            }
7691            AgentEvent::ChildApprovalChanged { tool_name, .. }
7692                if is_browser_tool_name(&tool_name) =>
7693            {
7694                // This durable approval delta is host-owned. An actor-supplied
7695                // copy is neither authoritative nor safe to mirror to the
7696                // account feed; the host publishes its own record separately.
7697                return None;
7698            }
7699            AgentEvent::SubAgentEvent {
7700                parent_session_id,
7701                child_session_id,
7702                event,
7703            } => {
7704                if depth >= MAX_NESTED_DISPLAY_DEPTH || self.nested_overflowed {
7705                    return None;
7706                }
7707                if parent_session_id.is_empty()
7708                    || child_session_id.is_empty()
7709                    || parent_session_id.len() > MAX_DISPLAY_ID_BYTES
7710                    || child_session_id.len() > MAX_DISPLAY_ID_BYTES
7711                {
7712                    self.fail_closed();
7713                    return None;
7714                }
7715                let key = (parent_session_id.clone(), child_session_id.clone());
7716                if !self.children.contains_key(&key) && !self.reserve_identity() {
7717                    return None;
7718                }
7719                let identities = self.identities.clone();
7720                let child = self.children.entry(key).or_insert_with(|| {
7721                    Box::new(ActorEventDisplay {
7722                        identities,
7723                        ..Default::default()
7724                    })
7725                });
7726                let event = child.project(*event, depth + 1)?;
7727                if self.identities.load(Ordering::Relaxed) > MAX_DISPLAY_CALLS {
7728                    self.fail_closed();
7729                    return None;
7730                }
7731                AgentEvent::SubAgentEvent {
7732                    parent_session_id,
7733                    child_session_id,
7734                    event: Box::new(event),
7735                }
7736            }
7737            other => other,
7738        })
7739    }
7740}
7741
7742#[allow(clippy::too_many_arguments)]
7743async fn process_actor_event(
7744    event: serde_json::Value,
7745    strict_permission_events: bool,
7746    permission_handshake: &mut PermissionPostureHandshake,
7747    expected_permission_posture: Option<&ExpectedPermissionPosture>,
7748    session_inbox_runtime: Option<&SessionInboxRuntimeBinding>,
7749    logical_session: &mut Session,
7750    event_tx: &mpsc::Sender<AgentEvent>,
7751    display: &mut ActorEventDisplay,
7752) -> crate::runtime::runner::Result<()> {
7753    let event = match serde_json::from_value::<AgentEvent>(event) {
7754        Ok(event) => event,
7755        Err(_) if strict_permission_events => {
7756            // Serde errors can quote an unrecognized variant or field value.
7757            // The actor event is untrusted display input, including browser
7758            // download metadata, so never echo its decode details to SSE.
7759            return Err(AgentError::LLM(
7760                "actor emitted malformed AgentEvent under a typed permission posture contract"
7761                    .to_string(),
7762            ));
7763        }
7764        Err(_) => {
7765            // A legacy actor can omit a malformed ToolStart that reuses an
7766            // earlier public call ID. Its later result must not inherit the
7767            // stale public display classification.
7768            display.fail_closed();
7769            return Ok(());
7770        }
7771    };
7772    process_typed_actor_event(
7773        event,
7774        permission_handshake,
7775        expected_permission_posture,
7776        session_inbox_runtime,
7777        logical_session,
7778        event_tx,
7779        display,
7780    )
7781    .await
7782}
7783
7784#[allow(clippy::too_many_arguments)]
7785async fn process_typed_actor_event(
7786    event: AgentEvent,
7787    permission_handshake: &mut PermissionPostureHandshake,
7788    expected_permission_posture: Option<&ExpectedPermissionPosture>,
7789    session_inbox_runtime: Option<&SessionInboxRuntimeBinding>,
7790    logical_session: &mut Session,
7791    event_tx: &mpsc::Sender<AgentEvent>,
7792    display: &mut ActorEventDisplay,
7793) -> crate::runtime::runner::Result<()> {
7794    if matches!(&event, AgentEvent::PermissionPostureActivated { .. }) {
7795        if permission_handshake.posture_was_confirmed() {
7796            return Err(AgentError::LLM(
7797                "actor emitted a duplicate permission posture activation".to_string(),
7798            ));
7799        }
7800        let seed = permission_posture_seed_from_event(logical_session, &event)
7801            .map_err(AgentError::LLM)?
7802            .ok_or_else(|| {
7803                AgentError::LLM(
7804                    "actor permission posture event did not decode as a posture".to_string(),
7805                )
7806            })?;
7807        if let Some(expected) = expected_permission_posture {
7808            if seed.policy_revision != expected.policy_revision
7809                || seed.resolution != expected.resolution
7810            {
7811                return Err(AgentError::LLM(
7812                    "permission posture event does not match the host-dispatched policy"
7813                        .to_string(),
7814                ));
7815            }
7816            if seed.executor_mapping() != expected.executor_mapping {
7817                return Err(AgentError::LLM(
7818                    "permission posture event does not match the host-dispatched executor mapping"
7819                        .to_string(),
7820                ));
7821            }
7822        }
7823        if let Some(binding) = session_inbox_runtime {
7824            let saved = binding
7825                .persistence
7826                .record_permission_posture_activation(
7827                    &logical_session.id,
7828                    expected_permission_posture
7829                        .and_then(|expected| expected.expected_audit_revision),
7830                    &seed,
7831                )
7832                .await
7833                .map_err(|error| {
7834                    AgentError::LLM(format!(
7835                        "persist child permission posture bootstrap: {error}"
7836                    ))
7837                })?
7838                .ok_or_else(|| {
7839                    AgentError::LLM(
7840                        "persist child permission posture bootstrap: session not found".to_string(),
7841                    )
7842                })?;
7843            let snapshot = bamboo_domain::PermissionAuditSnapshot::from_metadata(&saved.metadata)
7844                .ok_or_else(|| {
7845                AgentError::LLM(
7846                    "persisted child permission posture audit is incomplete".to_string(),
7847                )
7848            })?;
7849            snapshot.write_to(&mut logical_session.metadata);
7850        } else {
7851            // In-memory/custom actor embeddings still use a host-owned clock.
7852            // Durable server paths always take the atomic branch.
7853            bamboo_domain::record_permission_audit(&mut logical_session.metadata, &seed, None)
7854                .map_err(|error| {
7855                    AgentError::LLM(format!(
7856                        "record in-memory child permission posture: {error}"
7857                    ))
7858                })?;
7859        }
7860        // Confirmation is deliberately last: matching alone is not enough.
7861        // The host-owned audit write must succeed first.
7862        *permission_handshake = PermissionPostureHandshake::Confirmed;
7863    } else if permission_handshake.is_awaiting() {
7864        return Err(AgentError::LLM(
7865            "actor emitted an execution event before permission posture confirmation".to_string(),
7866        ));
7867    }
7868    if let Some(event) = display.project(event, 0) {
7869        let _ = event_tx.send(event).await;
7870    }
7871    Ok(())
7872}
7873
7874/// Pump child frames -> parent events until a terminal frame (or cancellation).
7875/// On success, yields the actor's final result text (for session write-back).
7876/// `live_rx` carries in-band frames (steering messages) from the live registry.
7877///
7878/// `escalation_bridge` (#68) is the per-run escalation host bridge CAPTURED BY
7879/// VALUE at spawn time in `execute_external_child` (NOT read live here): when a
7880/// non-bypass child re-proxies an approval request, this owned bridge routes it
7881/// UP to the parent run. Owning it for the call's lifetime is what lets a
7882/// fire-and-forget grandchild that outlives its spawning run still escalate to
7883/// the correct (then-current) parent bridge rather than a stale/overwritten one.
7884async fn drive(context: ActorDriveContext<'_>) -> crate::runtime::runner::Result<Option<String>> {
7885    let ActorDriveContext {
7886        client,
7887        parent_session_id,
7888        child_session_id,
7889        child_attempt,
7890        approval_registry,
7891        approval_decider,
7892        approval_reviewer,
7893        escalation_bridge,
7894        event_tx,
7895        cancel_token,
7896        live_rx,
7897        delivery_rx,
7898        logical_session,
7899        mut expected_permission_posture,
7900        expected_creation,
7901        session_inbox_runtime,
7902        actor_directory_store,
7903        canonical_subagent_tool,
7904        ticket_service,
7905        activation_run_id,
7906        execution_epoch,
7907        expected_source_actor_id,
7908        initial_inflight_claims,
7909        first_frame_timeout,
7910        plain_actor,
7911        remote_environment_lease,
7912        mut readonly_output,
7913        local_history_tools,
7914        local_history_read_only,
7915        mut strict_history_output,
7916        plain_input,
7917        canonical_activation,
7918        canonical_placement_ref,
7919        actor_event_observer,
7920        plain_run,
7921    } = context;
7922
7923    // A projection belongs only to this Host-validated Run. A loaded Session
7924    // or a prior attempt may carry a stale marker; only the canonical wait
7925    // checks in the terminal branch below may set it again.
7926    logical_session
7927        .metadata
7928        .remove("runtime.actor_nested_wait_handoff");
7929
7930    // First-frame watchdog: a live worker emits its first frame (run-started /
7931    // first token) within seconds; total silence past the deadline means the
7932    // worker is dead (e.g. a pooled worker that exited right after checkout), so
7933    // its Run sits queued forever. We trip ONLY before the first frame — once any
7934    // frame arrives the worker is proven live and a legitimately long run (a slow
7935    // tool between tokens) never trips it.
7936    let mut got_first_frame = false;
7937    let mut first_frame_watch = first_frame_timeout.map(|d| Box::pin(tokio::time::sleep(d)));
7938    let mut inflight_claims = initial_inflight_claims;
7939    let mut owned_inputs = plain_input
7940        .map(|activation| VecDeque::from(activation.initial_inputs.clone()))
7941        .unwrap_or_default();
7942    // A retry/recovery or completed initial batch consumes its cold allowance;
7943    // only a fresh activation can continue twice. No lease/watchdog restarts.
7944    let continuation_limit = if owned_inputs.is_empty() { 2 } else { 1 };
7945    let mut continuations = usize::from(!owned_inputs.is_empty());
7946    let mut released_inputs: Vec<(
7947        SessionInboxOwnedClaim,
7948        bamboo_subagent::proto::InitialInputRelease,
7949    )> = Vec::new();
7950    let mut current_epoch = execution_epoch;
7951    let approval_epoch = Arc::new(AtomicU64::new(execution_epoch));
7952    let approval_cancel = cancel_token.child_token();
7953    let _approval_lifetime = approval_cancel.clone().drop_guard();
7954    let mut approval_scopes: HashMap<String, (serde_json::Value, ChildApprovalScope)> =
7955        HashMap::new();
7956    let mut owned_tree_requests = 0usize;
7957    let mut canonical_subagent_requests = 0usize;
7958    let mut committed_plan_calls = HashMap::new();
7959    // Once this Run registers a canonical Child wait, a fast completion may
7960    // clear it before the Worker sends Terminal. Keep the current Run
7961    // nonterminal so its already admitted outcome belongs to the successor.
7962    let mut nested_wait_registered_this_run = false;
7963    let mut parent_question_checkpoint_id: Option<String> = None;
7964    let strict_permission_events = expected_permission_posture.is_some();
7965    let mut permission_handshake =
7966        PermissionPostureHandshake::new(expected_permission_posture.as_ref());
7967    let mut next_actor_event_seq = 1u64;
7968    let mut canonical_router: Option<ActorEventRouter> = None;
7969    let canonical_fence = plain_input
7970        .map(|activation| &activation.fence)
7971        .or(canonical_activation);
7972    let canonical_store = plain_input
7973        .map(|activation| activation.store.as_ref())
7974        .or(actor_directory_store);
7975    let remote_claim_fence = actor_directory_store.zip(canonical_activation);
7976    let workflow_usage_requested = logical_session
7977        .metadata
7978        .get(bamboo_subagent::proto::WORKFLOW_USAGE_REQUESTED_KEY)
7979        .is_some_and(|v| v == "true");
7980    if workflow_usage_requested {
7981        logical_session
7982            .metadata
7983            .remove(bamboo_subagent::proto::WORKFLOW_USAGE_OBSERVATION_KEY);
7984        logical_session
7985            .metadata
7986            .remove(bamboo_subagent::proto::WORKFLOW_TERMINAL_OBSERVATION_KEY);
7987    }
7988    let mut remote_cancel_deadline: Option<tokio::time::Instant> = None;
7989    let mut display = ActorEventDisplay::default();
7990    let mut readonly = readonly_output
7991        .as_ref()
7992        .map(|_| ReadOnlyActorCollector::default());
7993    let mut local_history = local_history_tools.map(|_| LocalToolCollector::default());
7994    loop {
7995        tokio::select! {
7996            _ = cancel_token.cancelled(), if remote_cancel_deadline.is_none() => {
7997                if remote_claim_fence.is_some() || workflow_usage_requested {
7998                    // A remote Worker may finish after its subscriber cancels.
7999                    // Keep the same correlated frame pump alive briefly so an
8000                    // exact Cancelled Outcome can terminalize the Actor fence.
8001                    // Without that proof the activation stays live until its
8002                    // lease expires and a retry must remain blocked.
8003                    let _ = client.send(ParentFrame::Cancel).await;
8004                    remote_cancel_deadline = Some(
8005                        tokio::time::Instant::now() + Duration::from_secs(10)
8006                    );
8007                    continue;
8008                }
8009                // Direct/local links retain their existing cancel behavior.
8010                break;
8011            }
8012            _ = async {
8013                if let Some(deadline) = remote_cancel_deadline {
8014                    tokio::time::sleep_until(deadline).await;
8015                } else {
8016                    std::future::pending::<()>().await;
8017                }
8018            } => {
8019                return Err(AgentError::Cancelled);
8020            }
8021            _ = async {
8022                match first_frame_watch.as_mut() {
8023                    Some(s) => s.as_mut().await,
8024                    None => std::future::pending::<()>().await,
8025                }
8026            }, if !got_first_frame => {
8027                return Err(AgentError::WorkerUnresponsive(format!(
8028                    "child {child_session_id} produced no frame within {:?}",
8029                    first_frame_timeout.unwrap_or_default()
8030                )));
8031            }
8032            Some(_generation) = delivery_rx.recv(),
8033                if session_inbox_runtime.is_some() && activation_run_id.is_some() =>
8034            {
8035                if plain_input.is_some() {
8036                    // Keep the real Inbox pending until this Run's completed
8037                    // reply can be committed BEFORE its correction User.
8038                    continue;
8039                }
8040                forward_next_canonical_claim(
8041                    client,
8042                    session_inbox_runtime.expect("guarded"),
8043                    logical_session,
8044                    activation_run_id.expect("guarded"),
8045                    &mut inflight_claims,
8046                    remote_claim_fence,
8047                )
8048                .await?;
8049            }
8050            Some(frame) = live_rx.recv() => {
8051                if plain_actor { return Err(plain_actor_unsupported()); }
8052                // Forward in-band steering to the worker over the existing WS.
8053                if client.send(frame).await.is_err() {
8054                    tracing::warn!("live steering frame could not be sent; connection failing");
8055                }
8056            }
8057            frame = client.next_frame() => {
8058                // Any frame (event / approval / terminal / close / error) proves
8059                // the worker responded — disarm the first-frame watchdog.
8060                got_first_frame = true;
8061                first_frame_watch = None;
8062                match frame {
8063                    Ok(Some(ChildFrame::Event { event })) => {
8064                        if canonical_activation.is_some() {
8065                            return Err(AgentError::LLM("remote_actor_event_batch_required".into()));
8066                        }
8067                        if local_history.is_some() {
8068                            return Err(local_tool_history_unsupported());
8069                        }
8070                        if event.get("initial_input_control").is_some() {
8071                            let bamboo_subagent::proto::InitialInputControl::Request { request } =
8072                                bamboo_subagent::proto::InitialInputControl::decode(event).map_err(|_| plain_actor_unsupported())?
8073                            else { return Err(plain_actor_unsupported()); };
8074                            if !permission_handshake.posture_was_confirmed() || cancel_token.is_cancelled() { return Err(plain_actor_unsupported()); }
8075                            let activation = plain_input.ok_or_else(|| plain_actor_unsupported())?;
8076                            let binding = session_inbox_runtime.ok_or_else(|| plain_actor_unsupported())?;
8077                            let (run, _) = plain_run.ok_or_else(|| plain_actor_unsupported())?;
8078                            // Exact retransmission may refer to an earlier
8079                            // release while the next input is still waiting.
8080                            // Every new request must match the ordered front.
8081                            let previous = released_inputs.iter()
8082                                .find(|(_, release)| release.request == request);
8083                            let (claim, previous) = match previous {
8084                                Some((claim, release)) => (claim.clone(), Some(release.clone())),
8085                                None => (owned_inputs.front().cloned()
8086                                    .ok_or_else(|| plain_actor_unsupported())?, None),
8087                            };
8088                            let release = activation.release_initial_input(binding, logical_session, run, current_epoch, &claim,
8089                                &request, &mut expected_permission_posture, previous.as_ref()).await?;
8090                            if cancel_token.is_cancelled() { return Err(AgentError::Cancelled); }
8091                            client.send(ParentFrame::InitialInputRelease { release: release.clone() }).await
8092                                .map_err(|_| AgentError::LLM("Actor initial release unconfirmed; durable input is preserved".into()))?;
8093                            if previous.is_none() {
8094                                let _ = owned_inputs.pop_front();
8095                                released_inputs.push((claim, release));
8096                            }
8097                            continue;
8098                        }
8099                        if expected_creation.is_some() {
8100                            return Err(AgentError::LLM("worker omitted required Child creation event identity".into()));
8101                        }
8102                        if event["type"] == bamboo_subagent::proto::LocalToolMessages::TYPE { continue; }
8103                        if event["type"] == "owned_readonly_transcript" {
8104                            if plain_actor { return Err(plain_actor_unsupported()); }
8105                            continue;
8106                        }
8107                        if plain_actor && !plain_actor_event(&event)? { continue; }
8108                        if let Some(activation) = plain_input {
8109                            validate_directory_actor_event(
8110                                activation.store.as_ref(),
8111                                &activation.fence,
8112                                logical_session,
8113                                None,
8114                            ).await?;
8115                        }
8116                        // Rolling-upgrade compatibility: old actors have no
8117                        // route/sequence metadata, but retain the same typed
8118                        // permission handshake and event validation.
8119                        process_actor_event(
8120                            event,
8121                            strict_permission_events,
8122                            &mut permission_handshake,
8123                            expected_permission_posture.as_ref(),
8124                            session_inbox_runtime,
8125                            logical_session,
8126                            event_tx,
8127                            &mut display,
8128                        )
8129                        .await?;
8130                    }
8131                    Ok(Some(ChildFrame::EventBatch { batch })) => {
8132                        validate_actor_event_batch(
8133                            &batch,
8134                            logical_session,
8135                            parent_session_id,
8136                            activation_run_id,
8137                            current_epoch,
8138                            expected_source_actor_id,
8139                            expected_creation,
8140                        )?;
8141                        let directory_entry = if let (Some(store), Some(fence)) = (canonical_store, canonical_fence) {
8142                            let entry = validate_directory_actor_event(
8143                                store, fence, logical_session, batch.activation_id.as_deref(),
8144                            ).await?;
8145                            if canonical_placement_ref.is_some_and(|expected| {
8146                                entry.activation.as_ref().and_then(|activation| activation.placement_ref.as_ref()) != Some(expected)
8147                            }) {
8148                                return Err(AgentError::LLM("remote_actor_placement_ref_changed".into()));
8149                            }
8150                            Some(entry)
8151                        } else { None };
8152                        if let (Some(entry), Some(fence)) = (&directory_entry, canonical_fence) {
8153                            if canonical_router.is_none() {
8154                                canonical_router = Some(
8155                                    ActorEventRouter::new(
8156                                        entry,
8157                                        fence,
8158                                        current_epoch,
8159                                        strict_permission_events && permission_handshake.is_awaiting(),
8160                                    )
8161                                    .map_err(actor_event_route_error)?,
8162                                );
8163                            }
8164                        }
8165                        if local_history_tools.is_some()
8166                            && (batch.first_seq != next_actor_event_seq
8167                                || batch.qos != bamboo_subagent::ActorEventQos::Durable
8168                                || batch.last_seq == u64::MAX) {
8169                            return Err(local_tool_history_unsupported());
8170                        }
8171                        if batch.last_seq < next_actor_event_seq {
8172                            continue;
8173                        }
8174                        if batch.first_seq > next_actor_event_seq {
8175                            if plain_actor {
8176                                return Err(AgentError::LLM(
8177                                    "actor event sequence gap; authoritative session snapshot is required".into(),
8178                                ));
8179                            }
8180                            tracing::warn!(
8181                                child_session_id,
8182                                expected_seq = next_actor_event_seq,
8183                                received_seq = batch.first_seq,
8184                                "actor event sequence gap; authoritative session snapshot is required"
8185                            );
8186                            // A missing ToolStart may have reused an earlier
8187                            // call ID for a private browser download. No prior
8188                            // identity remains safe for display after a gap.
8189                            display.fail_closed();
8190                        }
8191                        let skip = next_actor_event_seq
8192                            .saturating_sub(batch.first_seq)
8193                            .min(batch.events.len() as u64) as usize;
8194                        for (offset, event) in batch.events.into_iter().enumerate().skip(skip) {
8195                            let seq = batch.first_seq + offset as u64;
8196                            let mut publish = true;
8197                            if event["type"] == bamboo_subagent::proto::WorkflowAgentUsage::TYPE {
8198                                if workflow_usage_requested {
8199                                    if permission_handshake.is_awaiting() { return Err(plain_actor_unsupported()); }
8200                                    let usage: bamboo_subagent::proto::WorkflowAgentUsage = serde_json::from_value(event.clone()).map_err(|_| plain_actor_unsupported())?;
8201                                    let activation = activation_run_id.ok_or_else(plain_actor_unsupported)?;
8202                                    if !usage.matches(logical_session, activation) { return Err(plain_actor_unsupported()); }
8203                                    if logical_session.metadata.contains_key(bamboo_subagent::proto::WORKFLOW_USAGE_OBSERVATION_KEY) { return Err(plain_actor_unsupported()); }
8204                                    logical_session.metadata.insert(bamboo_subagent::proto::WORKFLOW_USAGE_OBSERVATION_KEY.into(), serde_json::to_string(&usage).map_err(|_| plain_actor_unsupported())?);
8205                                }
8206                                publish = false;
8207                            }
8208                            if publish { if let Some(collector) = local_history.as_mut() {
8209                                if permission_handshake.is_awaiting() && event["type"] != "permission_posture_activated" {
8210                                    return Err(local_tool_history_unsupported());
8211                                }
8212                                if logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY) {
8213                                    publish = collector.ticket_event(&event, &committed_plan_calls)?;
8214                                }
8215                                publish &= collector.event(&event)?;
8216                            } else if event["type"] == bamboo_subagent::proto::LocalToolMessages::TYPE {
8217                                // Completion DATA is private and grants nothing on an unselected route.
8218                                publish = false;
8219                            }}
8220                            if publish { publish = if let Some(collector) = readonly.as_mut() {
8221                                if permission_handshake.is_awaiting()
8222                                    && event["type"] != "permission_posture_activated" {
8223                                    return Err(plain_actor_unsupported());
8224                                }
8225                                collector.event(&event)?
8226                            } else if event["type"] == "owned_readonly_transcript" {
8227                                if plain_actor { return Err(plain_actor_unsupported()); }
8228                                false // A worker cache observation is not a legacy Host commit.
8229                            } else if plain_actor { plain_actor_event(&event)? } else { true };
8230                            }
8231                            if let (Some(router), Some(entry), Some(fence)) =
8232                                (canonical_router.as_mut(), directory_entry.as_ref(), canonical_fence)
8233                            {
8234                                let typed = if publish {
8235                                    Some(serde_json::from_value::<AgentEvent>(event.clone())
8236                                        .map_err(|_| plain_actor_unsupported())?)
8237                                } else {
8238                                    None
8239                                };
8240                                match router.route(entry, fence, current_epoch, seq, typed)
8241                                    .map_err(actor_event_route_error)?
8242                                {
8243                                    ActorEventRoute::Publish(envelope) => {
8244                                        let public_event = actor_event_observer
8245                                            .filter(|_| envelope.class != ActorEventClass::Ephemeral)
8246                                            .map(|_| PublicActorEvent::from(envelope.as_ref()));
8247                                        tracing::trace!(
8248                                            actor_id = %envelope.actor_id,
8249                                            event_id = %envelope.event_id,
8250                                            class = ?envelope.class,
8251                                            "publishing canonical Actor event"
8252                                        );
8253                                        process_typed_actor_event(
8254                                            envelope.payload,
8255                                            &mut permission_handshake,
8256                                            expected_permission_posture.as_ref(),
8257                                            session_inbox_runtime,
8258                                            logical_session,
8259                                            event_tx,
8260                                            &mut display,
8261                                        ).await?;
8262                                        if let (Some(observer), Some(public_event)) =
8263                                            (actor_event_observer, public_event)
8264                                        {
8265                                            observer.publish(public_event);
8266                                        }
8267                                    }
8268                                    ActorEventRoute::Duplicate | ActorEventRoute::Suppressed => {}
8269                                }
8270                                next_actor_event_seq = seq.saturating_add(1);
8271                                continue;
8272                            }
8273                            if !publish {
8274                                next_actor_event_seq = seq.saturating_add(1);
8275                                continue;
8276                            }
8277                            process_actor_event(
8278                                event,
8279                                strict_permission_events,
8280                                &mut permission_handshake,
8281                                expected_permission_posture.as_ref(),
8282                                session_inbox_runtime,
8283                                logical_session,
8284                                event_tx,
8285                                &mut display,
8286                            )
8287                            .await?;
8288                            next_actor_event_seq = seq.saturating_add(1);
8289                        }
8290                    }
8291                    Ok(Some(ChildFrame::OwnedTreeRequest { id, cursor })) => {
8292                        owned_tree_requests += 1;
8293                        if owned_tree_requests > 16 {
8294                            return Err(AgentError::LLM("owned tree request limit exceeded".into()));
8295                        }
8296                        let page = if id.len() <= 128
8297                            && !logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
8298                            && cursor.as_ref().is_none_or(|value| !value.is_empty() && value.len() <= 128)
8299                            && !cancel_token.is_cancelled()
8300                        {
8301                            match (actor_directory_store, session_inbox_runtime,
8302                                activation_run_id, expected_creation) {
8303                                (Some(store), Some(binding), Some(run_id), Some(creation)) => {
8304                                    tokio::time::timeout(
8305                                        Duration::from_secs(8),
8306                                        inspect_active_owned_tree(
8307                                            store, binding, run_id, child_session_id,
8308                                            parent_session_id, logical_session, creation,
8309                                            plain_input, cursor.as_deref(),
8310                                        ),
8311                                    ).await.ok().flatten()
8312                                }
8313                                _ => {
8314                                    tracing::warn!("owned tree denied: canonical Host scope unavailable");
8315                                    None
8316                                },
8317                            }
8318                        } else {
8319                            tracing::warn!("owned tree denied: request bounds or cancellation");
8320                            None
8321                        };
8322                        if cancel_token.is_cancelled() {
8323                            return Err(AgentError::Cancelled);
8324                        }
8325                        client.send(ParentFrame::OwnedTreeReply { id, page }).await
8326                            .map_err(|_| AgentError::LLM("owned tree reply transport closed".into()))?;
8327                    }
8328                    Ok(Some(ChildFrame::SubAgentRequest { id, tool_call_id, args })) => {
8329                        canonical_subagent_requests += 1;
8330                        if canonical_subagent_requests > 64 {
8331                            return Err(AgentError::LLM("canonical SubAgent request limit exceeded".into()));
8332                        }
8333                        let valid_shape = !id.is_empty() && id.len() <= 128
8334                            && !tool_call_id.is_empty() && tool_call_id.len() <= 128
8335                            && args.is_object()
8336                            && serde_json::to_vec(&args)
8337                                .is_ok_and(|bytes| bytes.len() <= 64 * 1024);
8338                        let registers_wait = matches!(args.get("action").and_then(|value| value.as_str()),
8339                            Some("wait"))
8340                            || (args.get("action").and_then(|value| value.as_str()) == Some("create")
8341                                && args.get("wait").and_then(|value| value.as_bool()) == Some(true));
8342                        let checkpoint_payload = args.get(PARENT_QUESTION_CHECKPOINT_ACTION).cloned();
8343                        let ticket_bootstrap_pending = valid_shape
8344                            && logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
8345                            && args == serde_json::json!({(crate::ticket_worker_plan::TICKET_PLAN_ACTION): {"read":true}})
8346                            && permission_handshake.is_awaiting()
8347                            && !cancel_token.is_cancelled();
8348                        let result = if ticket_bootstrap_pending {
8349                            // A read control can overtake the queued posture
8350                            // event on the separate lanes. Keep the handshake
8351                            // fence; the exact bootstrap read may retry briefly.
8352                            serde_json::json!({"error":crate::ticket_worker_plan::remote::TICKET_BOOTSTRAP_POSTURE_PENDING})
8353                        } else if valid_shape
8354                            && (!logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
8355                                || args.get(crate::ticket_worker_plan::TICKET_PLAN_ACTION).is_some()
8356                                || args.get(crate::ticket_worker_plan::files::TICKET_FILE_ACTION).is_some())
8357                            && !cancel_token.is_cancelled()
8358                            && !permission_handshake.is_awaiting()
8359                        {
8360                            if args.get(crate::ticket_worker_plan::TICKET_PLAN_ACTION).is_some() {
8361                                match (ticket_service.as_deref(), actor_directory_store,
8362                                    session_inbox_runtime, activation_run_id, expected_creation) {
8363                                    (Some(service), Some(store), Some(binding), Some(run_id), Some(creation)) => {
8364                                        if let Some(caller) = load_active_subagent_caller(
8365                                            store, binding, run_id, child_session_id,
8366                                            parent_session_id, logical_session, creation, plain_input,
8367                                        ).await {
8368                                            match crate::ticket_worker_plan::remote::apply_host_plan_request(service, &caller, run_id, &args, &tool_call_id) {
8369                                                Ok(projection) => {
8370                                                    logical_session.task_list = projection.task_list.clone();
8371                                                    logical_session.set_task_list_version_meta(projection.plan_revision.to_string());
8372                                                    if let Some(question) = &projection.question {
8373                                                        let raw = serde_json::to_string(question).map_err(|_| local_tool_history_unsupported())?;
8374                                                        logical_session.metadata.insert(crate::ticket_worker_plan::TICKET_QUESTION_YIELD_KEY.into(), raw);
8375                                                        if let Some(collector) = local_history.as_mut() {
8376                                                            collector.ticket_yield = Some((tool_call_id.clone(), question.clone()));
8377                                                        }
8378                                                    }
8379                                                    if let Some(task) = args[crate::ticket_worker_plan::TICKET_PLAN_ACTION].get("task") {
8380                                                        committed_plan_calls.insert(tool_call_id.clone(), task.clone());
8381                                                        if let Some(task_list) = projection.task_list.clone() {
8382                                                            let _ = event_tx.send(AgentEvent::TaskListUpdated {
8383                                                                task_list,
8384                                                                version: Some(projection.plan_revision),
8385                                                            }).await;
8386                                                        }
8387                                                    }
8388                                                    serde_json::json!({"result":projection})
8389                                                }
8390                                                Err(error) => serde_json::json!({"error":error.to_string()}),
8391                                            }
8392                                        } else { serde_json::json!({"error":"LocalPlan Child Run is no longer current"}) }
8393                                    }
8394                                    _ => serde_json::json!({"error":"LocalPlan Host authority unavailable"}),
8395                                }
8396                            } else if args.get(crate::ticket_worker_plan::files::TICKET_FILE_ACTION).is_some() {
8397                                match (ticket_service.as_deref(), actor_directory_store, session_inbox_runtime,
8398                                    activation_run_id, expected_creation, local_history_tools) {
8399                                    (Some(service),Some(store),Some(binding),Some(run_id),Some(creation),Some(ceiling)) => {
8400                                        if let Some(caller)=load_active_subagent_caller(store,binding,run_id,child_session_id,parent_session_id,logical_session,creation,plain_input).await {
8401                                            match crate::ticket_worker_plan::files::apply_host_file_request(service,&caller,run_id,&args,&tool_call_id,ceiling) {
8402                                                Ok(reply)=>{
8403                                                    let body=&args[crate::ticket_worker_plan::files::TICKET_FILE_ACTION];
8404                                                    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())?}));
8405                                                    serde_json::json!({"result":reply})
8406                                                },
8407                                                Err(error)=>serde_json::json!({"error":error.to_string()}),
8408                                            }
8409                                        } else { serde_json::json!({"error":"Ticket file Child Run is no longer current"}) }
8410                                    },
8411                                    _=>serde_json::json!({"error":"Ticket file Host authority unavailable"}),
8412                                }
8413                            } else if let Some(payload) = checkpoint_payload {
8414                                let parsed = (args.as_object().is_some_and(|object| object.len() == 1))
8415                                    .then(|| serde_json::from_value::<ParentQuestionCheckpointV1>(payload).ok())
8416                                    .flatten()
8417                                    .filter(|observation| observation.tool_call_id == tool_call_id);
8418                                match (parsed, actor_directory_store, session_inbox_runtime,
8419                                    activation_run_id, expected_creation) {
8420                                    (Some(observation), Some(store), Some(binding), Some(run_id), Some(creation)) => {
8421                                        let receipt = tokio::time::timeout(Duration::from_secs(25),
8422                                            checkpoint_active_parent_question(
8423                                                store, binding, run_id, child_session_id,
8424                                                parent_session_id, logical_session, creation,
8425                                                plain_input, observation,
8426                                            )).await.ok().flatten();
8427                                        if let Some(request_id) = receipt {
8428                                            parent_question_checkpoint_id = Some(request_id.clone());
8429                                            serde_json::json!({"result":{"request_id":request_id}})
8430                                        } else {
8431                                            serde_json::json!({"error":"canonical Child question checkpoint rejected; persisted worker question is preserved"})
8432                                        }
8433                                    }
8434                                    _ => serde_json::json!({"error":"canonical Child question checkpoint unavailable"}),
8435                                }
8436                            } else {
8437                            match (canonical_subagent_tool.as_deref(), actor_directory_store,
8438                                session_inbox_runtime, activation_run_id, expected_creation) {
8439                                (Some(tool), Some(store), Some(binding), Some(run_id), Some(creation)) => {
8440                                    if let Some(caller) = load_active_subagent_caller(
8441                                        store, binding, run_id, child_session_id,
8442                                        parent_session_id, logical_session, creation,
8443                                        plain_input,
8444                                    ).await {
8445                                        tokio::time::timeout(
8446                                            Duration::from_secs(30),
8447                                            invoke_active_subagent(tool, &caller, args, &tool_call_id),
8448                                        ).await.unwrap_or_else(|_| serde_json::json!({
8449                                            "error":"canonical SubAgent operation timed out; inspect logical child state before retrying"
8450                                        }))
8451                                    } else {
8452                                        serde_json::json!({"error":"canonical SubAgent caller is no longer active"})
8453                                    }
8454                                }
8455                                _ => serde_json::json!({"error":"canonical SubAgent Host authority unavailable"}),
8456                            }
8457                            }
8458                        } else {
8459                            serde_json::json!({"error":"canonical SubAgent request rejected"})
8460                        };
8461                        if registers_wait
8462                            && result["result"]["success"] == true
8463                            && result["result"]["result"]
8464                                .as_str()
8465                                .and_then(|value| serde_json::from_str::<serde_json::Value>(value).ok())
8466                                .is_some_and(|value| value["status"] == "waiting")
8467                        {
8468                            nested_wait_registered_this_run = true;
8469                        }
8470                        if cancel_token.is_cancelled() {
8471                            return Err(AgentError::Cancelled);
8472                        }
8473                        client.send(ParentFrame::SubAgentReply { id, result }).await
8474                            .map_err(|_| AgentError::LLM("canonical SubAgent reply transport closed".into()))?;
8475                    }
8476                    Ok(Some(ChildFrame::ApprovalRequest { id, body })) => {
8477                        if logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY) {
8478                            return Err(AgentError::LLM("Ticket Task-only Worker cannot request unrelated action authority".into()));
8479                        }
8480                        if plain_actor { return Err(plain_actor_unsupported()); }
8481                        if permission_handshake.is_awaiting() {
8482                            return Err(AgentError::LLM(
8483                                "actor requested approval before permission posture confirmation"
8484                                    .to_string(),
8485                            ));
8486                        }
8487                        // Phase 2: a worker proxied a gated-tool approval back to
8488                        // the host. The WORKER side is live — its executor installs
8489                        // a per-run task-local `ApprovalProxy` (subagent_worker.rs)
8490                        // that calls `host.approval_call`, so this frame arrives
8491                        // when a child hits `ConfirmationRequired`.
8492                        if let Some(reviewer) = approval_reviewer
8493                            .cloned()
8494                            .or_else(child_approval_reviewer)
8495                        {
8496                            // Phase 6, Part B: a BYPASSED parent worker
8497                            // model-reviews its children's forced-ask (dangerous)
8498                            // actions. The review is an LLM call, so run it OFF
8499                            // the frame pump in a spawned task and deliver the
8500                            // verdict async via the live channel — the pump keeps
8501                            // forwarding events and the agent loop never blocks. A
8502                            // timeout denies a hung review so the child can't hang.
8503                            let child = child_session_id.to_string();
8504                            let parent = parent_session_id.to_string();
8505                            let req_id = id.clone();
8506                            let body = body.clone();
8507                            let registry = approval_registry.cloned();
8508                            let Some((binding, run)) = session_inbox_runtime.zip(activation_run_id) else {
8509                                // Legacy/custom reviewers retain their original boolean seam;
8510                                // the canonical server reviewer denies without a live scope.
8511                                tokio::spawn(async move {
8512                                    let approved = tokio::time::timeout(CHILD_APPROVAL_TIMEOUT,
8513                                        reviewer.review(&parent, &child, &body)).await.unwrap_or(false);
8514                                    super::live::deliver_approval_scoped(registry.as_ref(), &child,
8515                                        child_attempt, &req_id, approved);
8516                                });
8517                                continue;
8518                            };
8519                            let generation = body.pointer("/permission_request/request_generation")
8520                                .and_then(serde_json::Value::as_str).unwrap_or("").to_string();
8521                            let scope = if let Some((original, scope)) = approval_scopes.get(&generation) {
8522                                if original != &body || scope.stamp()["reply"] != req_id {
8523                                    return Err(AgentError::LLM("approval generation changed in live scope".into()));
8524                                }
8525                                scope.clone()
8526                            } else {
8527                                if approval_scopes.len() >= 64 {
8528                                    return Err(AgentError::LLM("approval live scope limit exceeded".into()));
8529                                }
8530                                let scope = ChildApprovalScope::new(&parent, &child,
8531                                    (child_attempt, run, current_epoch, &req_id, chrono::Utc::now() + chrono::Duration::seconds(240)), binding.router.clone(),
8532                                    approval_cancel.clone(), approval_epoch.clone());
8533                                approval_scopes.insert(generation, (body.clone(), scope.clone()));
8534                                scope
8535                            };
8536                            tokio::spawn(async move {
8537                                let result = tokio::time::timeout(
8538                                    CHILD_APPROVAL_TIMEOUT,
8539                                    reviewer.review_scoped(&parent, &child, &body, &scope),
8540                                ).await.unwrap_or(ChildApprovalReview::Reply(false));
8541                                if let ChildApprovalReview::Reply(approved) = result {
8542                                    if scope.is_current(&parent, &child).await {
8543                                        super::live::deliver_approval_scoped(registry.as_ref(), &child,
8544                                            child_attempt, &req_id, approved && chrono::Utc::now() < scope.deadline());
8545                                    }
8546                                }
8547                            });
8548                        } else if approval_decider.is_some() {
8549                            // A decider is wired (policy / auto): decide promptly
8550                            // and reply inline. (Must not block the pump — see the
8551                            // `ChildApprovalDecider` doc.)
8552                            let approved =
8553                                decide_child_approval(approval_decider, child_session_id, &body)
8554                                    .await;
8555                            if client
8556                                .send(ParentFrame::ApprovalReply { id, approved })
8557                                .await
8558                                .is_err()
8559                            {
8560                                tracing::warn!(
8561                                    "failed to answer approval_request; connection failing"
8562                                );
8563                            }
8564                        } else if let Some(host) = escalation_bridge.clone() {
8565                            // Non-bypass WORKER: ESCALATE up our own actor link
8566                            // (re-proxy) so the request chains to our parent — and
8567                            // up every level until a bypass level or the top
8568                            // orchestrator's model reviewer decides. With no such
8569                            // reviewer the top level fails closed. Off-loop so the
8570                            // pump never blocks; relay the reply down to the child.
8571                            let child = child_session_id.to_string();
8572                            let req_id = id.clone();
8573                            let body = body.clone();
8574                            let registry = approval_registry.cloned();
8575                            tokio::spawn(async move {
8576                                let approved = match tokio::time::timeout(
8577                                    CHILD_APPROVAL_TIMEOUT,
8578                                    host.approval_call(body),
8579                                )
8580                                .await
8581                                {
8582                                    Ok(Ok(reply)) => reply
8583                                        .get("approved")
8584                                        .and_then(|v| v.as_bool())
8585                                        .unwrap_or(false),
8586                                    // Transport error or timeout ⇒ fail closed.
8587                                    _ => false,
8588                                };
8589                                super::live::deliver_approval_scoped(
8590                                    registry.as_ref(),
8591                                    &child,
8592                                    child_attempt,
8593                                    &req_id,
8594                                    approved,
8595                                );
8596                            });
8597                        } else {
8598                            // There is no parent-agent reviewer or upstream actor
8599                            // to own this decision. Never open a manual/UI approval
8600                            // path: forced-ask is parent-reviewed or fail-closed.
8601                            tracing::warn!(
8602                                parent_session_id,
8603                                child_session_id,
8604                                request_id = %id,
8605                                "forced-ask request has no parent-agent reviewer; denying"
8606                            );
8607                            if client
8608                                .send(ParentFrame::ApprovalReply {
8609                                    id,
8610                                    approved: false,
8611                                })
8612                                .await
8613                                .is_err()
8614                            {
8615                                tracing::warn!(
8616                                    "failed to send fail-closed approval reply; connection failing"
8617                                );
8618                            }
8619                        }
8620                    }
8621                    Ok(Some(ChildFrame::SessionMessageAdmitted { confirmation })) => {
8622                        if let Some(activation) = plain_input {
8623                            if activation.initial_release_required { return Err(plain_actor_unsupported()); }
8624                            let binding = session_inbox_runtime.ok_or_else(|| plain_actor_unsupported())?;
8625                            let run_id = activation_run_id.ok_or_else(|| plain_actor_unsupported())?;
8626                            let claim = owned_inputs.front().ok_or_else(|| plain_actor_unsupported())?;
8627                            activation.confirm_input(binding, logical_session, run_id, claim, &confirmation).await?;
8628                            let _ = owned_inputs.pop_front();
8629                            continue;
8630                        }
8631                        let Some(binding) = session_inbox_runtime else {
8632                            tracing::warn!(
8633                                child_session_id,
8634                                "ignoring SessionInbox confirmation without a runtime binding"
8635                            );
8636                            continue;
8637                        };
8638                        let Some(bound_run_id) = activation_run_id else {
8639                            tracing::warn!(
8640                                child_session_id,
8641                                "ignoring SessionInbox confirmation without an activation owner"
8642                            );
8643                            continue;
8644                        };
8645                        let Some(claim) = inflight_claims.front() else {
8646                            tracing::warn!(
8647                                child_session_id,
8648                                envelope_id = %confirmation.envelope_id,
8649                                "rejecting stale SessionInbox confirmation with no in-flight canonical claim"
8650                            );
8651                            continue;
8652                        };
8653                        let exact = confirmation.target_session_id == logical_session.id
8654                            && confirmation.envelope_id == claim.envelope.id.as_str()
8655                            && confirmation.canonical_claim_generation == claim.generation
8656                            && confirmation.activation_run_id == bound_run_id;
8657                        if !exact
8658                            || !binding
8659                                .router
8660                                .owns_run(&logical_session.id, bound_run_id)
8661                                .await
8662                        {
8663                            tracing::warn!(
8664                                child_session_id,
8665                                expected_target = %logical_session.id,
8666                                received_target = %confirmation.target_session_id,
8667                                expected_envelope_id = %claim.envelope.id,
8668                                received_envelope_id = %confirmation.envelope_id,
8669                                expected_generation = claim.generation,
8670                                received_generation = confirmation.canonical_claim_generation,
8671                                expected_run_id = bound_run_id,
8672                                received_run_id = %confirmation.activation_run_id,
8673                                "rejecting stale or mismatched SessionInbox admission confirmation"
8674                            );
8675                            continue;
8676                        }
8677                        let claim = inflight_claims
8678                            .pop_front()
8679                            .expect("validated in-flight canonical claim");
8680                        // On failure the durable canonical cur file remains
8681                        // recoverable for the next owner.
8682                        checkpoint_and_ack_canonical_claim(
8683                            binding,
8684                            logical_session,
8685                            &claim,
8686                            remote_claim_fence,
8687                        )
8688                            .await?;
8689                        // Ordered single-consumer: only after the exact prior
8690                        // claim is checkpointed+acked may the driver claim and
8691                        // forward the next envelope.
8692                        if inflight_claims.is_empty() {
8693                            forward_next_canonical_claim(
8694                                client,
8695                                binding,
8696                                logical_session,
8697                                bound_run_id,
8698                                &mut inflight_claims,
8699                                remote_claim_fence,
8700                            )
8701                            .await?;
8702                        }
8703                    }
8704                    Ok(Some(ChildFrame::Terminal { status, result, error, final_event_watermark, .. })) => {
8705                        if local_history_tools.is_some() {
8706                            // Check before history commit or terminal receipt acceptance.
8707                            // Normal lossy observations do not promise a complete trace.
8708                            validate_strict_history_terminal(
8709                                final_event_watermark.as_ref(), logical_session, parent_session_id,
8710                                activation_run_id, current_epoch, expected_creation,
8711                                next_actor_event_seq - 1,
8712                            )?;
8713                        }
8714                        if remote_cancel_deadline.is_some() && status == TerminalStatus::Cancelled {
8715                            if let Some(tools) = local_history_tools {
8716                                // The watermark proves transport completeness, not
8717                                // persistence. Certify only an entirely validated
8718                                // local Read/Glob prefix with no pending handoff.
8719                                if permission_handshake.is_awaiting()
8720                                    || !owned_inputs.is_empty()
8721                                    || !inflight_claims.is_empty()
8722                                    || parent_question_checkpoint_id.is_some()
8723                                    || nested_wait_registered_this_run
8724                                    || strict_history_output.is_none()
8725                                {
8726                                    return Err(local_tool_history_unsupported());
8727                                }
8728                                commit_local_tool_history(
8729                                    local_history.take().ok_or_else(local_tool_history_unsupported)?,
8730                                    logical_session,
8731                                    session_inbox_runtime.ok_or_else(local_tool_history_unsupported)?,
8732                                    activation_run_id.ok_or_else(local_tool_history_unsupported)?,
8733                                    (tools, local_history_read_only),
8734                                    LocalToolTerminal::Cancelled,
8735                                    cancel_token,
8736                                ).await?;
8737                                let proof = bamboo_storage::v2::HostTerminalCompleteness::from_verified_host(
8738                                    logical_session,
8739                                    activation_run_id.ok_or_else(local_tool_history_unsupported)?,
8740                                    current_epoch,
8741                                    next_actor_event_seq - 1,
8742                                ).map_err(|_| local_tool_history_unsupported())?;
8743                                *strict_history_output.as_deref_mut()
8744                                    .ok_or_else(local_tool_history_unsupported)? = Some(StrictTerminalHistory {
8745                                        proof,
8746                                        watermark: final_event_watermark.ok_or_else(local_tool_history_unsupported)?,
8747                                    });
8748                            }
8749                            // A selected Worker terminal proves that its Run
8750                            // ended even if permission posture or an Inbox
8751                            // correction was still pending. Claims remain
8752                            // unacked for retry; only the placement fence can
8753                            // now be released after SDK saves Cancelled.
8754                            client.accept_durable_terminal(status);
8755                            if workflow_usage_requested {
8756                                logical_session.metadata.insert(bamboo_subagent::proto::WORKFLOW_TERMINAL_OBSERVATION_KEY.into(), activation_run_id.ok_or_else(plain_actor_unsupported)?.into());
8757                            }
8758                            return Err(AgentError::Cancelled);
8759                        }
8760                        if parent_question_checkpoint_id.is_some()
8761                            && status != TerminalStatus::Suspended
8762                        {
8763                            return Err(AgentError::LLM(
8764                                "Child question checkpoint must end in a suspended Run".into(),
8765                            ));
8766                        }
8767                        if permission_handshake.is_awaiting() {
8768                            if remote_environment_lease && status == TerminalStatus::Error {
8769                                if let Some(code) = error.as_deref().and_then(remote_environment_error_code) {
8770                                    // The Worker rejected the EnvironmentLease before it
8771                                    // could confirm its permission posture. This exact
8772                                    // correlated Outcome still proves the Run ended, so
8773                                    // retain its durable receipt and release the slot after
8774                                    // the Host records the failure. Other early terminals
8775                                    // remain unproven and keep their placement fence.
8776                                    client.accept_durable_terminal(status);
8777                            if workflow_usage_requested {
8778                                logical_session.metadata.insert(bamboo_subagent::proto::WORKFLOW_TERMINAL_OBSERVATION_KEY.into(), activation_run_id.ok_or_else(plain_actor_unsupported)?.into());
8779                            }
8780                                    return Err(AgentError::LLM(code.to_owned()));
8781                                }
8782                            }
8783                            return Err(AgentError::LLM(
8784                                "actor terminated before permission posture confirmation"
8785                                    .to_string(),
8786                            ));
8787                        }
8788                        if !owned_inputs.is_empty() {
8789                            return Err(AgentError::LLM("Actor terminated before confirming its durable correction; Inbox claim is retained".into()));
8790                        }
8791                        if let Some(claim) = inflight_claims.front() {
8792                            return Err(AgentError::LLM(format!(
8793                                "actor terminated before durably admitting SessionInbox message {}; canonical claim remains recoverable",
8794                                claim.envelope.id
8795                            )));
8796                        }
8797                        if status == TerminalStatus::Completed {
8798                            if let Some(collector) = local_history.take() {
8799                                commit_local_tool_history(
8800                                    collector, logical_session,
8801                                    session_inbox_runtime.ok_or_else(local_tool_history_unsupported)?,
8802                                    activation_run_id.ok_or_else(local_tool_history_unsupported)?,
8803                                    (local_history_tools.ok_or_else(local_tool_history_unsupported)?, local_history_read_only),
8804                                    LocalToolTerminal::Completed(result.as_deref()), cancel_token,
8805                                ).await?;
8806                            }
8807                        }
8808                        if let Some(collector) = readonly.take() {
8809                            if status == TerminalStatus::Completed {
8810                                let activation = plain_input.ok_or_else(|| plain_actor_unsupported())?;
8811                                let backlog = activation.input_inbox.inspect(&logical_session.id).await
8812                                    .map_err(|_| plain_actor_unsupported())?;
8813                                if backlog.pending != 0 || backlog.claimed != 0 || backlog.activation_pending() {
8814                                    return Err(AgentError::LLM("Glob Actor continuation is unsupported; pending Inbox input is preserved".into()));
8815                                }
8816                                *readonly_output.as_deref_mut().ok_or_else(|| plain_actor_unsupported())? =
8817                                    collector.finish(result.as_deref())?;
8818                            }
8819                        } else if let Some(activation) = plain_input {
8820                            if status == TerminalStatus::Completed {
8821                                let binding = session_inbox_runtime.ok_or_else(|| plain_actor_unsupported())?;
8822                                let run_id = activation_run_id.ok_or_else(|| plain_actor_unsupported())?;
8823                                if continuations < continuation_limit {
8824                                    if let Some((claim, epoch)) = activation.continue_input(
8825                                        client, binding, logical_session, run_id, plain_run, &mut expected_permission_posture,
8826                                        result.clone().filter(|text| !text.is_empty()).ok_or_else(|| plain_actor_unsupported())?, event_tx,
8827                                    ).await? {
8828                                        owned_inputs = VecDeque::from([claim]);
8829                                        released_inputs.clear();
8830                                        continuations += 1;
8831                                        current_epoch = epoch;
8832                                        approval_epoch.store(epoch, Ordering::Release);
8833                                        // These are native coordinates scoped to the newly
8834                                        // correlated epoch. Public Host feed seq is untouched.
8835                                        next_actor_event_seq = 1;
8836                                        canonical_router = None;
8837                                        permission_handshake = PermissionPostureHandshake::new(expected_permission_posture.as_ref());
8838                                        display = ActorEventDisplay::default();
8839                                        continue;
8840                                    }
8841                                } else if activation.input_inbox.inspect(&logical_session.id).await
8842                                    .map_err(|_| plain_actor_unsupported())?.activation_pending() {
8843                                    if continuation_limit == 1 {
8844                                        return Err(AgentError::LLM("Only one Actor correction continuation is supported; remaining Inbox input is preserved".into()));
8845                                    }
8846                                    // The outer success path appends the last reply. An
8847                                    // explicit cap refusal must preserve it here instead,
8848                                    // without claiming/acking the excess input or saving
8849                                    // through the ordinary writer on this owned Actor.
8850                                    activation.append_reply(logical_session,
8851                                        result.clone().filter(|text| !text.is_empty()).ok_or_else(|| plain_actor_unsupported())?,
8852                                        event_tx).await?;
8853                                    return Err(AgentError::LLM("Actor correction continuation limit reached; completed reply committed and remaining Inbox input preserved".into()));
8854                                }
8855                            }
8856                        }
8857                        if status == TerminalStatus::Completed {
8858                            // Only an activation with a canonical Child creation
8859                            // identity can invoke the Host SubAgent route. Legacy
8860                            // Echo and other executors may share this runner's
8861                            // installed tool but have no nested wait to fence.
8862                            if let (Some(tool), Some(creation)) =
8863                                (canonical_subagent_tool.as_deref(), expected_creation)
8864                            {
8865                                let (Some(store), Some(binding), Some(run_id)) =
8866                                    (actor_directory_store, session_inbox_runtime,
8867                                        activation_run_id)
8868                                else {
8869                                    return Err(AgentError::LLM(
8870                                        "nested SubAgent Host authority unavailable at Child terminal"
8871                                            .into(),
8872                                    ));
8873                                };
8874                                let must_suspend = ensure_active_nested_wait(
8875                                    tool, store, binding, run_id, child_session_id,
8876                                    parent_session_id, logical_session, creation, plain_input,
8877                                ).await?;
8878                                if must_suspend || nested_wait_registered_this_run {
8879                                    // This is only a current-Run handoff fence.
8880                                    // sdk/spawn re-reads the durable Host wait;
8881                                    // copying a stale wait into this Session
8882                                    // would re-arm it after a fast grandchild
8883                                    // completion, while copying no wait would
8884                                    // erase an active registration on final save.
8885                                    logical_session.metadata.insert(
8886                                        "runtime.actor_nested_wait_handoff".into(),
8887                                        "true".into(),
8888                                    );
8889                                }
8890                            }
8891                        }
8892                        let terminal_result = match status {
8893                            TerminalStatus::Completed => Ok(result),
8894                            TerminalStatus::Cancelled => Err(AgentError::Cancelled),
8895                            TerminalStatus::Error => Err(AgentError::LLM(
8896                                error.unwrap_or_else(|| "actor child errored".to_string()),
8897                            )),
8898                            TerminalStatus::Suspended => {
8899                                use bamboo_agent_core::storage::Storage;
8900                                let (Some(request_id), Some(store), Some(binding), Some(run_id), Some(creation)) =
8901                                    (parent_question_checkpoint_id.as_ref(), actor_directory_store,
8902                                        session_inbox_runtime, activation_run_id, expected_creation)
8903                                else {
8904                                    return Err(AgentError::LLM("uncheckpointed Child suspension rejected".into()));
8905                                };
8906                                if load_active_subagent_caller(
8907                                    store, binding, run_id, child_session_id,
8908                                    parent_session_id, logical_session, creation, plain_input,
8909                                ).await.is_none() {
8910                                    return Err(AgentError::LLM("stale Child question suspension rejected".into()));
8911                                }
8912                                let id = bamboo_domain::SessionMessageId::parse(request_id.clone())
8913                                    .map_err(|_| AgentError::LLM("invalid Child question receipt".into()))?;
8914                                let child = store.load_session(child_session_id).await
8915                                    .map_err(|_| AgentError::LLM("canonical Child question unavailable".into()))?
8916                                    .ok_or_else(|| AgentError::LLM("canonical Child question missing".into()))?;
8917                                let parent = store.load_session(parent_session_id).await
8918                                    .map_err(|_| AgentError::LLM("canonical direct parent unavailable".into()))?
8919                                    .ok_or_else(|| AgentError::LLM("canonical direct parent missing".into()))?;
8920                                let pending = ParentQuestion::for_pending(&parent, &child)
8921                                    .is_some_and(|question| question.id == id);
8922                                let answered = bamboo_domain::ParentQuestionResolution::from_child(
8923                                    &parent, &child, &id,
8924                                ).is_some();
8925                                if !pending && !answered {
8926                                    return Err(AgentError::LLM("canonical Child question proof changed".into()));
8927                                }
8928                                logical_session.metadata.insert(
8929                                    "runtime.actor_parent_question_handoff".into(), "true".into(),
8930                                );
8931                                Ok(None)
8932                            }
8933                        };
8934                        if local_history_tools.is_some() {
8935                            // Failed strict Runs discard their collected suffix. A
8936                            // consumed watermark cannot certify those unapplied
8937                            // effects; retain every broker frame for recovery.
8938                            if status != TerminalStatus::Completed {
8939                                return terminal_result;
8940                            }
8941                            let proof = bamboo_storage::v2::HostTerminalCompleteness::from_verified_host(
8942                                logical_session,
8943                                activation_run_id.ok_or_else(local_tool_history_unsupported)?,
8944                                current_epoch,
8945                                next_actor_event_seq - 1,
8946                            ).map_err(|_| local_tool_history_unsupported())?;
8947                            let history = StrictTerminalHistory {
8948                                proof,
8949                                watermark: final_event_watermark.ok_or_else(local_tool_history_unsupported)?,
8950                            };
8951                            if let Some(output) = strict_history_output.as_deref_mut() {
8952                                *output = Some(history);
8953                            }
8954                        }
8955                        // This marker is set only after every Host terminal
8956                        // validation above succeeded. A surfaced broker Outcome
8957                        // that failed permission, Inbox, or question checks must
8958                        // never receive a durable ACK merely because SDK saved
8959                        // an error status. Genuine Worker Error/Cancelled frames
8960                        // are accepted terminals even though drive returns Err.
8961                        client.accept_durable_terminal(status);
8962                            if workflow_usage_requested {
8963                                logical_session.metadata.insert(bamboo_subagent::proto::WORKFLOW_TERMINAL_OBSERVATION_KEY.into(), activation_run_id.ok_or_else(plain_actor_unsupported)?.into());
8964                            }
8965                        if status == TerminalStatus::Completed {
8966                            if let Some(activation) = plain_input {
8967                                // Only the final epoch reaches here: continued Runs
8968                                // above reset their router and stay in the pump.
8969                                *activation.completion_router.lock().recover_poison() =
8970                                    canonical_router.take();
8971                            }
8972                        }
8973                        return terminal_result;
8974                    }
8975                    Ok(None) => {
8976                        return Err(AgentError::LLM(
8977                            "actor child closed before terminal".to_string(),
8978                        ));
8979                    }
8980                    Err(e) => {
8981                        return Err(AgentError::LLM(format!("actor transport error: {e}")));
8982                    }
8983                }
8984            }
8985        }
8986    }
8987
8988    // Only reached on cancellation: ask the child to stop (best-effort), then report cancelled.
8989    let _ = client.send(ParentFrame::Cancel).await;
8990    Err(AgentError::Cancelled)
8991}
8992
8993/// The assignment text = the child session's latest user message (falls back to its title).
8994fn project_id_for_actor_run(
8995    session: &Session,
8996) -> Result<Option<bamboo_domain::ProjectId>, AgentError> {
8997    match crate::project_context::ProjectContextResolver::session_project_identity(session) {
8998        crate::project_context::SessionProjectIdentity::Assigned(project_id) => {
8999            Ok(Some(project_id))
9000        }
9001        crate::project_context::SessionProjectIdentity::Unassigned => Ok(None),
9002        crate::project_context::SessionProjectIdentity::Invalid { raw, message } => {
9003            Err(AgentError::LLM(format!(
9004                "child session carries an invalid Project identity '{raw}': {message}"
9005            )))
9006        }
9007    }
9008}
9009
9010fn remote_broker_unavailable() -> AgentError {
9011    AgentError::LLM("remote_broker_activation_unavailable".into())
9012}
9013
9014/// Only these bounded worker admission errors may cross the fixed Remote
9015/// diagnostic boundary before permission posture confirmation. Transport and
9016/// arbitrary worker text retain the generic remote error.
9017fn remote_environment_error_code(value: &str) -> Option<&'static str> {
9018    const CODES: &[&str] = &[
9019        "remote_environment_lease_invalid",
9020        "remote_environment_snapshot_mismatch",
9021        "remote_environment_checkout_not_clean",
9022        "remote_environment_checkout_unsupported",
9023        "remote_environment_workspace_unavailable",
9024        "remote_environment_workspace_outside_git_root",
9025        "remote_environment_path_unsupported",
9026        "remote_environment_git_unavailable",
9027        "remote_environment_git_inspection_failed",
9028        "remote_environment_git_inspection_timed_out",
9029        "remote_environment_git_output_too_large",
9030        "remote_environment_snapshot_too_large",
9031        "remote_environment_snapshot_changed",
9032        "remote_environment_index_invalid",
9033        "remote_environment_index_unsupported",
9034        "remote_environment_host_path_or_grants_rejected",
9035        "remote_environment_worker_workspace_missing",
9036        "remote_environment_runspec_invalid",
9037        "remote_environment_lease_missing_or_host_path_present",
9038    ];
9039    CODES.iter().copied().find(|code| *code == value)
9040}
9041
9042// Read only: no ensure/repair/Actor initialization or subtree budget. Capture
9043// exactly the canonical chain and compare it again immediately before Run.
9044async fn remote_canonical_lineage(
9045    binding: &SessionInboxRuntimeBinding,
9046    session: &Session,
9047) -> Result<Vec<bamboo_domain::ActorSession>, AgentError> {
9048    let mut current = bamboo_domain::ActorSession::from_session(session)
9049        .map_err(|_| remote_broker_unavailable())?;
9050    let mut chain = Vec::new();
9051    loop {
9052        if chain.len() > session.spawn_depth as usize {
9053            return Err(remote_broker_unavailable());
9054        }
9055        let saved = binding
9056            .storage
9057            .load_session(&current.actor_id)
9058            .await
9059            .map_err(|_| remote_broker_unavailable())?
9060            .ok_or_else(remote_broker_unavailable)?;
9061        let observed = bamboo_domain::ActorSession::from_session(&saved)
9062            .map_err(|_| remote_broker_unavailable())?;
9063        if observed.actor_id != current.actor_id
9064            || observed.session_created_at != current.session_created_at
9065            || observed.parent_actor_id != current.parent_actor_id
9066            || observed.root_actor_id != current.root_actor_id
9067            || observed.spawn_depth != current.spawn_depth
9068            || observed.project_id != current.project_id
9069        {
9070            return Err(remote_broker_unavailable());
9071        }
9072        chain.push(observed.clone());
9073        let Some(parent_id) = observed.parent_actor_id.as_ref() else {
9074            if observed.actor_id != session.root_session_id || observed.spawn_depth != 0 {
9075                return Err(remote_broker_unavailable());
9076            }
9077            return Ok(chain);
9078        };
9079        let parent = binding
9080            .storage
9081            .load_session(parent_id)
9082            .await
9083            .map_err(|_| remote_broker_unavailable())?
9084            .ok_or_else(remote_broker_unavailable)?;
9085        let parent = bamboo_domain::ActorSession::from_session(&parent)
9086            .map_err(|_| remote_broker_unavailable())?;
9087        if parent.root_actor_id != observed.root_actor_id
9088            || parent.project_id != observed.project_id
9089            || parent.spawn_depth.checked_add(1) != Some(observed.spawn_depth)
9090            || parent.session_created_at > observed.session_created_at
9091        {
9092            return Err(remote_broker_unavailable());
9093        }
9094        current = parent;
9095    }
9096}
9097
9098async fn canonical_child_creation(
9099    binding: &SessionInboxRuntimeBinding,
9100    session: &Session,
9101    job: &SpawnJob,
9102) -> Result<bamboo_subagent::proto::ChildCreationIdentity, AgentError> {
9103    let conflict = || {
9104        AgentError::LLM(
9105            "child creation identity unavailable or changed in canonical host Storage".into(),
9106        )
9107    };
9108    let current = binding
9109        .storage
9110        .load_session(&job.child_session_id)
9111        .await
9112        .map_err(|_| conflict())?
9113        .ok_or_else(conflict)?;
9114    if bamboo_domain::ActorSession::from_session(&current).is_err()
9115        || bamboo_domain::ActorSession::from_session(session).is_err()
9116        || current.kind != bamboo_domain::SessionKind::Child
9117        || session.kind != current.kind
9118        || current.id != session.id
9119        || current.id != job.child_session_id
9120        || current.parent_session_id.as_deref() != Some(job.parent_session_id.as_str())
9121        || current.parent_session_id != session.parent_session_id
9122        || current.root_session_id.trim().is_empty()
9123        || current.root_session_id != session.root_session_id
9124        || current.created_at != session.created_at
9125        || current.spawn_depth != session.spawn_depth
9126        || current.spawn_depth == 0
9127        || project_id_for_actor_run(&current)? != project_id_for_actor_run(session)?
9128    {
9129        return Err(conflict());
9130    }
9131    Ok(bamboo_subagent::proto::ChildCreationIdentity {
9132        created_at: current.created_at,
9133        spawn_depth: current.spawn_depth,
9134    })
9135}
9136
9137fn logical_identity_for_actor_run(session: &Session, job: &SpawnJob) -> LogicalSessionIdentity {
9138    LogicalSessionIdentity {
9139        creation: None,
9140        session_id: session.id.clone(),
9141        parent_session_id: session
9142            .parent_session_id
9143            .clone()
9144            .or_else(|| Some(job.parent_session_id.clone())),
9145        root_session_id: if session.root_session_id.trim().is_empty() {
9146            job.parent_session_id.clone()
9147        } else {
9148            session.root_session_id.clone()
9149        },
9150    }
9151}
9152
9153fn extract_assignment(session: &Session) -> String {
9154    session
9155        .messages
9156        .iter()
9157        .rev()
9158        .find(|m| matches!(m.role, Role::User))
9159        .map(|m| m.content.clone())
9160        .unwrap_or_else(|| {
9161            session
9162                .metadata
9163                .get("title")
9164                .cloned()
9165                .unwrap_or_else(|| "Execute task".to_string())
9166        })
9167}
9168
9169#[cfg(test)]
9170mod tests {
9171    use super::*;
9172    use crate::SessionActivationRouter;
9173    use bamboo_domain::{RuntimeSessionPersistence, SessionInboxPort, Storage};
9174
9175    include!("expired_plain_owner_tests.rs");
9176
9177    include!("owned_cold_input_tests.rs");
9178
9179    #[test]
9180    fn plain_rejection_logs_branch_and_run_without_changing_error() {
9181        // Keep shared callsites consulting the current subscriber during scoped capture.
9182        let capture_interest_guard = tracing::Dispatch::new(
9183            tracing_subscriber::fmt()
9184                .with_max_level(tracing_subscriber::filter::LevelFilter::OFF)
9185                .with_writer(std::io::sink)
9186                .finish(),
9187        );
9188        let output = tempfile::NamedTempFile::new().unwrap();
9189        let subscriber = tracing_subscriber::fmt()
9190            .with_ansi(false)
9191            .without_time()
9192            .with_writer(output.reopen().unwrap())
9193            .finish();
9194        let branch_line = std::cell::Cell::new(0);
9195        let error = tracing::subscriber::with_default(subscriber, || {
9196            // Exercise the shared warning callsite without this thread's scoped subscriber.
9197            std::thread::spawn(|| drop(plain_actor_unsupported()))
9198                .join()
9199                .expect("unscoped rejection warning thread panicked");
9200            let span = tracing::warn_span!(
9201                "actor_child_run",
9202                child_session_id = %"diagnostic-child",
9203                activation_run_id = tracing::field::Empty
9204            );
9205            let _entered = span.enter();
9206            span.record("activation_run_id", "diagnostic-run");
9207            None::<()>
9208                .ok_or_else(|| {
9209                    let caller_line = line!() + 1;
9210                    let error = plain_actor_unsupported();
9211                    branch_line.set(caller_line);
9212                    error
9213                })
9214                .unwrap_err()
9215        });
9216        assert!(matches!(&error, AgentError::LLM(message)
9217            if message.starts_with("This Actor Child supports a fresh plain activation,")
9218                && message.ends_with("Other continuation is unsupported; durable history is preserved.")));
9219        let log = std::fs::read_to_string(output.path()).unwrap();
9220        assert!(log.contains("child_session_id=diagnostic-child"), "{log}");
9221        assert!(
9222            log.contains("activation_run_id=\"diagnostic-run\""),
9223            "{log}"
9224        );
9225        assert!(
9226            log.contains(&format!("process_id={}", std::process::id())),
9227            "{log}"
9228        );
9229        assert!(log.contains(&format!("source_file={:?}", file!())), "{log}");
9230        assert!(
9231            log.contains(&format!("source_line={}", branch_line.get())),
9232            "{log}"
9233        );
9234        assert!(!error.to_string().contains("diagnostic-run"));
9235        drop(capture_interest_guard);
9236    }
9237
9238    #[tokio::test]
9239    async fn completed_plain_continuation_requires_bounded_inputs_and_current_identity() {
9240        for case in [
9241            "valid",
9242            "over-batch-limit",
9243            "tool-history",
9244            "stale-lineage",
9245            "no-release",
9246        ] {
9247            let temp = tempfile::tempdir().unwrap();
9248            let store = Arc::new(
9249                bamboo_storage::SessionStoreV2::new(temp.path().into())
9250                    .await
9251                    .unwrap(),
9252            );
9253            let parent = Session::new("continuation-parent", "model");
9254            store.save_session(&parent).await.unwrap();
9255            let mut child =
9256                Session::new_child_of("continuation-child", &parent, "model", "first task");
9257            child.add_message(bamboo_agent_core::Message::assistant("first result", None));
9258            if case == "tool-history" {
9259                let mut tool = bamboo_agent_core::Message::assistant("old tool result", None);
9260                tool.role = Role::Tool;
9261                child.add_message(tool);
9262            }
9263            child.set_last_run_status("completed");
9264            store.save_session(&child).await.unwrap();
9265            store.ensure_actor(&child.id).await.unwrap();
9266            let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
9267            bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
9268            let now = chrono::Utc::now();
9269            let old = store
9270                .claim_activation(&ActorActivationClaim {
9271                    actor_id: child.id.clone(),
9272                    run_id: "first-run".into(),
9273                    lease_owner: "first-owner".into(),
9274                    lease_expires_at: now + chrono::Duration::minutes(1),
9275                    inbox_generation: 0,
9276                    placement_ref: None,
9277                    now,
9278                })
9279                .await
9280                .unwrap();
9281            store.start_activation(&old.fence(), now).await.unwrap();
9282            store
9283                .finish_activation(&old.fence(), now, ActorActivationFinish::Succeeded)
9284                .await
9285                .unwrap();
9286            let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
9287                store.clone(),
9288                Default::default(),
9289            ));
9290            for slot in 0..if case == "over-batch-limit" {
9291                bamboo_domain::SessionInboxLimits::default().max_claim_batch + 1
9292            } else {
9293                1
9294            } {
9295                let envelope = bamboo_domain::SessionMessageEnvelope::user_input(
9296                    &child.id,
9297                    format!("new task {slot}"),
9298                );
9299                let receipt = inbox.deliver(&envelope).await.unwrap();
9300                inbox
9301                    .mark_activation_eligible(
9302                        &child.id,
9303                        receipt.generation,
9304                        bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
9305                    )
9306                    .await
9307                    .unwrap();
9308            }
9309            let storage: Arc<dyn Storage> = store.clone();
9310            let persistence = Arc::new(bamboo_storage::LockedSessionStore::new(storage));
9311            let binding = actor_binding(store.clone(), inbox, persistence);
9312            let _registration = binding
9313                .router
9314                .register_run(&child.id, "second-run")
9315                .await
9316                .unwrap();
9317            if case == "stale-lineage" {
9318                child.parent_session_id = Some("foreign-parent".into());
9319            }
9320            let result = PlainActorActivation::start(
9321                store.clone(),
9322                &child,
9323                &binding,
9324                Some("second-run"),
9325                Some(policy),
9326                true,
9327                (case != "no-release").then_some("second-worker"),
9328            )
9329            .await;
9330            if case == "valid" {
9331                let activation = result.expect("one new input on a completed plain Child");
9332                assert_eq!(activation.fence.attempt, old.attempt + 1);
9333                assert_eq!(activation.fence.actor_id, child.id);
9334                assert_eq!(activation.inbox_generation, 1);
9335                activation
9336                    .finish(ActorActivationFinish::Failed)
9337                    .await
9338                    .unwrap();
9339            } else {
9340                assert!(
9341                    result.is_err(),
9342                    "unsupported continuation {case} must fail before claim"
9343                );
9344                assert_eq!(
9345                    store
9346                        .inspect_actor(&child.id)
9347                        .await
9348                        .unwrap()
9349                        .actor
9350                        .current_attempt,
9351                    old.attempt
9352                );
9353            }
9354            let canonical = store.load_session(&child.id).await.unwrap().unwrap();
9355            assert_eq!(
9356                canonical.parent_session_id.as_deref(),
9357                Some(parent.id.as_str())
9358            );
9359            assert!(canonical
9360                .messages
9361                .iter()
9362                .any(|message| message.content == "first result"));
9363        }
9364    }
9365
9366    #[tokio::test]
9367    async fn pre_dispatch_placement_failure_fails_only_claimed_activation() {
9368        let temp = tempfile::tempdir().unwrap();
9369        let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
9370            .await
9371            .unwrap();
9372        let parent = Session::new("placement-parent", "model");
9373        let child = Session::new_child_of("placement-child", &parent, "model", "task");
9374        store.save_session(&parent).await.unwrap();
9375        store.save_session(&child).await.unwrap();
9376        store.ensure_actor(&child.id).await.unwrap();
9377        store
9378            .bind_control_plane(
9379                &child.id,
9380                bamboo_domain::ActorControlPlaneBinding {
9381                    policy_revision: None,
9382                    placement_intent: Some(bamboo_domain::ActorPlacementIntent::Pool {
9383                        pool_id: "remote-worker".into(),
9384                    }),
9385                },
9386            )
9387            .await
9388            .unwrap();
9389
9390        let claim = |run_id: &str| ActorActivationClaim {
9391            actor_id: child.id.clone(),
9392            run_id: run_id.into(),
9393            lease_owner: format!("host-{run_id}"),
9394            lease_expires_at: chrono::Utc::now() + chrono::Duration::seconds(80),
9395            inbox_generation: 0,
9396            placement_ref: Some(bamboo_domain::ActorPlacementRef {
9397                class: bamboo_domain::ActorPlacementClass::Remote,
9398                lease_id: format!("slot-{run_id}"),
9399                slot_epoch: Some(1),
9400            }),
9401            now: chrono::Utc::now(),
9402        };
9403        let first = store.claim_activation(&claim("first")).await.unwrap();
9404        store
9405            .start_activation(&first.fence(), chrono::Utc::now())
9406            .await
9407            .unwrap();
9408        assert!(store.claim_activation(&claim("retry")).await.is_err());
9409
9410        // This is the post-claim placement preflight failure path. No Run has
9411        // been dispatched, so the same ActorId can retry before lease expiry.
9412        fail_pre_dispatch_activation(&store, &first.fence())
9413            .await
9414            .unwrap();
9415        let failed = store.inspect_actor(&child.id).await.unwrap();
9416        assert_eq!(
9417            failed.activation.unwrap().status,
9418            bamboo_domain::ActorActivationStatus::Failed
9419        );
9420        let retry = store.claim_activation(&claim("retry")).await.unwrap();
9421        assert_eq!(retry.attempt, first.attempt + 1);
9422        assert!(fail_pre_dispatch_activation(&store, &first.fence())
9423            .await
9424            .is_err());
9425        assert_eq!(
9426            store.inspect_actor(&child.id).await.unwrap().activation,
9427            Some(retry)
9428        );
9429    }
9430
9431    #[tokio::test]
9432    async fn lost_run_delivery_receipt_keeps_actor_activation_live() {
9433        struct AcceptedWithoutReceipt {
9434            published: bool,
9435        }
9436
9437        #[async_trait]
9438        impl bamboo_subagent::ChildLink for AcceptedWithoutReceipt {
9439            async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
9440                assert!(matches!(frame, ParentFrame::Run(_)));
9441                self.published = true;
9442                Err(bamboo_subagent::TransportError::Protocol(
9443                    "broker delivery receipt lost after publication".into(),
9444                ))
9445            }
9446
9447            async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
9448                std::future::pending().await
9449            }
9450        }
9451
9452        let temp = tempfile::tempdir().unwrap();
9453        let store = Arc::new(
9454            bamboo_storage::SessionStoreV2::new(temp.path().into())
9455                .await
9456                .unwrap(),
9457        );
9458        let parent = Session::new("dispatch-parent", "model");
9459        let child = Session::new_child_of("dispatch-child", &parent, "model", "task");
9460        store.save_session(&parent).await.unwrap();
9461        store.save_session(&child).await.unwrap();
9462        store.ensure_actor(&child.id).await.unwrap();
9463        let now = chrono::Utc::now();
9464        let claim = ActorActivationClaim {
9465            actor_id: child.id.clone(),
9466            run_id: "dispatch-run".into(),
9467            lease_owner: "dispatch-host".into(),
9468            lease_expires_at: now + chrono::Duration::seconds(80),
9469            inbox_generation: 0,
9470            placement_ref: None,
9471            now,
9472        };
9473        let activation = store.claim_activation(&claim).await.unwrap();
9474        store
9475            .start_activation(&activation.fence(), chrono::Utc::now())
9476            .await
9477            .unwrap();
9478        let mut link = AcceptedWithoutReceipt { published: false };
9479        let mut send_invoked = false;
9480        let run = RunSpec {
9481            assignment: "task".into(),
9482            logical_session: None,
9483            project_id: None,
9484            reasoning_effort: None,
9485            permission_policy: None,
9486            messages: Vec::new(),
9487            activation_run_id: Some(claim.run_id.clone()),
9488            execution_epoch: 1,
9489            initial_session_messages: Vec::new(),
9490            secrets: Default::default(),
9491        };
9492        assert!(send_actor_run(&mut link, run, &mut send_invoked)
9493            .await
9494            .is_err());
9495        assert!(send_invoked && link.published);
9496        let slot = WorkerSlotLease {
9497            lease_id: "dispatch-slot".into(),
9498            host_ref: "dispatch-worker".into(),
9499            connection_generation: "dispatch-generation".into(),
9500            slot: 0,
9501            epoch: 1,
9502            actor_id: child.id.clone(),
9503            run_id: claim.run_id.clone(),
9504            expires_at: claim.lease_expires_at,
9505        };
9506        let mut lease = ActivePlacementLease {
9507            registry: bamboo_storage::v2::FileHostRegistry::new(temp.path().into())
9508                .await
9509                .unwrap(),
9510            actor_store: store.clone(),
9511            route: ScopedPlacementRoute {
9512                endpoint: String::new(),
9513                token: String::new(),
9514                ca_cert_file: PathBuf::new(),
9515                parent: bamboo_subagent::AgentRef {
9516                    session_id: "dispatch-parent".into(),
9517                    role: Some("host".into()),
9518                },
9519                worker_role: "worker".into(),
9520                pinned_mailbox: None,
9521                requirements: Default::default(),
9522            },
9523            mailbox: "dispatch-worker".into(),
9524            host_ref: "dispatch-worker".into(),
9525            fence: activation.fence(),
9526            placement_ref: bamboo_domain::ActorPlacementRef {
9527                class: bamboo_domain::ActorPlacementClass::Remote,
9528                lease_id: slot.lease_id.clone(),
9529                slot_epoch: Some(slot.epoch),
9530            },
9531            lease: Arc::new(tokio::sync::Mutex::new(slot)),
9532            heartbeat: tokio::spawn(std::future::pending()),
9533            run_cancel: CancellationToken::new(),
9534            lost: Arc::new(AtomicBool::new(false)),
9535            release_on_drop: AtomicBool::new(false),
9536        };
9537        assert!(!lease.abort_if_run_unsent(send_invoked).await.unwrap());
9538        assert_eq!(
9539            store
9540                .inspect_actor(&child.id)
9541                .await
9542                .unwrap()
9543                .activation
9544                .unwrap()
9545                .status,
9546            bamboo_domain::ActorActivationStatus::Running
9547        );
9548        let mut retry = claim.clone();
9549        retry.run_id = "dispatch-retry".into();
9550        retry.now = chrono::Utc::now();
9551        retry.lease_expires_at = retry.now + chrono::Duration::seconds(80);
9552        assert!(matches!(
9553            store.claim_activation(&retry).await,
9554            Err(bamboo_domain::ActorDirectoryError::Busy)
9555        ));
9556    }
9557
9558    #[test]
9559    fn accepted_cancelled_terminal_maps_to_cancelled_actor_activation() {
9560        let cancelled: Result<Option<String>, AgentError> = Err(AgentError::Cancelled);
9561        assert_eq!(
9562            remote_activation_finish(&cancelled, Some(TerminalStatus::Cancelled)),
9563            ActorActivationFinish::Cancelled
9564        );
9565        assert_eq!(
9566            remote_activation_finish(&cancelled, None),
9567            ActorActivationFinish::Failed
9568        );
9569        assert_eq!(
9570            remote_activation_finish(&cancelled, Some(TerminalStatus::Completed)),
9571            ActorActivationFinish::Failed
9572        );
9573    }
9574
9575    #[tokio::test]
9576    async fn remote_reply_is_fenced_before_terminal_status_save() {
9577        let temp = tempfile::tempdir().unwrap();
9578        let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
9579            .await
9580            .unwrap();
9581        let parent = Session::new("remote-reply-parent", "model");
9582        let mut child = Session::new_child_of("remote-reply-child", &parent, "model", "task");
9583        child.add_message(bamboo_agent_core::Message::user("task"));
9584        store.save_session(&parent).await.unwrap();
9585        store.save_session(&child).await.unwrap();
9586        store.ensure_actor(&child.id).await.unwrap();
9587        store
9588            .bind_control_plane(
9589                &child.id,
9590                bamboo_domain::ActorControlPlaneBinding {
9591                    policy_revision: None,
9592                    placement_intent: Some(bamboo_domain::ActorPlacementIntent::Pool {
9593                        pool_id: "remote-worker".into(),
9594                    }),
9595                },
9596            )
9597            .await
9598            .unwrap();
9599        let now = chrono::Utc::now();
9600        let activation = store
9601            .claim_activation(&ActorActivationClaim {
9602                actor_id: child.id.clone(),
9603                run_id: "remote-reply-run".into(),
9604                lease_owner: "remote-worker".into(),
9605                lease_expires_at: now + chrono::Duration::seconds(80),
9606                inbox_generation: 0,
9607                placement_ref: Some(bamboo_domain::ActorPlacementRef {
9608                    class: bamboo_domain::ActorPlacementClass::Remote,
9609                    lease_id: "remote-slot".into(),
9610                    slot_epoch: Some(1),
9611                }),
9612                now,
9613            })
9614            .await
9615            .unwrap();
9616        let fence = activation.fence();
9617        store.start_activation(&fence, now).await.unwrap();
9618
9619        append_fenced_remote_reply(&store, &fence, &mut child, "done")
9620            .await
9621            .unwrap();
9622        store
9623            .finish_activation(&fence, chrono::Utc::now(), ActorActivationFinish::Succeeded)
9624            .await
9625            .unwrap();
9626        child.set_last_run_status("completed");
9627        store.save_session(&child).await.unwrap();
9628        let saved = store.load_session(&child.id).await.unwrap().unwrap();
9629        assert_eq!(saved.last_run_status().as_deref(), Some("completed"));
9630        assert_eq!(saved.messages.len(), 2);
9631        assert_eq!(saved.messages.last().unwrap().content, "done");
9632        assert!(
9633            append_fenced_remote_reply(&store, &fence, &mut child, "duplicate")
9634                .await
9635                .is_err()
9636        );
9637        assert_eq!(
9638            store
9639                .load_session(&child.id)
9640                .await
9641                .unwrap()
9642                .unwrap()
9643                .messages
9644                .len(),
9645            2
9646        );
9647    }
9648
9649    async fn start_bus() -> (String, tempfile::TempDir) {
9650        let dir = tempfile::tempdir().unwrap();
9651        let core = Arc::new(bamboo_broker::BrokerCore::new(dir.path()));
9652        let server = Arc::new(bamboo_broker::BrokerServer::new(core, "t"));
9653        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9654        let addr = listener.local_addr().unwrap();
9655        tokio::spawn(async move {
9656            let _ = server.serve(listener).await;
9657        });
9658        (format!("ws://{addr}"), dir)
9659    }
9660
9661    struct DurableAckProbe {
9662        calls: Arc<AtomicUsize>,
9663        fail: bool,
9664        terminal_status: TerminalStatus,
9665        parent_mailbox: String,
9666    }
9667
9668    #[async_trait]
9669    impl bamboo_subagent::ChildLink for DurableAckProbe {
9670        async fn send(&mut self, _frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
9671            Ok(())
9672        }
9673
9674        async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
9675            Ok(None)
9676        }
9677
9678        fn has_pending_durable_terminal(&self) -> bool {
9679            true
9680        }
9681
9682        fn durable_delivery_receipt(&self) -> Option<bamboo_subagent::DurableChildDeliveryReceipt> {
9683            Some(bamboo_subagent::DurableChildDeliveryReceipt {
9684                broker_identity: "00000000-0000-4000-8000-000000000001".into(),
9685                parent_mailbox: self.parent_mailbox.clone(),
9686                correlation_id: "broker-run-1".into(),
9687                message_ids: vec!["broker-outcome-1".into()],
9688                terminal_status: self.terminal_status,
9689            })
9690        }
9691
9692        async fn acknowledge_durable_frames(&mut self) -> bamboo_subagent::TransportResult<()> {
9693            self.calls.fetch_add(1, Ordering::SeqCst);
9694            if self.fail {
9695                Err(bamboo_subagent::TransportError::Protocol(
9696                    "injected ACK delivery failure".into(),
9697                ))
9698            } else {
9699                Ok(())
9700            }
9701        }
9702    }
9703
9704    async fn insert_ack_probe(
9705        runner: &ActorChildRunner,
9706        session: &Session,
9707        calls: &Arc<AtomicUsize>,
9708        terminal_status: TerminalStatus,
9709    ) {
9710        insert_ack_probe_with_nested_wait(runner, session, calls, terminal_status, false).await;
9711    }
9712
9713    async fn insert_ack_probe_with_nested_wait(
9714        runner: &ActorChildRunner,
9715        session: &Session,
9716        calls: &Arc<AtomicUsize>,
9717        terminal_status: TerminalStatus,
9718        host_nested_wait_handoff: bool,
9719    ) {
9720        runner.pending_durable_links.lock().await.insert(
9721            (session.id.clone(), session.created_at, "run-1".into()),
9722            PendingDurableChildLink {
9723                link: Box::new(DurableAckProbe {
9724                    calls: calls.clone(),
9725                    fail: false,
9726                    terminal_status,
9727                    parent_mailbox: format!("p-{}", session.id),
9728                }),
9729                host_nested_wait_handoff,
9730                strict_history: None,
9731                strict_history_required: false,
9732                strict_history_committed: false,
9733            },
9734        );
9735    }
9736
9737    #[tokio::test]
9738    async fn broker_receipt_reconciler_repairs_only_the_proven_local_mailbox() {
9739        let (endpoint, broker_dir) = start_bus().await;
9740        let home = tempfile::tempdir().unwrap();
9741        let store = Arc::new(
9742            bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
9743                .await
9744                .unwrap(),
9745        );
9746        let root = Session::new("receipt-root", "model");
9747        store.save_session(&root).await.unwrap();
9748        let mut sender = bamboo_broker::BrokerClient::connect(
9749            &endpoint,
9750            bamboo_subagent::AgentRef {
9751                session_id: "receipt-worker".into(),
9752                role: None,
9753            },
9754            "t",
9755        )
9756        .await
9757        .unwrap();
9758        let broker_identity = sender.durable_broker_identity().await.unwrap();
9759        let wrong_broker_identity = if broker_identity == "00000000-0000-4000-8000-000000000001" {
9760            "00000000-0000-4000-8000-000000000002"
9761        } else {
9762            "00000000-0000-4000-8000-000000000001"
9763        };
9764
9765        for (id, receipt_mailbox, receipt_broker) in [
9766            ("receipt-good", "p-receipt-good", broker_identity.as_str()),
9767            (
9768                "receipt-wrong-mailbox",
9769                "p-another-child",
9770                broker_identity.as_str(),
9771            ),
9772            (
9773                "receipt-wrong-broker",
9774                "p-receipt-wrong-broker",
9775                wrong_broker_identity,
9776            ),
9777            (
9778                "receipt-local-role-changed",
9779                "p-receipt-local-role-changed",
9780                broker_identity.as_str(),
9781            ),
9782            (
9783                "receipt-remote-unproven",
9784                "remote-parent-unproven",
9785                broker_identity.as_str(),
9786            ),
9787        ] {
9788            let mut child = Session::new_child_of(id, &root, "model", "task");
9789            child
9790                .metadata
9791                .insert("runtime.kind".into(), "external".into());
9792            child
9793                .metadata
9794                .insert("external.protocol".into(), "actor".into());
9795            child.metadata.insert(
9796                "external.agent_id".into(),
9797                crate::external_agents::config::LOCAL_ACTOR_AGENT_ID.into(),
9798            );
9799            if id == "receipt-local-role-changed" || id == "receipt-remote-unproven" {
9800                child
9801                    .metadata
9802                    .insert("subagent_type".into(), "remote-worker".into());
9803            }
9804            child.add_message(bamboo_agent_core::Message::user("work"));
9805            store.save_session(&child).await.unwrap();
9806            let message_id = bamboo_subagent::MsgId::new();
9807            sender
9808                .deliver(
9809                    &format!("p-{id}"),
9810                    bamboo_subagent::InboxMessage {
9811                        id: message_id.clone(),
9812                        from: bamboo_subagent::AgentRef {
9813                            session_id: "receipt-worker".into(),
9814                            role: None,
9815                        },
9816                        kind: bamboo_subagent::InboxKind::Outcome,
9817                        body: serde_json::to_value(bamboo_subagent::ChildOutcome::completed(
9818                            "done",
9819                        ))
9820                        .unwrap(),
9821                        created_at: chrono::Utc::now(),
9822                        correlation_id: Some(bamboo_subagent::MsgId::new()),
9823                    },
9824                )
9825                .await
9826                .unwrap();
9827            // The Host can checkpoint only a frame already surfaced from the
9828            // broker, which moves its Maildir file from new/ into cur/.
9829            let mailbox =
9830                bamboo_subagent::Mailbox::at(broker_dir.path().join(format!("mailboxes/p-{id}")));
9831            assert_eq!(mailbox.drain().await.unwrap().len(), 1);
9832            child.add_message(bamboo_agent_core::Message::assistant("done", None));
9833            child.set_last_run_status("completed");
9834            if id == "receipt-good" {
9835                let prior_ids = child.messages[..child.messages.len() - 1]
9836                    .iter()
9837                    .map(|message| message.id.clone())
9838                    .collect();
9839                crate::execution::ChildCompletionSource::prepare(&mut child, "run-1", &prior_ids);
9840                assert!(
9841                    crate::execution::ChildCompletionSource::defer_for_durable_delivery(
9842                        &mut child, "run-1"
9843                    )
9844                    .unwrap()
9845                );
9846                let proof = bamboo_storage::v2::HostTerminalCompleteness::from_verified_host(
9847                    &child, "run-1", 7, 1,
9848                )
9849                .unwrap();
9850                store
9851                    .prepare_broker_terminal_receipt_with_completeness(
9852                        &child,
9853                        bamboo_storage::v2::BrokerTerminalRoute {
9854                            activation_run_id: "run-1",
9855                            broker_identity: receipt_broker,
9856                            parent_mailbox: receipt_mailbox,
9857                            broker_correlation_id: "correlation-1",
9858                            message_ids: &[message_id.as_str().to_owned()],
9859                        },
9860                        &proof,
9861                    )
9862                    .await
9863                    .unwrap();
9864                let mut staged = child.clone();
9865                staged.set_last_run_status("running");
9866                staged.clear_last_run_error();
9867                store.save_session(&staged).await.unwrap();
9868                store
9869                    .commit_broker_terminal_receipt_with_completeness(&child, "run-1", &proof)
9870                    .await
9871                    .unwrap();
9872            } else {
9873                store
9874                    .prepare_broker_terminal_receipt(
9875                        &child,
9876                        "run-1",
9877                        receipt_broker,
9878                        receipt_mailbox,
9879                        "correlation-1",
9880                        &[message_id.as_str().to_owned()],
9881                    )
9882                    .await
9883                    .unwrap();
9884                store.save_session(&child).await.unwrap();
9885            }
9886        }
9887
9888        let mut config = bamboo_config::Config::default();
9889        config.subagents_mut().broker = Some(bamboo_config::BrokerClientConfig {
9890            endpoint,
9891            token: "t".into(),
9892            ..Default::default()
9893        });
9894        config
9895            .subagents_mut()
9896            .remote_placements
9897            .push(bamboo_config::RemoteActorPlacement {
9898                role: "remote-worker".into(),
9899                endpoint: "ws://unscoped.example.invalid".into(),
9900                token_env: None,
9901                ca_cert_file: None,
9902                broker_peer: None,
9903                placement_requirements: None,
9904            });
9905        // Recover on an independently reopened Store, with no Actor executor or
9906        // provider available. Only the committed Host proof can authorize ACK.
9907        let cold_store = Arc::new(
9908            bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
9909                .await
9910                .unwrap(),
9911        );
9912        let before = cold_store
9913            .load_session("receipt-good")
9914            .await
9915            .unwrap()
9916            .unwrap();
9917        assert!(crate::execution::ChildCompletionSource::has_source_record(
9918            &before
9919        ));
9920        assert_eq!(before.last_run_status().as_deref(), Some("running"));
9921        assert!(crate::execution::ChildCompletionSource::from_committed_session(&before).is_none());
9922        let repair = BrokerTerminalReceiptReconciler::new(cold_store.clone(), &config);
9923        let report = repair.reconcile_once().await;
9924        assert_eq!(report.candidates, 5);
9925        assert_eq!(report.repaired, 2);
9926        assert_eq!(report.blocked, 3);
9927        let after = cold_store
9928            .load_session("receipt-good")
9929            .await
9930            .unwrap()
9931            .unwrap();
9932        assert!(crate::execution::ChildCompletionSource::from_committed_session(&after).is_some());
9933        assert_eq!(
9934            serde_json::to_value(before.messages).unwrap(),
9935            serde_json::to_value(after.messages).unwrap()
9936        );
9937        let retry = repair.reconcile_once().await;
9938        assert_eq!(retry.repaired, 0, "committed strict recovery is idempotent");
9939
9940        let good = bamboo_subagent::Mailbox::at(broker_dir.path().join("mailboxes/p-receipt-good"));
9941        let wrong = bamboo_subagent::Mailbox::at(
9942            broker_dir.path().join("mailboxes/p-receipt-wrong-mailbox"),
9943        );
9944        let wrong_broker = bamboo_subagent::Mailbox::at(
9945            broker_dir.path().join("mailboxes/p-receipt-wrong-broker"),
9946        );
9947        let local_role_changed = bamboo_subagent::Mailbox::at(
9948            broker_dir
9949                .path()
9950                .join("mailboxes/p-receipt-local-role-changed"),
9951        );
9952        let remote_unproven = bamboo_subagent::Mailbox::at(
9953            broker_dir
9954                .path()
9955                .join("mailboxes/p-receipt-remote-unproven"),
9956        );
9957        assert_eq!(good.pending_count().await.unwrap(), 0);
9958        assert_eq!(wrong.pending_count().await.unwrap(), 1);
9959        assert_eq!(wrong_broker.pending_count().await.unwrap(), 1);
9960        assert_eq!(local_role_changed.pending_count().await.unwrap(), 0);
9961        assert_eq!(remote_unproven.pending_count().await.unwrap(), 1);
9962        let good_child = store.load_session("receipt-good").await.unwrap().unwrap();
9963        let wrong_child = store
9964            .load_session("receipt-wrong-mailbox")
9965            .await
9966            .unwrap()
9967            .unwrap();
9968        let wrong_broker_child = store
9969            .load_session("receipt-wrong-broker")
9970            .await
9971            .unwrap()
9972            .unwrap();
9973        let local_role_changed_child = store
9974            .load_session("receipt-local-role-changed")
9975            .await
9976            .unwrap()
9977            .unwrap();
9978        let remote_unproven_child = store
9979            .load_session("receipt-remote-unproven")
9980            .await
9981            .unwrap()
9982            .unwrap();
9983        assert!(store
9984            .recover_broker_terminal_receipts(&good_child)
9985            .await
9986            .unwrap()
9987            .is_empty());
9988        assert_eq!(
9989            store
9990                .recover_broker_terminal_receipts(&wrong_child)
9991                .await
9992                .unwrap()
9993                .len(),
9994            1
9995        );
9996        assert_eq!(
9997            store
9998                .recover_broker_terminal_receipts(&wrong_broker_child)
9999                .await
10000                .unwrap()
10001                .len(),
10002            1
10003        );
10004        assert!(store
10005            .recover_broker_terminal_receipts(&local_role_changed_child)
10006            .await
10007            .unwrap()
10008            .is_empty());
10009        assert_eq!(
10010            store
10011                .recover_broker_terminal_receipts(&remote_unproven_child)
10012                .await
10013                .unwrap()
10014                .len(),
10015            1
10016        );
10017    }
10018
10019    #[tokio::test]
10020    async fn broker_receipt_confirmation_requires_exact_birth_run_and_committed_status() {
10021        let runner = ActorChildRunner::new(
10022            "test".into(),
10023            PathBuf::new(),
10024            vec![],
10025            PathBuf::new(),
10026            ExecutorSpec::BambooRuntime,
10027            vec![],
10028            "test".into(),
10029            1,
10030        );
10031        let home = tempfile::tempdir().unwrap();
10032        let store = Arc::new(
10033            bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
10034                .await
10035                .unwrap(),
10036        );
10037        let root = Session::new("ack-parent", "model");
10038        store.save_session(&root).await.unwrap();
10039        let mut child = Session::new_child_of("ack-child", &root, "model", "task");
10040        child.add_message(bamboo_agent_core::Message::user("work"));
10041        store.save_session(&child).await.unwrap();
10042        child.add_message(bamboo_agent_core::Message::assistant("done", None));
10043        child.set_last_run_status("completed");
10044        store
10045            .prepare_broker_terminal_receipt(
10046                &child,
10047                "run-1",
10048                "00000000-0000-4000-8000-000000000001",
10049                "p-ack-child",
10050                "broker-run-1",
10051                &["broker-outcome-1".into()],
10052            )
10053            .await
10054            .unwrap();
10055        store.save_session(&child).await.unwrap();
10056        runner.set_actor_directory_store(Some(store));
10057        let calls = Arc::new(AtomicUsize::new(0));
10058        insert_ack_probe(&runner, &child, &calls, TerminalStatus::Completed).await;
10059        runner
10060            .confirm_durable_child_delivery(&child, "wrong-run", true)
10061            .await
10062            .unwrap();
10063        let mut wrong_birth = child.clone();
10064        wrong_birth.created_at += chrono::Duration::seconds(1);
10065        runner
10066            .confirm_durable_child_delivery(&wrong_birth, "run-1", true)
10067            .await
10068            .unwrap();
10069        assert_eq!(runner.pending_durable_links.lock().await.len(), 1);
10070        child.set_last_run_status("completed");
10071        runner
10072            .confirm_durable_child_delivery(&child, "run-1", false)
10073            .await
10074            .unwrap();
10075        assert_eq!(calls.load(Ordering::SeqCst), 0);
10076
10077        insert_ack_probe(&runner, &child, &calls, TerminalStatus::Completed).await;
10078        child.set_last_run_status("error");
10079        runner
10080            .confirm_durable_child_delivery(&child, "run-1", true)
10081            .await
10082            .unwrap();
10083        assert_eq!(calls.load(Ordering::SeqCst), 0);
10084
10085        insert_ack_probe(&runner, &child, &calls, TerminalStatus::Completed).await;
10086        child.set_last_run_status("completed");
10087        runner
10088            .confirm_durable_child_delivery(&child, "run-1", true)
10089            .await
10090            .unwrap();
10091        assert_eq!(calls.load(Ordering::SeqCst), 1);
10092    }
10093
10094    #[tokio::test]
10095    async fn strict_completeness_gate_precedes_ack_and_ack_failure_preserves_cold_proof() {
10096        for commit in [false, true] {
10097            let runner = ActorChildRunner::new(
10098                "test".into(),
10099                PathBuf::new(),
10100                vec![],
10101                PathBuf::new(),
10102                ExecutorSpec::BambooRuntime,
10103                vec![],
10104                "test".into(),
10105                1,
10106            );
10107            let home = tempfile::tempdir().unwrap();
10108            let store = Arc::new(
10109                bamboo_storage::SessionStoreV2::new(home.path().into())
10110                    .await
10111                    .unwrap(),
10112            );
10113            let root = Session::new("strict-ack-parent", "model");
10114            store.save_session(&root).await.unwrap();
10115            let mut child = Session::new_child_of("strict-ack-child", &root, "model", "work");
10116            child.add_message(bamboo_agent_core::Message::user("work"));
10117            store.save_session(&child).await.unwrap();
10118            child.add_message(bamboo_agent_core::Message::assistant("finished", None));
10119            child.set_last_run_status("completed");
10120            let proof = bamboo_storage::v2::HostTerminalCompleteness::from_verified_host(
10121                &child, "run-1", 7, 2,
10122            )
10123            .unwrap();
10124            let creation = bamboo_subagent::proto::ChildCreationIdentity {
10125                created_at: child.created_at,
10126                spawn_depth: child.spawn_depth,
10127            };
10128            let history = StrictTerminalHistory {
10129                proof: proof.clone(),
10130                watermark: bamboo_subagent::ActorEventWatermark {
10131                    version: 1,
10132                    logical_session: Some(actor_event_logical_identity(
10133                        &child,
10134                        &root.id,
10135                        Some(&creation),
10136                    )),
10137                    activation_id: Some("run-1".into()),
10138                    execution_epoch: 7,
10139                    final_seq: 2,
10140                },
10141            };
10142            let parent_mailbox = "p-strict-ack-child";
10143            store
10144                .prepare_broker_terminal_receipt_with_completeness(
10145                    &child,
10146                    bamboo_storage::v2::BrokerTerminalRoute {
10147                        activation_run_id: "run-1",
10148                        broker_identity: "00000000-0000-4000-8000-000000000001",
10149                        parent_mailbox,
10150                        broker_correlation_id: "broker-run-1",
10151                        message_ids: &["broker-outcome-1".into()],
10152                    },
10153                    &proof,
10154                )
10155                .await
10156                .unwrap();
10157            store.save_session(&child).await.unwrap();
10158            runner.set_actor_directory_store(Some(store.clone()));
10159            let calls = Arc::new(AtomicUsize::new(0));
10160            runner.pending_durable_links.lock().await.insert(
10161                (child.id.clone(), child.created_at, "run-1".into()),
10162                PendingDurableChildLink {
10163                    link: Box::new(DurableAckProbe {
10164                        calls: calls.clone(),
10165                        fail: true,
10166                        terminal_status: TerminalStatus::Completed,
10167                        parent_mailbox: parent_mailbox.into(),
10168                    }),
10169                    host_nested_wait_handoff: false,
10170                    strict_history: Some(history),
10171                    strict_history_required: true,
10172                    strict_history_committed: false,
10173                },
10174            );
10175            if commit {
10176                runner
10177                    .commit_durable_child_delivery(&child, "run-1")
10178                    .await
10179                    .unwrap();
10180                assert_eq!(calls.load(Ordering::SeqCst), 0, "proof commit cannot ACK");
10181            }
10182            assert!(runner
10183                .confirm_durable_child_delivery(&child, "run-1", true)
10184                .await
10185                .is_err());
10186            assert_eq!(calls.load(Ordering::SeqCst), usize::from(commit));
10187            let cold = bamboo_storage::SessionStoreV2::new(home.path().into())
10188                .await
10189                .unwrap();
10190            let recovered = cold.recover_broker_terminal_receipts(&child).await;
10191            if commit {
10192                let receipts = recovered.unwrap();
10193                assert_eq!(receipts.len(), 1);
10194                assert_eq!(
10195                    receipts[0]
10196                        .terminal_completeness
10197                        .as_ref()
10198                        .unwrap()
10199                        .contiguous_applied_seq,
10200                    2
10201                );
10202                assert_eq!(
10203                    cold.load_session(&child.id)
10204                        .await
10205                        .unwrap()
10206                        .unwrap()
10207                        .last_run_status()
10208                        .as_deref(),
10209                    Some("completed")
10210                );
10211            } else {
10212                assert!(
10213                    recovered.is_err(),
10214                    "matching canonical history is not proof commitment"
10215                );
10216            }
10217            assert_eq!(
10218                serde_json::to_value(
10219                    cold.load_session(&child.id)
10220                        .await
10221                        .unwrap()
10222                        .unwrap()
10223                        .messages
10224                )
10225                .unwrap(),
10226                serde_json::to_value(&child.messages).unwrap(),
10227            );
10228        }
10229    }
10230
10231    #[tokio::test]
10232    async fn broker_receipt_confirms_canonical_worker_error_and_cancelled_status() {
10233        for (terminal_status, status) in [
10234            (TerminalStatus::Error, "error"),
10235            (TerminalStatus::Cancelled, "cancelled"),
10236        ] {
10237            let runner = ActorChildRunner::new(
10238                "test".into(),
10239                PathBuf::new(),
10240                vec![],
10241                PathBuf::new(),
10242                ExecutorSpec::BambooRuntime,
10243                vec![],
10244                "test".into(),
10245                1,
10246            );
10247            let home = tempfile::tempdir().unwrap();
10248            let store = Arc::new(
10249                bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
10250                    .await
10251                    .unwrap(),
10252            );
10253            let root = Session::new("failed-ack-parent", "model");
10254            store.save_session(&root).await.unwrap();
10255            let mut child = Session::new_child_of("failed-ack-child", &root, "model", "task");
10256            child.add_message(bamboo_agent_core::Message::user("work"));
10257            store.save_session(&child).await.unwrap();
10258            child.set_last_run_status(status);
10259            child.set_last_run_error(format!("worker {status}"));
10260            store
10261                .prepare_broker_terminal_receipt(
10262                    &child,
10263                    "run-1",
10264                    "00000000-0000-4000-8000-000000000001",
10265                    "p-failed-ack-child",
10266                    "broker-run-1",
10267                    &["broker-outcome-1".into()],
10268                )
10269                .await
10270                .unwrap();
10271            store.save_session(&child).await.unwrap();
10272            runner.set_actor_directory_store(Some(store));
10273            let calls = Arc::new(AtomicUsize::new(0));
10274            insert_ack_probe(&runner, &child, &calls, terminal_status).await;
10275            runner
10276                .confirm_durable_child_delivery(&child, "run-1", true)
10277                .await
10278                .unwrap();
10279            assert_eq!(calls.load(Ordering::SeqCst), 1, "{status}");
10280        }
10281    }
10282
10283    #[tokio::test]
10284    async fn broker_completed_terminal_projects_only_host_verified_nested_wait() {
10285        assert_eq!(
10286            effective_broker_terminal_status(TerminalStatus::Completed, true).unwrap(),
10287            "suspended"
10288        );
10289        for raw in [TerminalStatus::Error, TerminalStatus::Cancelled] {
10290            assert!(effective_broker_terminal_status(raw, true).is_err());
10291        }
10292        let runner = ActorChildRunner::new(
10293            "test".into(),
10294            PathBuf::new(),
10295            vec![],
10296            PathBuf::new(),
10297            ExecutorSpec::BambooRuntime,
10298            vec![],
10299            "test".into(),
10300            1,
10301        );
10302        let home = tempfile::tempdir().unwrap();
10303        let store = Arc::new(
10304            bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
10305                .await
10306                .unwrap(),
10307        );
10308        let root = Session::new("nested-ack-parent", "model");
10309        store.save_session(&root).await.unwrap();
10310        let mut child = Session::new_child_of("nested-ack-child", &root, "model", "task");
10311        child.add_message(bamboo_agent_core::Message::user("spawn a grandchild"));
10312        store.save_session(&child).await.unwrap();
10313        child.set_last_run_status("suspended");
10314        store
10315            .prepare_broker_terminal_receipt(
10316                &child,
10317                "run-1",
10318                "00000000-0000-4000-8000-000000000001",
10319                "p-nested-ack-child",
10320                "broker-run-1",
10321                &["broker-outcome-1".into()],
10322            )
10323            .await
10324            .unwrap();
10325        store.save_session(&child).await.unwrap();
10326        runner.set_actor_directory_store(Some(store.clone()));
10327        let calls = Arc::new(AtomicUsize::new(0));
10328
10329        insert_ack_probe(&runner, &child, &calls, TerminalStatus::Completed).await;
10330        runner
10331            .confirm_durable_child_delivery(&child, "run-1", true)
10332            .await
10333            .unwrap();
10334        assert_eq!(calls.load(Ordering::SeqCst), 0);
10335
10336        insert_ack_probe_with_nested_wait(&runner, &child, &calls, TerminalStatus::Completed, true)
10337            .await;
10338        runner
10339            .confirm_durable_child_delivery(&child, "run-1", true)
10340            .await
10341            .unwrap();
10342        assert_eq!(calls.load(Ordering::SeqCst), 1);
10343        assert_eq!(
10344            store
10345                .load_session(&child.id)
10346                .await
10347                .unwrap()
10348                .unwrap()
10349                .last_run_status()
10350                .as_deref(),
10351            Some("suspended")
10352        );
10353        assert!(store
10354            .recover_broker_terminal_receipts(&child)
10355            .await
10356            .unwrap()
10357            .is_empty());
10358    }
10359
10360    #[tokio::test]
10361    async fn host_completion_notifies_once_only_after_reply_and_finish_commit() {
10362        struct CommittedObserver {
10363            authority: PathBuf,
10364            events: std::sync::Mutex<Vec<PublicActorEvent>>,
10365        }
10366        impl ActorEventObserver for CommittedObserver {
10367            fn publish(&self, event: PublicActorEvent) {
10368                // Observe the real durable sidecar synchronously at publication.
10369                let entry: bamboo_domain::ActorDirectoryEntry =
10370                    serde_json::from_slice(&std::fs::read(&self.authority).unwrap()).unwrap();
10371                let committed = entry.activation.unwrap();
10372                assert_eq!(
10373                    committed.status,
10374                    bamboo_domain::ActorActivationStatus::Succeeded
10375                );
10376                assert_eq!(committed.activation_id, event.activation_id);
10377                assert!(committed.finished_at.is_some());
10378                self.events.lock().unwrap().push(event);
10379            }
10380        }
10381        struct CompletionLink(VecDeque<ChildFrame>);
10382        #[async_trait]
10383        impl bamboo_subagent::ChildLink for CompletionLink {
10384            async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
10385                Ok(())
10386            }
10387            async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
10388                Ok(self.0.pop_front())
10389            }
10390        }
10391        for cut in [
10392            "success",
10393            "no_terminal",
10394            "invalid_terminal",
10395            "stale",
10396            "storage_failure",
10397            "append_failure",
10398        ] {
10399            let temp = tempfile::tempdir().unwrap();
10400            let store = Arc::new(
10401                bamboo_storage::SessionStoreV2::new(temp.path().into())
10402                    .await
10403                    .unwrap(),
10404            );
10405            let root = Session::new("notify-root", "model");
10406            store.save_session(&root).await.unwrap();
10407            let mut child = Session::new_child_of("notify-child", &root, "model", "assignment");
10408            store.save_session(&child).await.unwrap();
10409            let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
10410                store.clone(),
10411                bamboo_domain::SessionInboxLimits::default(),
10412            ));
10413            let binding = actor_binding(
10414                store.clone(),
10415                inbox,
10416                Arc::new(bamboo_storage::LockedSessionStore::new(store.clone())),
10417            );
10418            let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
10419            bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
10420            let activation = PlainActorActivation::start(
10421                store.clone(),
10422                &child,
10423                &binding,
10424                Some("notify-run"),
10425                Some(policy),
10426                false,
10427                None,
10428            )
10429            .await
10430            .unwrap();
10431            let _owner = binding
10432                .router
10433                .register_run(&child.id, "notify-run")
10434                .await
10435                .unwrap();
10436            let batch = ActorEventBatch {
10437                logical_session: Some(LogicalSessionIdentity {
10438                    session_id: child.id.clone(),
10439                    parent_session_id: child.parent_session_id.clone(),
10440                    root_session_id: child.root_session_id.clone(),
10441                    creation: None,
10442                }),
10443                activation_id: Some("notify-run".into()),
10444                execution_epoch: 9,
10445                source_node_id: None,
10446                source_actor_id: Some("notify-worker".into()),
10447                first_seq: 1,
10448                last_seq: 2,
10449                qos: bamboo_subagent::ActorEventQos::Durable,
10450                events: vec![
10451                    serde_json::to_value(AgentEvent::Token {
10452                        content: "PRIVATE_PROVIDER_TOKEN".into(),
10453                    })
10454                    .unwrap(),
10455                    serde_json::to_value(AgentEvent::message_appended(
10456                        &child.id,
10457                        &bamboo_agent_core::Message::assistant("PRIVATE_WORKER_CACHE", None),
10458                    ))
10459                    .unwrap(),
10460                ],
10461            };
10462            let mut frames = VecDeque::from([ChildFrame::EventBatch { batch }]);
10463            if cut != "no_terminal" {
10464                let mut terminal = completed_actor_frame();
10465                if cut == "invalid_terminal" {
10466                    if let ChildFrame::Terminal { status, .. } = &mut terminal {
10467                        *status = TerminalStatus::Suspended;
10468                    }
10469                }
10470                frames.push_back(terminal);
10471            }
10472            let mut link = CompletionLink(frames);
10473            let (tx, mut rx) = mpsc::channel(8);
10474            let (_live, mut live_rx) = mpsc::unbounded_channel();
10475            let (_delivery, mut delivery_rx) = mpsc::unbounded_channel();
10476            let cancel = CancellationToken::new();
10477            let outcome = drive(ActorDriveContext {
10478                client: &mut link,
10479                parent_session_id: "notify-root",
10480                child_session_id: "notify-child",
10481                child_attempt: 0,
10482                approval_registry: None,
10483                approval_decider: None,
10484                approval_reviewer: None,
10485                escalation_bridge: None,
10486                event_tx: &tx,
10487                cancel_token: &cancel,
10488                live_rx: &mut live_rx,
10489                delivery_rx: &mut delivery_rx,
10490                logical_session: &mut child,
10491                expected_permission_posture: None,
10492                expected_creation: None,
10493                session_inbox_runtime: Some(&binding),
10494                actor_directory_store: Some(store.as_ref()),
10495                canonical_subagent_tool: None,
10496                ticket_service: None,
10497                activation_run_id: Some("notify-run"),
10498                execution_epoch: 9,
10499                expected_source_actor_id: "notify-worker",
10500                initial_inflight_claims: VecDeque::new(),
10501                plain_actor: true,
10502                remote_environment_lease: false,
10503                readonly_output: None,
10504                local_history_tools: None,
10505                local_history_read_only: false,
10506                strict_history_output: None,
10507                plain_input: Some(&activation),
10508                canonical_activation: None,
10509                canonical_placement_ref: None,
10510                actor_event_observer: None,
10511                plain_run: None,
10512                first_frame_timeout: Some(Duration::from_secs(1)),
10513            })
10514            .await;
10515            let admitted = !matches!(cut, "no_terminal" | "invalid_terminal");
10516            assert_eq!(outcome.is_ok(), admitted, "{cut}: {outcome:?}");
10517            assert_eq!(
10518                activation.completion_router.lock().unwrap().is_some(),
10519                admitted,
10520                "{cut}"
10521            );
10522            while rx.try_recv().is_ok() {}
10523            let authority = temp
10524                .path()
10525                .join(store.resolve_rel_path(&child.id).await.unwrap())
10526                .join("actor-authority.json");
10527            let observer = CommittedObserver {
10528                authority: authority.clone(),
10529                events: Default::default(),
10530            };
10531            if cut == "append_failure" {
10532                child.add_message(bamboo_agent_core::Message::user("uncommitted-prefix"));
10533                assert!(activation
10534                    .append_reply(&mut child, "PRIVATE_REPLY".into(), &tx)
10535                    .await
10536                    .is_err());
10537                activation
10538                    .finish(ActorActivationFinish::Failed)
10539                    .await
10540                    .unwrap();
10541                assert!(observer.events.lock().unwrap().is_empty());
10542                continue;
10543            }
10544            activation
10545                .append_reply(&mut child, "PRIVATE_REPLY".into(), &tx)
10546                .await
10547                .unwrap();
10548            assert!(
10549                rx.try_recv().is_ok(),
10550                "legacy content notification retained"
10551            );
10552            assert!(
10553                observer.events.lock().unwrap().is_empty(),
10554                "reply alone must not publish"
10555            );
10556            if cut == "stale" {
10557                store
10558                    .retire_actor(&child.id, chrono::Utc::now())
10559                    .await
10560                    .unwrap();
10561            } else if cut == "storage_failure" {
10562                // Make the canonical write path unreadable, rather than mocking
10563                // the store or allowing completion to fall back to ordinary save.
10564                std::fs::remove_file(&authority).unwrap();
10565                std::fs::create_dir(&authority).unwrap();
10566            }
10567            let result = activation
10568                .finish_with_observer(ActorActivationFinish::Succeeded, Some(&observer))
10569                .await;
10570            assert_eq!(
10571                result.is_ok(),
10572                matches!(cut, "success" | "no_terminal" | "invalid_terminal"),
10573                "{cut}"
10574            );
10575            let events = observer.events.lock().unwrap().clone();
10576            assert_eq!(events.len(), usize::from(cut == "success"), "{cut}");
10577            if let Some(event) = events.first() {
10578                assert_eq!(event.actor_id, child.id);
10579                assert_eq!(event.root_actor_id, root.id);
10580                assert_eq!(event.parent_actor_id, Some(root.id.clone()));
10581                assert_eq!(event.source_order.sequence, 3);
10582                let json = serde_json::to_string(event).unwrap();
10583                assert!(!json.contains("PRIVATE_") && !json.contains("notify-run"));
10584                let persisted = store.load_session(&child.id).await.unwrap().unwrap();
10585                assert_eq!(persisted.messages.last().unwrap().content, "PRIVATE_REPLY");
10586            }
10587            assert!(activation
10588                .finish_with_observer(ActorActivationFinish::Succeeded, Some(&observer))
10589                .await
10590                .is_err());
10591            assert_eq!(
10592                observer.events.lock().unwrap().len(),
10593                events.len(),
10594                "repeat finish cannot publish"
10595            );
10596        }
10597    }
10598
10599    async fn bind_local_control_plane(
10600        store: &bamboo_storage::SessionStoreV2,
10601        actor_id: &str,
10602        policy: &bamboo_tools::permission::PermissionConfig,
10603    ) {
10604        store
10605            .bind_control_plane(
10606                actor_id,
10607                bamboo_domain::ActorControlPlaneBinding {
10608                    policy_revision: Some(policy.policy_revision()),
10609                    placement_intent: Some(bamboo_domain::ActorPlacementIntent::Local),
10610                },
10611            )
10612            .await
10613            .unwrap();
10614    }
10615
10616    fn local_tool_sample(
10617        host: &Session,
10618        denied_write: bool,
10619    ) -> (LocalToolCollector, Vec<bamboo_agent_core::Message>) {
10620        local_tool_sample_with_reasoning(host, denied_write, None)
10621    }
10622
10623    fn local_tool_sample_with_reasoning(
10624        host: &Session,
10625        denied_write: bool,
10626        reasoning: Option<&str>,
10627    ) -> (LocalToolCollector, Vec<bamboo_agent_core::Message>) {
10628        use bamboo_agent_core::{FunctionCall, Message, ToolCall};
10629        let calls = ["Read", "Write"]
10630            .map(|name| ToolCall {
10631                id: format!("local-{name}"),
10632                tool_type: "function".into(),
10633                function: FunctionCall {
10634                    name: name.into(),
10635                    arguments: "{\"file_path\":\"answer.txt\"}".into(),
10636                },
10637            })
10638            .to_vec();
10639        let mut rows = vec![Message::assistant("checking", Some(calls))];
10640        rows[0].reasoning = reasoning.map(str::to_owned);
10641        let mut collector = LocalToolCollector::default();
10642        if let Some(reasoning) = reasoning {
10643            assert!(collector
10644                .event(
10645                    &serde_json::to_value(AgentEvent::ReasoningToken {
10646                        content: reasoning.into(),
10647                    })
10648                    .unwrap()
10649                )
10650                .unwrap());
10651        }
10652        for name in ["Read", "Write"] {
10653            let denied = name == "Write" && denied_write;
10654            let id = format!("local-{name}");
10655            if !denied {
10656                collector
10657                    .event(
10658                        &serde_json::to_value(AgentEvent::ToolStart {
10659                            tool_call_id: id.clone(),
10660                            tool_name: name.into(),
10661                            arguments: serde_json::json!({"file_path":"answer.txt"}),
10662                        })
10663                        .unwrap(),
10664                    )
10665                    .unwrap();
10666            }
10667            let text = if denied {
10668                "Error: native_tool_ceiling_denied: Write"
10669            } else {
10670                "actual file result"
10671            };
10672            let mut message = Message::tool_result_with_status(id.clone(), text, !denied);
10673            message.metadata = Some(serde_json::json!({"elapsed_ms":7,"tool_name":name,
10674                "is_mutating":name == "Write","auto_approved":name != "Write","success":!denied}));
10675            rows.push(message);
10676            let event = if denied {
10677                AgentEvent::ToolError {
10678                    tool_call_id: id,
10679                    error: "native_tool_ceiling_denied: Write".into(),
10680                }
10681            } else {
10682                AgentEvent::ToolComplete {
10683                    tool_call_id: id,
10684                    result: bamboo_agent_core::tools::ToolResult::text(true, text),
10685                }
10686            };
10687            collector
10688                .event(&serde_json::to_value(event).unwrap())
10689                .unwrap();
10690        }
10691        rows.push(Message::assistant("complete exact report", None));
10692        rows.last_mut().unwrap().reasoning = reasoning.map(str::to_owned);
10693        if let Some(reasoning) = reasoning {
10694            assert!(collector
10695                .event(
10696                    &serde_json::to_value(AgentEvent::ReasoningToken {
10697                        content: reasoning.into(),
10698                    })
10699                    .unwrap()
10700                )
10701                .unwrap());
10702        }
10703        let mut messages = host.messages.clone();
10704        if let Some(system) = messages
10705            .iter_mut()
10706            .find(|message| message.role == Role::System)
10707        {
10708            system.content = "worker-private system never imported".into();
10709        }
10710        messages.extend(rows.iter().cloned());
10711        let data = bamboo_subagent::proto::LocalToolMessages::Complete {
10712            version: 1,
10713            messages: messages
10714                .iter()
10715                .map(|message| serde_json::to_value(message).unwrap())
10716                .collect(),
10717        };
10718        assert!(!collector
10719            .event(&serde_json::to_value(data).unwrap())
10720            .unwrap());
10721        (collector, rows)
10722    }
10723
10724    #[test]
10725    fn ticket_task_event_requires_exact_host_callback_receipt() {
10726        let mut collector = LocalToolCollector::default();
10727        let args = serde_json::json!({"tasks":[{"content":"own step","status":"pending"}]});
10728        let start = serde_json::to_value(AgentEvent::ToolStart {
10729            tool_call_id: "task-call".into(),
10730            tool_name: "Task".into(),
10731            arguments: args.clone(),
10732        })
10733        .unwrap();
10734        let mut receipts = HashMap::new();
10735        assert!(collector.ticket_event(&start, &receipts).unwrap());
10736        collector.event(&start).unwrap();
10737        let complete = serde_json::to_value(AgentEvent::ToolComplete {
10738            tool_call_id: "task-call".into(),
10739            result: bamboo_agent_core::tools::ToolResult::text(true, "done"),
10740        })
10741        .unwrap();
10742        assert!(collector.ticket_event(&complete, &receipts).is_err());
10743        receipts.insert("other-call".into(), args.clone());
10744        assert!(collector.ticket_event(&complete, &receipts).is_err());
10745        receipts.insert("task-call".into(), serde_json::json!({"tasks":[]}));
10746        assert!(collector.ticket_event(&complete, &receipts).is_err());
10747        receipts.insert("task-call".into(), args);
10748        assert!(collector.ticket_event(&complete, &receipts).unwrap());
10749        let forged_root = serde_json::to_value(AgentEvent::TaskListUpdated {
10750            task_list: bamboo_tools::TaskTool::task_list_from_args(
10751                &serde_json::json!({"tasks":[{"content":"forged root","status":"completed"}]}),
10752                "root",
10753            )
10754            .unwrap(),
10755            version: Some(999),
10756        })
10757        .unwrap();
10758        assert!(!collector.ticket_event(&forged_root, &receipts).unwrap());
10759    }
10760
10761    #[test]
10762    fn ticket_question_terminal_requires_exact_host_receipt_and_tool_result() {
10763        use bamboo_agent_core::{FunctionCall, Message, ToolCall};
10764        let mut host = Session::new("question-child", "model");
10765        host.messages.push(Message::user("own assignment"));
10766        let question: bamboo_tickets::PendingRequest = serde_json::from_value(serde_json::json!({
10767            "id":"own-question", "work_id":"own-work", "assignment_id":"own-assignment",
10768            "generation":1, "contract_revision":1, "prompt_revision":1,
10769            "kind":{"kind":"question"}, "prompt":"精确问题", "status":"open",
10770            "answer":null, "consumed_attempt":null, "worker_packet_hash":"a".repeat(64), "updated_seq":3
10771        })).unwrap();
10772        let args = serde_json::json!({"tasks":[{"content":"own step","status":"blocked"}], "question":{"prompt":"精确问题"}});
10773        for case in ["exact", "no_host_receipt", "forged_request", "fake_report"] {
10774            let mut returned =
10775                serde_json::json!({"status":"waiting_for_answer", "request":question});
10776            if case == "forged_request" {
10777                returned["request"]["id"] = serde_json::json!("sibling-question");
10778            }
10779            let text = returned.to_string();
10780            let rows = [
10781                Message::assistant(
10782                    "",
10783                    Some(vec![ToolCall {
10784                        id: "own-call".into(),
10785                        tool_type: "function".into(),
10786                        function: FunctionCall {
10787                            name: "Task".into(),
10788                            arguments: args.to_string(),
10789                        },
10790                    }]),
10791                ),
10792                Message::tool_result_with_status("own-call", &text, true),
10793            ];
10794            let collector = LocalToolCollector {
10795                messages: Some(host.messages.iter().cloned().chain(rows).collect()),
10796                starts: HashMap::from([("own-call".into(), ("Task".into(), args.clone()))]),
10797                outcomes: HashMap::from([("own-call".into(), (true, text))]),
10798                ticket_yield: (case != "no_host_receipt")
10799                    .then(|| ("own-call".into(), question.clone())),
10800            };
10801            let terminal = if case == "fake_report" {
10802                Some("fake delivery")
10803            } else {
10804                Some("")
10805            };
10806            assert_eq!(
10807                collector
10808                    .suffix(&host, &["Task".into()], false, terminal)
10809                    .is_ok(),
10810                case == "exact",
10811                "{case}"
10812            );
10813        }
10814    }
10815
10816    #[test]
10817    fn local_tool_suffix_preserves_full_current_prefix_and_rejects_authority_or_collisions() {
10818        let mut host = Session::new("tool-history-prefix", "model");
10819        host.messages
10820            .push(bamboo_agent_core::Message::user("assignment"));
10821        let mut confirmed = bamboo_agent_core::Message::user("confirmed during Run");
10822        confirmed.metadata = Some(
10823            serde_json::json!({"session_message":{"id":confirmed.id,"target_session_id":host.id}}),
10824        );
10825        host.messages.push(confirmed);
10826        let tools = vec!["Read".into(), "Write".into()];
10827        let (good, rows) = local_tool_sample(&host, false);
10828        assert_eq!(
10829            serde_json::to_value(
10830                good.suffix(&host, &tools, false, Some("complete exact report"))
10831                    .unwrap()
10832            )
10833            .unwrap(),
10834            serde_json::to_value(&rows).unwrap()
10835        );
10836        for case in [
10837            "missing",
10838            "changed",
10839            "metadata",
10840            "system",
10841            "user",
10842            "collision",
10843            "dangling",
10844            "result",
10845            "terminal",
10846            "tool_metadata",
10847            "grant",
10848            "start_arguments",
10849            "ledger",
10850        ] {
10851            let (mut collector, _) = local_tool_sample(&host, false);
10852            let mut current = host.clone();
10853            let messages = collector.messages.as_mut().unwrap();
10854            match case {
10855                "missing" => {
10856                    messages.remove(host.messages.len() - 1);
10857                }
10858                "changed" => messages[0].content.push('!'),
10859                "metadata" => messages[1].metadata = None,
10860                "system" => messages.last_mut().unwrap().role = Role::System,
10861                "user" => messages.last_mut().unwrap().role = Role::User,
10862                "collision" => messages.last_mut().unwrap().id = host.messages[0].id.clone(),
10863                "dangling" => {
10864                    messages[host.messages.len() + 1].tool_call_id = Some("foreign".into())
10865                }
10866                "result" => messages[host.messages.len() + 1].content.push('!'),
10867                "tool_metadata" => {
10868                    messages[host.messages.len() + 1].metadata.as_mut().unwrap()["permission"] =
10869                        true.into()
10870                }
10871                "grant" => {
10872                    messages[host.messages.len()].tool_calls.as_mut().unwrap()[0]
10873                        .function
10874                        .name = "Bash".into()
10875                }
10876                "start_arguments" => {
10877                    collector.starts.get_mut("local-Read").unwrap().1 =
10878                        serde_json::json!({"file_path":"different.txt"})
10879                }
10880                "ledger" => {
10881                    current.append_provider_transcript_group(current.messages[0].id.clone(), None, vec![
10882                        bamboo_domain::session::provider_transcript::ProviderTranscriptItem::try_from_payload(
10883                            bamboo_domain::ProviderFamily::OpenAi,
10884                            bamboo_domain::ProviderProtocol::OpenAiResponsesV1,
10885                            bamboo_domain::session::provider_transcript::ProviderTranscriptOrigin::HostToolSearch,
10886                            bamboo_domain::session::provider_transcript::ProviderTranscriptAuthor::ToolResult,
10887                            serde_json::json!({"type":"tool_search_output","execution":"client","call_id":"search","status":"completed","tools":[]}),
10888                        ).unwrap(),
10889                    ]).unwrap();
10890                }
10891                "terminal" => {}
10892                _ => unreachable!(),
10893            }
10894            let terminal = if case == "terminal" {
10895                "different"
10896            } else {
10897                "complete exact report"
10898            };
10899            assert!(
10900                collector
10901                    .suffix(&current, &tools, false, Some(terminal))
10902                    .is_err(),
10903                "{case}"
10904            );
10905        }
10906        let (denied, _) = local_tool_sample(&host, true);
10907        assert!(denied
10908            .suffix(&host, &["Read".into()], true, Some("complete exact report"))
10909            .is_ok());
10910        let (mut contradicted, _) = local_tool_sample(&host, true);
10911        contradicted.starts.insert(
10912            "local-Write".into(),
10913            ("Read".into(), serde_json::json!({"file_path":"answer.txt"})),
10914        );
10915        assert!(contradicted
10916            .suffix(&host, &["Read".into()], true, Some("complete exact report"))
10917            .is_err());
10918        let (successful, _) = local_tool_sample(&host, false);
10919        assert!(successful
10920            .suffix(&host, &["Read".into()], true, Some("complete exact report"))
10921            .is_err());
10922        let mut empty = LocalToolCollector::default();
10923        assert!(empty
10924            .event(
10925                &serde_json::to_value(AgentEvent::ReasoningToken {
10926                    content: "private".into()
10927                })
10928                .unwrap()
10929            )
10930            .unwrap());
10931        assert!(
10932            empty
10933                .suffix(&host, &tools, false, Some("complete exact report"))
10934                .is_err(),
10935            "stream observations alone cannot commit history"
10936        );
10937        let (mut completed, _) = local_tool_sample(&host, false);
10938        assert!(completed.event(&serde_json::json!({"type":"local_client_tool_messages_v1","version":1,"messages":[]})).is_err());
10939    }
10940
10941    struct LocalToolConcurrentCheckpoint {
10942        real: SessionInboxRuntimeBinding,
10943        claim: SessionInboxClaim,
10944    }
10945    #[async_trait]
10946    impl RuntimeSessionPersistence for LocalToolConcurrentCheckpoint {
10947        async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
10948            self.real.persistence.save_runtime_session(session).await
10949        }
10950        async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
10951            // A real canonical admission wins after validation, before the
10952            // final append-safe checkpoint acquires its serialization lock.
10953            let mut latest = self.real.storage.load_session(&session.id).await?.unwrap();
10954            checkpoint_and_ack_canonical_claim(&self.real, &mut latest, &self.claim, None)
10955                .await
10956                .map_err(|_| std::io::Error::other("concurrent admission failed"))?;
10957            self.real
10958                .persistence
10959                .checkpoint_runtime_session(session)
10960                .await
10961        }
10962        async fn load_runtime_session(&self, id: &str) -> std::io::Result<Option<Session>> {
10963            self.real.storage.load_session(id).await
10964        }
10965    }
10966
10967    #[tokio::test]
10968    async fn local_tool_history_checkpoint_preserves_confirmed_input_and_cold_pairs_or_refuses() {
10969        for case in [
10970            "success",
10971            "reasoning",
10972            "concurrent_input",
10973            "checkpoint_error",
10974            "stale_prefix",
10975            "cancelled",
10976            "lost_owner",
10977        ] {
10978            let temp = tempfile::tempdir().unwrap();
10979            let store = Arc::new(
10980                bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
10981                    .await
10982                    .unwrap(),
10983            );
10984            let root = Session::new("local-tool-root", "model");
10985            store.save_session(&root).await.unwrap();
10986            let mut child = Session::new_child("local-tool-child", &root.id, "model", "child");
10987            let mut system = bamboo_agent_core::Message::user("Host-owned immutable System");
10988            system.role = Role::System;
10989            child.messages.push(system);
10990            child
10991                .messages
10992                .push(bamboo_agent_core::Message::user("assignment"));
10993            store.save_session(&child).await.unwrap();
10994            let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
10995                store.clone(),
10996                bamboo_domain::SessionInboxLimits::default(),
10997            ));
10998            let locked = Arc::new(bamboo_storage::LockedSessionStore::new(store.clone()));
10999            let binding = actor_binding(store.clone(), inbox.clone(), locked.clone());
11000            let mut owner = Some(
11001                binding
11002                    .router
11003                    .register_run(&child.id, "current-run")
11004                    .await
11005                    .unwrap(),
11006            );
11007            let envelope = bamboo_domain::SessionMessageEnvelope::user_input(
11008                &child.id,
11009                "current confirmed User",
11010            );
11011            let receipt = inbox.deliver(&envelope).await.unwrap();
11012            inbox
11013                .mark_activation_eligible(
11014                    &child.id,
11015                    receipt.generation,
11016                    bamboo_domain::SessionActivationPolicy::RespectSpecificWait,
11017                )
11018                .await
11019                .unwrap();
11020            let claim = inbox.claim(&child.id, 1).await.unwrap().remove(0);
11021            checkpoint_and_ack_canonical_claim(&binding, &mut child, &claim, None)
11022                .await
11023                .unwrap();
11024            assert!(inbox.was_admitted(&child.id, &envelope.id).await.unwrap());
11025            let before = serde_json::to_value(&child).unwrap();
11026            let reasoning = (case == "reasoning")
11027                .then_some("Complete plain reasoning before Read and the final reply 🪷.");
11028            let (collector, rows) = local_tool_sample_with_reasoning(&child, false, reasoning);
11029            let mut binding = binding;
11030            let late = bamboo_domain::SessionMessageEnvelope::user_input(
11031                &child.id,
11032                "concurrent confirmed User",
11033            );
11034            if case == "concurrent_input" {
11035                let receipt = inbox.deliver(&late).await.unwrap();
11036                inbox
11037                    .mark_activation_eligible(
11038                        &child.id,
11039                        receipt.generation,
11040                        bamboo_domain::SessionActivationPolicy::RespectSpecificWait,
11041                    )
11042                    .await
11043                    .unwrap();
11044                let late_claim = inbox.claim(&child.id, 1).await.unwrap().remove(0);
11045                binding.persistence = Arc::new(LocalToolConcurrentCheckpoint {
11046                    real: actor_binding(store.clone(), inbox.clone(), locked.clone()),
11047                    claim: late_claim,
11048                });
11049            }
11050            if case == "checkpoint_error" {
11051                binding.persistence = Arc::new(ActorFaultingPersistence {
11052                    inner: locked.clone(),
11053                    fail_checkpoint_once: AtomicBool::new(true),
11054                });
11055            }
11056            if case == "stale_prefix" {
11057                let mut latest = child.clone();
11058                latest
11059                    .messages
11060                    .push(bamboo_agent_core::Message::user("new confirmed prefix"));
11061                store.save_session(&latest).await.unwrap();
11062            }
11063            let cancel = CancellationToken::new();
11064            if case == "cancelled" {
11065                cancel.cancel();
11066            }
11067            if case == "lost_owner" {
11068                owner.take().unwrap().abandon().await;
11069            }
11070            let durable_before =
11071                serde_json::to_value(store.load_session(&child.id).await.unwrap()).unwrap();
11072            let result = commit_local_tool_history(
11073                collector,
11074                &mut child,
11075                &binding,
11076                "current-run",
11077                (&["Read".into(), "Write".into()], false),
11078                LocalToolTerminal::Completed(Some("complete exact report")),
11079                &cancel,
11080            )
11081            .await;
11082            let cold = bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
11083                .await
11084                .unwrap()
11085                .load_session(&child.id)
11086                .await
11087                .unwrap()
11088                .unwrap();
11089            if !matches!(case, "success" | "reasoning" | "concurrent_input") {
11090                assert!(result.is_err(), "{case}");
11091                assert_eq!(
11092                    serde_json::to_value(&child).unwrap(),
11093                    before,
11094                    "{case}: no partial mutable success"
11095                );
11096                assert_eq!(
11097                    serde_json::to_value(Some(&cold)).unwrap(),
11098                    durable_before,
11099                    "{case}: no write"
11100                );
11101                continue;
11102            }
11103            result.unwrap();
11104            assert_eq!(
11105                serde_json::to_value(&cold.messages[..3]).unwrap(),
11106                before["messages"]
11107            );
11108            let suffix_start = if case == "concurrent_input" {
11109                assert!(inbox.was_admitted(&child.id, &late.id).await.unwrap());
11110                assert_eq!(
11111                    serde_json::to_value(&cold.messages[3]).unwrap(),
11112                    serde_json::to_value(late.to_provider_message().unwrap()).unwrap()
11113                );
11114                assert!(child
11115                    .messages
11116                    .iter()
11117                    .any(|message| message.id == late.id.as_str()));
11118                4
11119            } else {
11120                3
11121            };
11122            assert_eq!(
11123                serde_json::to_value(&cold.messages[suffix_start..]).unwrap(),
11124                serde_json::to_value(rows).unwrap()
11125            );
11126            assert_eq!(
11127                cold.messages
11128                    .iter()
11129                    .filter(|message| message.id == envelope.id.as_str())
11130                    .count(),
11131                1
11132            );
11133            assert_eq!(
11134                cold.messages
11135                    .iter()
11136                    .filter(|message| message.content == "complete exact report")
11137                    .count(),
11138                1
11139            );
11140            assert_eq!(cold.metadata, child.metadata);
11141            assert!(cold.provider_transcript.is_empty());
11142        }
11143    }
11144
11145    #[tokio::test]
11146    async fn local_tool_completion_requires_current_ordered_batch_and_never_falls_back_to_text() {
11147        for case in [
11148            "legacy",
11149            "missing",
11150            "gap",
11151            "duplicate",
11152            "epoch",
11153            "source",
11154            "birth",
11155            "qos",
11156            "ticket_forged_success",
11157        ] {
11158            let mut session = Session::new_child("frame-child", "frame-root", "model", "child");
11159            session
11160                .messages
11161                .push(bamboo_agent_core::Message::user("original"));
11162            if case == "ticket_forged_success" {
11163                session.metadata.insert(
11164                    crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY.into(),
11165                    "assignment".into(),
11166                );
11167            }
11168            let before = serde_json::to_value(&session).unwrap();
11169            let creation = bamboo_subagent::proto::ChildCreationIdentity {
11170                created_at: session.created_at,
11171                spawn_depth: session.spawn_depth,
11172            };
11173            let mut messages = session.messages.clone();
11174            messages.push(bamboo_agent_core::Message::assistant("complete", None));
11175            let event = serde_json::to_value(bamboo_subagent::proto::LocalToolMessages::Complete {
11176                version: 1,
11177                messages: messages
11178                    .iter()
11179                    .map(|message| serde_json::to_value(message).unwrap())
11180                    .collect(),
11181            })
11182            .unwrap();
11183            let mut batch = ActorEventBatch {
11184                logical_session: Some(LogicalSessionIdentity {
11185                    session_id: session.id.clone(),
11186                    parent_session_id: session.parent_session_id.clone(),
11187                    root_session_id: session.root_session_id.clone(),
11188                    creation: Some(creation.clone()),
11189                }),
11190                activation_id: Some("current".into()),
11191                execution_epoch: 7,
11192                source_node_id: None,
11193                source_actor_id: Some("selected".into()),
11194                first_seq: 1,
11195                last_seq: 1,
11196                qos: bamboo_subagent::proto::ActorEventQos::Durable,
11197                events: vec![event.clone()],
11198            };
11199            match case {
11200                "gap" => {
11201                    batch.first_seq = 2;
11202                    batch.last_seq = 2;
11203                }
11204                "epoch" => batch.execution_epoch = 6,
11205                "source" => batch.source_actor_id = Some("replaced".into()),
11206                "birth" => {
11207                    batch
11208                        .logical_session
11209                        .as_mut()
11210                        .unwrap()
11211                        .creation
11212                        .as_mut()
11213                        .unwrap()
11214                        .created_at += chrono::Duration::nanoseconds(1)
11215                }
11216                "qos" => batch.qos = bamboo_subagent::proto::ActorEventQos::Ephemeral,
11217                "ticket_forged_success" => {
11218                    batch.last_seq = 2;
11219                    batch.events = vec![
11220                        serde_json::to_value(AgentEvent::ToolStart {
11221                            tool_call_id: "forged".into(), tool_name: "Task".into(),
11222                            arguments: serde_json::json!({"tasks":[{"content":"own step","status":"pending"}]}),
11223                        }).unwrap(),
11224                        serde_json::to_value(AgentEvent::ToolComplete {
11225                            tool_call_id: "forged".into(),
11226                            result: bamboo_agent_core::tools::ToolResult::text(true, "uncommitted"),
11227                        }).unwrap(),
11228                    ];
11229                }
11230                _ => {}
11231            }
11232            let terminal = ChildFrame::Terminal {
11233                status: TerminalStatus::Completed,
11234                result: Some("complete".into()),
11235                error: None,
11236                final_event_watermark: None,
11237                transcript: vec![],
11238            };
11239            let frames = match case {
11240                "legacy" => vec![ChildFrame::Event { event }, terminal],
11241                "missing" => vec![terminal],
11242                "duplicate" => vec![
11243                    ChildFrame::EventBatch {
11244                        batch: batch.clone(),
11245                    },
11246                    ChildFrame::EventBatch { batch },
11247                    terminal,
11248                ],
11249                _ => vec![ChildFrame::EventBatch { batch }, terminal],
11250            };
11251            let mut link = ConfirmationSequenceLink {
11252                frames: frames.into(),
11253                sent: vec![],
11254            };
11255            let (tx, mut rx) = mpsc::channel(32);
11256            let (_live, mut live_rx) = mpsc::unbounded_channel();
11257            let (_delivery, mut delivery_rx) = mpsc::unbounded_channel();
11258            let cancel = CancellationToken::new();
11259            let tools = vec![if case == "ticket_forged_success" {
11260                "Task"
11261            } else {
11262                "Read"
11263            }
11264            .into()];
11265            let outcome = drive(ActorDriveContext {
11266                client: &mut link,
11267                parent_session_id: "frame-root",
11268                child_session_id: "frame-child",
11269                child_attempt: 0,
11270                approval_registry: None,
11271                approval_decider: None,
11272                approval_reviewer: None,
11273                escalation_bridge: None,
11274                event_tx: &tx,
11275                cancel_token: &cancel,
11276                live_rx: &mut live_rx,
11277                delivery_rx: &mut delivery_rx,
11278                logical_session: &mut session,
11279                expected_permission_posture: None,
11280                expected_creation: Some(&creation),
11281                session_inbox_runtime: None,
11282                actor_directory_store: None,
11283                canonical_subagent_tool: None,
11284                ticket_service: None,
11285                activation_run_id: Some("current"),
11286                execution_epoch: 7,
11287                expected_source_actor_id: "selected",
11288                initial_inflight_claims: vec![].into(),
11289                plain_actor: false,
11290                remote_environment_lease: false,
11291                readonly_output: None,
11292                local_history_tools: Some(&tools),
11293                local_history_read_only: true,
11294                strict_history_output: None,
11295                plain_input: None,
11296                canonical_activation: None,
11297                canonical_placement_ref: None,
11298                actor_event_observer: None,
11299                plain_run: None,
11300                first_frame_timeout: Some(Duration::from_secs(1)),
11301            })
11302            .await;
11303            assert!(outcome.is_err(), "{case}: no last-text success fallback");
11304            if case == "ticket_forged_success" {
11305                assert!(outcome
11306                    .unwrap_err()
11307                    .to_string()
11308                    .contains("no exact Host receipt"));
11309                assert!(matches!(
11310                    rx.try_recv().unwrap(),
11311                    AgentEvent::ToolStart { .. }
11312                ));
11313            }
11314            assert_eq!(serde_json::to_value(&session).unwrap(), before, "{case}");
11315            assert!(link.sent.is_empty());
11316            assert!(rx.try_recv().is_err(), "DATA must never enter public feed");
11317        }
11318    }
11319
11320    struct HistoryTerminalProbe {
11321        frames: VecDeque<ChildFrame>,
11322        accepted: Option<TerminalStatus>,
11323        acks: usize,
11324    }
11325
11326    #[async_trait]
11327    impl bamboo_subagent::ChildLink for HistoryTerminalProbe {
11328        async fn send(&mut self, _frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
11329            Ok(())
11330        }
11331        async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
11332            Ok(self.frames.pop_front())
11333        }
11334        fn accept_durable_terminal(&mut self, status: TerminalStatus) {
11335            self.accepted = Some(status);
11336        }
11337        fn durable_delivery_receipt(&self) -> Option<bamboo_subagent::DurableChildDeliveryReceipt> {
11338            Some(bamboo_subagent::DurableChildDeliveryReceipt {
11339                broker_identity: "fixture".into(),
11340                parent_mailbox: "parent".into(),
11341                correlation_id: "current".into(),
11342                message_ids: vec!["tail".into(), "terminal".into()],
11343                terminal_status: self.accepted?,
11344            })
11345        }
11346        async fn acknowledge_durable_frames(&mut self) -> bamboo_subagent::TransportResult<()> {
11347            self.acks += 1;
11348            Ok(())
11349        }
11350    }
11351
11352    struct CancelConcurrentInputCheckpoint {
11353        real: SessionInboxRuntimeBinding,
11354        envelope: bamboo_domain::SessionMessageEnvelope,
11355    }
11356    #[async_trait]
11357    impl RuntimeSessionPersistence for CancelConcurrentInputCheckpoint {
11358        async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
11359            self.real.persistence.save_runtime_session(session).await
11360        }
11361        async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
11362            let receipt = self
11363                .real
11364                .inbox
11365                .deliver(&self.envelope)
11366                .await
11367                .map_err(|_| std::io::Error::other("concurrent delivery failed"))?;
11368            self.real
11369                .inbox
11370                .mark_activation_eligible(
11371                    &session.id,
11372                    receipt.generation,
11373                    bamboo_domain::SessionActivationPolicy::RespectSpecificWait,
11374                )
11375                .await
11376                .map_err(|_| std::io::Error::other("concurrent activation failed"))?;
11377            let claim = self
11378                .real
11379                .inbox
11380                .claim(&session.id, 1)
11381                .await
11382                .map_err(|_| std::io::Error::other("concurrent claim failed"))?
11383                .remove(0);
11384            let mut latest = self.real.storage.load_session(&session.id).await?.unwrap();
11385            checkpoint_and_ack_canonical_claim(&self.real, &mut latest, &claim, None)
11386                .await
11387                .map_err(|_| std::io::Error::other("concurrent admission failed"))?;
11388            self.real
11389                .persistence
11390                .checkpoint_runtime_session(session)
11391                .await
11392        }
11393        async fn load_runtime_session(&self, id: &str) -> std::io::Result<Option<Session>> {
11394            self.real.storage.load_session(id).await
11395        }
11396    }
11397
11398    struct CancelHistoryProbe {
11399        inner: HistoryTerminalProbe,
11400        cancel_requested: bool,
11401    }
11402
11403    #[async_trait]
11404    impl bamboo_subagent::ChildLink for CancelHistoryProbe {
11405        async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
11406            self.cancel_requested |= matches!(frame, ParentFrame::Cancel);
11407            Ok(())
11408        }
11409        async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
11410            if !self.cancel_requested {
11411                std::future::pending().await
11412            } else {
11413                self.inner.next_frame().await
11414            }
11415        }
11416        fn accept_durable_terminal(&mut self, status: TerminalStatus) {
11417            self.inner.accept_durable_terminal(status);
11418        }
11419        fn durable_delivery_receipt(&self) -> Option<bamboo_subagent::DurableChildDeliveryReceipt> {
11420            self.inner.durable_delivery_receipt()
11421        }
11422        async fn acknowledge_durable_frames(&mut self) -> bamboo_subagent::TransportResult<()> {
11423            self.inner.acknowledge_durable_frames().await
11424        }
11425    }
11426
11427    #[tokio::test]
11428    async fn selected_cancelled_read_history_requires_checkpointed_complete_prefix() {
11429        use bamboo_subagent::ChildLink;
11430        for case in [
11431            "success",
11432            "empty",
11433            "missing",
11434            "unclosed",
11435            "unobserved",
11436            "prefix",
11437            "metadata",
11438            "write",
11439            "not_read_only",
11440            "watermark",
11441            "epoch",
11442            "birth",
11443            "permission",
11444            "inbox",
11445            "pending",
11446            "pending_eligible",
11447            "claimed",
11448            "inspect_error",
11449            "checkpoint_error",
11450            "lost_owner",
11451            "concurrent_input",
11452        ] {
11453            let temp = tempfile::tempdir().unwrap();
11454            let store = Arc::new(
11455                bamboo_storage::SessionStoreV2::new(temp.path().into())
11456                    .await
11457                    .unwrap(),
11458            );
11459            let root = Session::new("cancel-history-root", "model");
11460            store.save_session(&root).await.unwrap();
11461            let mut child = Session::new_child("cancel-history-child", &root.id, "model", "child");
11462            child
11463                .messages
11464                .push(bamboo_agent_core::Message::user("exact assignment"));
11465            child.metadata.insert(
11466                bamboo_subagent::proto::WORKFLOW_USAGE_REQUESTED_KEY.into(),
11467                "true".into(),
11468            );
11469            store.save_session(&child).await.unwrap();
11470            let before = serde_json::to_value(&child.messages).unwrap();
11471            let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
11472                store.clone(),
11473                bamboo_domain::SessionInboxLimits::default(),
11474            ));
11475            let locked = Arc::new(bamboo_storage::LockedSessionStore::new(store.clone()));
11476            let mut binding = actor_binding(store.clone(), inbox.clone(), locked.clone());
11477            let mut owner = Some(
11478                binding
11479                    .router
11480                    .register_run(&child.id, "current")
11481                    .await
11482                    .unwrap(),
11483            );
11484            let creation = bamboo_subagent::proto::ChildCreationIdentity {
11485                created_at: child.created_at,
11486                spawn_depth: child.spawn_depth,
11487            };
11488            let identity = actor_event_logical_identity(&child, &root.id, Some(&creation));
11489            let tool = if case == "write" { "Write" } else { "Read" };
11490            let arguments = serde_json::json!({"file_path":"assigned.txt"});
11491            let call = bamboo_agent_core::ToolCall {
11492                id: "cancel-read".into(),
11493                tool_type: "function".into(),
11494                function: bamboo_agent_core::FunctionCall {
11495                    name: tool.into(),
11496                    arguments: arguments.to_string(),
11497                },
11498            };
11499            let mut rows = child.messages.clone();
11500            let mut events = Vec::new();
11501            if case != "empty" {
11502                rows.push(bamboo_agent_core::Message::assistant("", Some(vec![call])));
11503                events.push(
11504                    serde_json::to_value(AgentEvent::ToolStart {
11505                        tool_call_id: "cancel-read".into(),
11506                        tool_name: tool.into(),
11507                        arguments,
11508                    })
11509                    .unwrap(),
11510                );
11511                if case != "unclosed" {
11512                    let mut result = bamboo_agent_core::Message::tool_result_with_status(
11513                        "cancel-read",
11514                        "exact Read result",
11515                        true,
11516                    );
11517                    if case == "metadata" {
11518                        result.metadata = Some(serde_json::json!({"permission":true}));
11519                    }
11520                    rows.push(result);
11521                    events.push(
11522                        serde_json::to_value(AgentEvent::ToolComplete {
11523                            tool_call_id: "cancel-read".into(),
11524                            result: bamboo_agent_core::tools::ToolResult::text(
11525                                true,
11526                                "exact Read result",
11527                            ),
11528                        })
11529                        .unwrap(),
11530                    );
11531                }
11532            }
11533            if case == "unobserved" {
11534                events.clear();
11535            }
11536            if case == "prefix" {
11537                rows[0].content.push('!');
11538            }
11539            events.push(
11540                serde_json::to_value(bamboo_subagent::proto::LocalToolMessages::Complete {
11541                    version: 1,
11542                    messages: rows
11543                        .iter()
11544                        .map(|row| serde_json::to_value(row).unwrap())
11545                        .collect(),
11546                })
11547                .unwrap(),
11548            );
11549            if case == "missing" {
11550                events.clear();
11551            }
11552            let final_seq = events.len() as u64;
11553            let mut watermark = bamboo_subagent::ActorEventWatermark {
11554                version: 1,
11555                logical_session: Some(identity.clone()),
11556                activation_id: Some("current".into()),
11557                execution_epoch: 7,
11558                final_seq,
11559            };
11560            match case {
11561                "watermark" => watermark.final_seq += 1,
11562                "epoch" => watermark.execution_epoch -= 1,
11563                "birth" => {
11564                    watermark
11565                        .logical_session
11566                        .as_mut()
11567                        .unwrap()
11568                        .creation
11569                        .as_mut()
11570                        .unwrap()
11571                        .created_at += chrono::Duration::nanoseconds(1)
11572                }
11573                _ => {}
11574            }
11575            let mut frames = VecDeque::new();
11576            if !events.is_empty() {
11577                frames.push_back(ChildFrame::EventBatch {
11578                    batch: ActorEventBatch {
11579                        logical_session: Some(identity),
11580                        activation_id: Some("current".into()),
11581                        execution_epoch: 7,
11582                        source_node_id: None,
11583                        source_actor_id: Some("selected".into()),
11584                        first_seq: 1,
11585                        last_seq: final_seq,
11586                        qos: bamboo_subagent::ActorEventQos::Durable,
11587                        events,
11588                    },
11589                });
11590            }
11591            frames.push_back(ChildFrame::Terminal {
11592                status: TerminalStatus::Cancelled,
11593                result: None,
11594                error: None,
11595                transcript: vec![],
11596                final_event_watermark: Some(watermark),
11597            });
11598            let mut claims = VecDeque::new();
11599            let pending_input =
11600                bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "new canonical input");
11601            if matches!(case, "inbox" | "pending" | "pending_eligible" | "claimed") {
11602                let receipt = inbox.deliver(&pending_input).await.unwrap();
11603                if case != "pending" {
11604                    inbox
11605                        .mark_activation_eligible(
11606                            &child.id,
11607                            receipt.generation,
11608                            bamboo_domain::SessionActivationPolicy::RespectSpecificWait,
11609                        )
11610                        .await
11611                        .unwrap();
11612                }
11613                if matches!(case, "inbox" | "claimed") {
11614                    let claim = inbox.claim(&child.id, 1).await.unwrap().remove(0);
11615                    if case == "inbox" {
11616                        claims.push_back(claim);
11617                    }
11618                }
11619            }
11620            if case == "concurrent_input" {
11621                binding.persistence = Arc::new(CancelConcurrentInputCheckpoint {
11622                    real: actor_binding(store.clone(), inbox.clone(), locked.clone()),
11623                    envelope: pending_input.clone(),
11624                });
11625            }
11626            if case == "inspect_error" {
11627                let rel = store.resolve_rel_path(&child.id).await.unwrap();
11628                let dir = store.bamboo_home_dir().join(rel).join("inbox");
11629                std::fs::create_dir_all(&dir).unwrap();
11630                std::fs::create_dir(dir.join(".session-inbox.lock")).unwrap();
11631                assert!(inbox.inspect(&child.id).await.is_err());
11632            }
11633            let backlog_before =
11634                if matches!(case, "inbox" | "pending" | "pending_eligible" | "claimed") {
11635                    Some(inbox.inspect(&child.id).await.unwrap())
11636                } else {
11637                    None
11638                };
11639            if case == "checkpoint_error" {
11640                binding.persistence = Arc::new(ActorFaultingPersistence {
11641                    inner: locked.clone(),
11642                    fail_checkpoint_once: AtomicBool::new(true),
11643                });
11644            }
11645            if case == "lost_owner" {
11646                owner.take().unwrap().abandon().await;
11647            }
11648            let mut link = CancelHistoryProbe {
11649                inner: HistoryTerminalProbe {
11650                    frames,
11651                    accepted: None,
11652                    acks: 0,
11653                },
11654                cancel_requested: false,
11655            };
11656            let (tx, mut rx) = mpsc::channel(32);
11657            let (_live, mut live_rx) = mpsc::unbounded_channel();
11658            let (_delivery, mut delivery_rx) = mpsc::unbounded_channel();
11659            let cancel = CancellationToken::new();
11660            cancel.cancel();
11661            let tools = vec!["Read".into(), "Glob".into()];
11662            let mut strict_history = None;
11663            let outcome = drive(ActorDriveContext {
11664                client: &mut link,
11665                parent_session_id: &root.id,
11666                child_session_id: &child.id.clone(),
11667                child_attempt: 0,
11668                approval_registry: None,
11669                approval_decider: None,
11670                approval_reviewer: None,
11671                escalation_bridge: None,
11672                event_tx: &tx,
11673                cancel_token: &cancel,
11674                live_rx: &mut live_rx,
11675                delivery_rx: &mut delivery_rx,
11676                logical_session: &mut child,
11677                expected_permission_posture: (case == "permission")
11678                    .then(|| expected_default_permission_posture(7)),
11679                expected_creation: Some(&creation),
11680                session_inbox_runtime: Some(&binding),
11681                actor_directory_store: None,
11682                canonical_subagent_tool: None,
11683                ticket_service: None,
11684                activation_run_id: Some("current"),
11685                execution_epoch: 7,
11686                expected_source_actor_id: "selected",
11687                initial_inflight_claims: claims,
11688                plain_actor: false,
11689                remote_environment_lease: false,
11690                readonly_output: None,
11691                local_history_tools: Some(&tools),
11692                local_history_read_only: case != "not_read_only",
11693                strict_history_output: Some(&mut strict_history),
11694                plain_input: None,
11695                canonical_activation: None,
11696                canonical_placement_ref: None,
11697                actor_event_observer: None,
11698                plain_run: None,
11699                first_frame_timeout: None,
11700            })
11701            .await;
11702            let proven = matches!(case, "success" | "empty");
11703            assert_eq!(strict_history.is_some(), proven, "{case}");
11704            assert_eq!(link.durable_delivery_receipt().is_some(), proven, "{case}");
11705            assert_eq!(
11706                link.inner.acks, 0,
11707                "{case}: pump cannot ACK before SDK save/proof"
11708            );
11709            let cold = bamboo_storage::SessionStoreV2::new(temp.path().into())
11710                .await
11711                .unwrap()
11712                .load_session(&child.id)
11713                .await
11714                .unwrap()
11715                .unwrap();
11716            if proven {
11717                assert!(matches!(outcome, Err(AgentError::Cancelled)), "{case}");
11718                assert_eq!(link.inner.accepted, Some(TerminalStatus::Cancelled));
11719                let history = strict_history.unwrap();
11720                assert_eq!(history.proof.execution_epoch(), 7);
11721                assert_eq!(history.proof.contiguous_applied_seq(), final_seq);
11722                assert_eq!(
11723                    serde_json::to_value(&cold.messages).unwrap(),
11724                    serde_json::to_value(&rows).unwrap()
11725                );
11726            } else {
11727                assert!(outcome.is_err(), "{case}");
11728                if case == "concurrent_input" {
11729                    assert!(
11730                        cold.messages
11731                            .iter()
11732                            .any(|message| message.id == pending_input.id.as_str()),
11733                        "new input must survive refusal"
11734                    );
11735                } else {
11736                    assert_eq!(
11737                        serde_json::to_value(&cold.messages).unwrap(),
11738                        before,
11739                        "{case}: no unvalidated history write"
11740                    );
11741                }
11742            }
11743            if let Some(backlog) = backlog_before {
11744                assert!(!inbox
11745                    .was_admitted(&child.id, &pending_input.id)
11746                    .await
11747                    .unwrap());
11748                assert_eq!(
11749                    inbox.inspect(&child.id).await.unwrap(),
11750                    backlog,
11751                    "{case}: proof refusal must neither claim nor ACK the input"
11752                );
11753            }
11754            while let Ok(event) = rx.try_recv() {
11755                assert!(
11756                    !matches!(event, AgentEvent::SessionHistoryCommitted { .. }),
11757                    "private DATA is not a canonical public barrier"
11758                );
11759            }
11760        }
11761    }
11762
11763    #[tokio::test]
11764    async fn strict_terminal_watermark_gates_history_commit_and_durable_acceptance() {
11765        use bamboo_subagent::ChildLink;
11766        for case in [
11767            "success",
11768            "tail",
11769            "short",
11770            "missing",
11771            "version",
11772            "activation",
11773            "epoch",
11774            "birth",
11775            "duplicate",
11776            "overlap",
11777            "reorder",
11778            "midgap",
11779            "qos",
11780            "zero_missing",
11781            "zero_valid",
11782            "error_tail",
11783            "cancelled_tail",
11784            "cancelled_valid",
11785            "cancelled_zero_valid",
11786            "error_valid",
11787            "error_zero_valid",
11788            "ordinary_lossy",
11789        ] {
11790            let temp = tempfile::tempdir().unwrap();
11791            let store = Arc::new(
11792                bamboo_storage::SessionStoreV2::new(temp.path().into())
11793                    .await
11794                    .unwrap(),
11795            );
11796            let root = Session::new("watermark-root", "model");
11797            store.save_session(&root).await.unwrap();
11798            let mut child = Session::new_child("watermark-child", &root.id, "model", "child");
11799            child
11800                .messages
11801                .push(bamboo_agent_core::Message::user("assignment"));
11802            store.save_session(&child).await.unwrap();
11803            let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
11804                store.clone(),
11805                bamboo_domain::SessionInboxLimits::default(),
11806            ));
11807            let locked = Arc::new(bamboo_storage::LockedSessionStore::new(store.clone()));
11808            let binding = actor_binding(store.clone(), inbox, locked);
11809            let _owner = binding
11810                .router
11811                .register_run(&child.id, "current")
11812                .await
11813                .unwrap();
11814            let before = serde_json::to_value(&child).unwrap();
11815            let creation = bamboo_subagent::proto::ChildCreationIdentity {
11816                created_at: child.created_at,
11817                spawn_depth: child.spawn_depth,
11818            };
11819            let identity = actor_event_logical_identity(&child, &root.id, Some(&creation));
11820            let mut rows = child.messages.clone();
11821            rows.push(bamboo_agent_core::Message::assistant("complete", None));
11822            let data = serde_json::to_value(bamboo_subagent::proto::LocalToolMessages::Complete {
11823                version: 1,
11824                messages: rows
11825                    .iter()
11826                    .map(|row| serde_json::to_value(row).unwrap())
11827                    .collect(),
11828            })
11829            .unwrap();
11830            let batch = |seq, event, qos| ActorEventBatch {
11831                logical_session: Some(identity.clone()),
11832                activation_id: Some("current".into()),
11833                execution_epoch: 7,
11834                source_node_id: None,
11835                source_actor_id: Some("selected".into()),
11836                first_seq: seq,
11837                last_seq: seq,
11838                qos,
11839                events: vec![event],
11840            };
11841            let durable = bamboo_subagent::ActorEventQos::Durable;
11842            let token = serde_json::json!({"type":"token", "content":"observation"});
11843            let mut frames = vec![ChildFrame::EventBatch {
11844                batch: batch(1, data.clone(), durable),
11845            }];
11846            let mut marker = bamboo_subagent::ActorEventWatermark {
11847                version: 1,
11848                logical_session: Some(identity.clone()),
11849                activation_id: Some("current".into()),
11850                execution_epoch: 7,
11851                final_seq: 1,
11852            };
11853            match case {
11854                "tail" | "error_tail" | "cancelled_tail" => marker.final_seq = 2,
11855                "short" => marker.final_seq = 0,
11856                "version" => marker.version = 99,
11857                "activation" => marker.activation_id = Some("old".into()),
11858                "epoch" => marker.execution_epoch = 6,
11859                "birth" => {
11860                    marker
11861                        .logical_session
11862                        .as_mut()
11863                        .unwrap()
11864                        .creation
11865                        .as_mut()
11866                        .unwrap()
11867                        .created_at += chrono::Duration::nanoseconds(1)
11868                }
11869                "duplicate" => frames.push(frames[0].clone()),
11870                "overlap" | "midgap" | "qos" => {
11871                    frames = vec![
11872                        ChildFrame::EventBatch {
11873                            batch: batch(
11874                                1,
11875                                token.clone(),
11876                                if case == "qos" {
11877                                    bamboo_subagent::ActorEventQos::Ephemeral
11878                                } else {
11879                                    durable
11880                                },
11881                            ),
11882                        },
11883                        ChildFrame::EventBatch {
11884                            batch: batch(
11885                                if case == "overlap" {
11886                                    1
11887                                } else if case == "midgap" {
11888                                    3
11889                                } else {
11890                                    2
11891                                },
11892                                data,
11893                                durable,
11894                            ),
11895                        },
11896                    ];
11897                    marker.final_seq = 2;
11898                }
11899                "reorder" => {
11900                    frames = vec![
11901                        ChildFrame::EventBatch {
11902                            batch: batch(2, data, durable),
11903                        },
11904                        ChildFrame::EventBatch {
11905                            batch: batch(1, token, durable),
11906                        },
11907                    ]
11908                }
11909                "zero_missing" | "zero_valid" | "error_zero_valid" | "cancelled_zero_valid" => {
11910                    frames.clear();
11911                    marker.final_seq = 0;
11912                }
11913                "ordinary_lossy" => {
11914                    frames = vec![ChildFrame::EventBatch {
11915                        batch: batch(3, token, bamboo_subagent::ActorEventQos::Ephemeral),
11916                    }]
11917                }
11918                _ => {}
11919            }
11920            let status = match case {
11921                "error_tail" | "error_valid" | "error_zero_valid" => TerminalStatus::Error,
11922                "cancelled_tail" | "cancelled_valid" | "cancelled_zero_valid" => {
11923                    TerminalStatus::Cancelled
11924                }
11925                _ => TerminalStatus::Completed,
11926            };
11927            frames.push(ChildFrame::Terminal {
11928                status,
11929                result: Some("complete".into()),
11930                error: Some("worker failed".into()),
11931                transcript: vec![],
11932                final_event_watermark: (!matches!(
11933                    case,
11934                    "missing" | "zero_missing" | "ordinary_lossy"
11935                ))
11936                .then_some(marker),
11937            });
11938            let mut link = HistoryTerminalProbe {
11939                frames: frames.into(),
11940                accepted: None,
11941                acks: 0,
11942            };
11943            let (tx, mut rx) = mpsc::channel(32);
11944            let (_live, mut live_rx) = mpsc::unbounded_channel();
11945            let (_delivery, mut delivery_rx) = mpsc::unbounded_channel();
11946            let cancel = CancellationToken::new();
11947            let tools = vec!["Read".into()];
11948            let mut strict_history = None;
11949            let outcome = drive(ActorDriveContext {
11950                client: &mut link,
11951                parent_session_id: &root.id,
11952                child_session_id: "watermark-child",
11953                child_attempt: 0,
11954                approval_registry: None,
11955                approval_decider: None,
11956                approval_reviewer: None,
11957                escalation_bridge: None,
11958                event_tx: &tx,
11959                cancel_token: &cancel,
11960                live_rx: &mut live_rx,
11961                delivery_rx: &mut delivery_rx,
11962                logical_session: &mut child,
11963                expected_permission_posture: None,
11964                expected_creation: Some(&creation),
11965                session_inbox_runtime: Some(&binding),
11966                actor_directory_store: None,
11967                canonical_subagent_tool: None,
11968                ticket_service: None,
11969                activation_run_id: Some("current"),
11970                execution_epoch: 7,
11971                expected_source_actor_id: "selected",
11972                initial_inflight_claims: vec![].into(),
11973                plain_actor: false,
11974                remote_environment_lease: false,
11975                readonly_output: None,
11976                local_history_tools: (case != "ordinary_lossy").then_some(&tools),
11977                local_history_read_only: true,
11978                strict_history_output: Some(&mut strict_history),
11979                plain_input: None,
11980                canonical_activation: None,
11981                canonical_placement_ref: None,
11982                actor_event_observer: None,
11983                plain_run: None,
11984                first_frame_timeout: Some(Duration::from_secs(1)),
11985            })
11986            .await;
11987            assert_eq!(strict_history.is_some(), case == "success", "{case}");
11988            if let Some(history) = strict_history {
11989                assert_eq!(history.proof.execution_epoch(), 7);
11990                assert_eq!(history.proof.contiguous_applied_seq(), 1);
11991                assert_eq!(history.watermark.final_seq, 1);
11992            }
11993            let cold = store.load_session(&child.id).await.unwrap().unwrap();
11994            if matches!(case, "success" | "ordinary_lossy") {
11995                assert_eq!(outcome.unwrap().as_deref(), Some("complete"), "{case}");
11996            } else {
11997                let error = outcome.unwrap_err();
11998                if matches!(case, "cancelled_valid" | "cancelled_zero_valid") {
11999                    assert!(matches!(&error, AgentError::Cancelled), "{case}: {error}");
12000                }
12001                let error = error.to_string();
12002                if !matches!(
12003                    case,
12004                    "duplicate"
12005                        | "overlap"
12006                        | "reorder"
12007                        | "midgap"
12008                        | "qos"
12009                        | "zero_valid"
12010                        | "error_valid"
12011                        | "error_zero_valid"
12012                        | "cancelled_valid"
12013                        | "cancelled_zero_valid"
12014                ) {
12015                    assert!(
12016                        error.contains("actor_history_tail_incomplete"),
12017                        "{case}: {error}"
12018                    );
12019                }
12020            }
12021            if case == "success" {
12022                assert_eq!(
12023                    serde_json::to_value(&cold.messages).unwrap(),
12024                    serde_json::to_value(rows).unwrap()
12025                );
12026            } else {
12027                assert_eq!(
12028                    serde_json::to_value(&cold).unwrap(),
12029                    before,
12030                    "{case}: no cold history commit"
12031                );
12032                assert_eq!(
12033                    serde_json::to_value(&child).unwrap(),
12034                    before,
12035                    "{case}: no mutable history commit"
12036                );
12037            }
12038            let accepted = matches!(case, "success" | "ordinary_lossy");
12039            assert_eq!(link.accepted.is_some(), accepted, "{case}");
12040            assert_eq!(
12041                link.durable_delivery_receipt().is_some(),
12042                accepted,
12043                "{case}"
12044            );
12045            assert_eq!(link.acks, 0, "frame pump never ACKs uncheckpointed history");
12046            while let Ok(event) = rx.try_recv() {
12047                assert!(
12048                    matches!(event, AgentEvent::Token { .. }),
12049                    "typed DATA cannot leak"
12050                );
12051            }
12052        }
12053    }
12054
12055    fn processless_pool_worker(mailbox_id: &str) -> PooledWorker {
12056        PooledWorker {
12057            worker: SpawnedChild::remote(AgentRecord {
12058                agent_id: mailbox_id.to_string(),
12059                role: "test".to_string(),
12060                labels: Vec::new(),
12061                endpoint: "ws://127.0.0.1:1".to_string(),
12062                pid: 0,
12063                version: String::new(),
12064                started_at: chrono::Utc::now(),
12065                lease_expires_at: chrono::Utc::now(),
12066            }),
12067            mailbox_id: mailbox_id.to_string(),
12068            parked_at: None,
12069        }
12070    }
12071
12072    #[test]
12073    fn parked_worker_deadline_is_inclusive_and_ignores_checked_out_workers() {
12074        let now = Instant::now();
12075        let timeout = Duration::from_secs(300);
12076        assert!(!parked_worker_expired(None, now, timeout));
12077        assert!(!parked_worker_expired(
12078            Some(now - timeout + Duration::from_millis(1)),
12079            now,
12080            timeout
12081        ));
12082        assert!(parked_worker_expired(Some(now - timeout), now, timeout));
12083    }
12084
12085    #[test]
12086    fn idle_pool_global_cap_bounds_distinct_fingerprints() {
12087        let mut pool = WorkerPool::new();
12088        pool.insert(
12089            "fingerprint-a".to_string(),
12090            vec![processless_pool_worker("a")],
12091        );
12092        pool.insert(
12093            "fingerprint-b".to_string(),
12094            vec![processless_pool_worker("b")],
12095        );
12096
12097        assert_eq!(
12098            idle_pool_limit_reason(&pool, "fingerprint-c", 4, 2),
12099            Some("pool_global_limit")
12100        );
12101        assert_eq!(idle_pool_limit_reason(&pool, "fingerprint-c", 4, 3), None);
12102        assert_eq!(
12103            idle_pool_limit_reason(&pool, "fingerprint-a", 1, 8),
12104            Some("pool_per_key_limit")
12105        );
12106    }
12107
12108    #[test]
12109    fn pool_sweep_removes_dead_workers_and_empty_fingerprint_buckets() {
12110        let mut pool = WorkerPool::new();
12111        pool.insert(
12112            "fingerprint-a".to_string(),
12113            vec![processless_pool_worker("dead")],
12114        );
12115
12116        let reaped = take_reapable_workers(
12117            &mut pool,
12118            Instant::now(),
12119            Duration::from_secs(POOLED_IDLE_TIMEOUT_SECS),
12120        );
12121
12122        assert!(pool.is_empty());
12123        assert_eq!(reaped.len(), 1);
12124        assert_eq!(reaped[0].1, PoolReapReason::ProcessExited);
12125    }
12126
12127    #[test]
12128    fn actor_preflight_counts_only_current_session_scoped_denies() {
12129        let config = bamboo_tools::permission::PermissionConfig::new();
12130        let secret_matcher = "TOP_SECRET_ACTOR_DENY_MATCHER";
12131        config.deny_scoped_session_permission(
12132            "target-session",
12133            bamboo_tools::permission::PermissionType::ExecuteCommand,
12134            secret_matcher,
12135        );
12136        config.deny_scoped_session_permission(
12137            "other-session",
12138            bamboo_tools::permission::PermissionType::WriteFile,
12139            "/other/**",
12140        );
12141
12142        assert_eq!(
12143            active_scoped_session_deny_count(&config, "target-session"),
12144            1
12145        );
12146        assert_eq!(
12147            active_scoped_session_deny_count(&config, "clean-session"),
12148            0
12149        );
12150        let error = ensure_no_active_scoped_session_denies(&config, "target-session")
12151            .unwrap_err()
12152            .to_string();
12153        assert!(!error.contains(secret_matcher));
12154        ensure_no_active_scoped_session_denies(&config, "clean-session")
12155            .expect("another session's deny must not block this activation");
12156    }
12157
12158    #[test]
12159    fn permission_posture_event_rejects_oversized_executor_mapping() {
12160        let session = Session::new("mapping-bound", "model");
12161        let event = AgentEvent::PermissionPostureActivated {
12162            session_id: session.id.clone(),
12163            policy_revision: 1,
12164            requested_mode: "default".to_string(),
12165            effective_mode: "default".to_string(),
12166            executor_mapping: "x".repeat(bamboo_domain::MAX_PERMISSION_EXECUTOR_MAPPING_CHARS + 1),
12167        };
12168
12169        assert!(permission_posture_seed_from_event(&session, &event)
12170            .unwrap_err()
12171            .contains("executor mapping"));
12172    }
12173
12174    #[test]
12175    fn remote_audit_revision_and_timestamp_fields_cannot_poison_host_audit() {
12176        let mut session = Session::new("host-resigns-audit", "model");
12177        let hostile_timestamp = "9".repeat(1024);
12178        let event: AgentEvent = serde_json::from_value(serde_json::json!({
12179            "type": "permission_posture_activated",
12180            "session_id": session.id.clone(),
12181            "policy_revision": 31,
12182            "requested_mode": "default",
12183            "effective_mode": "default",
12184            "executor_mapping": "codex_exec:approval_policy=never",
12185            "audit_revision": u64::MAX,
12186            "transitioned_at": hostile_timestamp,
12187        }))
12188        .expect("unknown remote audit fields are ignored by the typed event");
12189        let seed = permission_posture_seed_from_event(&session, &event)
12190            .unwrap()
12191            .expect("permission event");
12192
12193        let host_revision =
12194            bamboo_domain::record_permission_audit(&mut session.metadata, &seed, None).unwrap();
12195        let host_audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
12196            .expect("host-generated complete audit");
12197        assert_eq!(host_audit.audit_revision, host_revision);
12198        assert!(host_audit.audit_revision < bamboo_domain::MAX_PERMISSION_AUDIT_REVISION);
12199        assert_ne!(host_audit.transitioned_at, hostile_timestamp);
12200        assert!(chrono::DateTime::parse_from_rfc3339(&host_audit.transitioned_at).is_ok());
12201    }
12202
12203    struct ActorFaultingPersistence {
12204        inner: Arc<bamboo_storage::LockedSessionStore>,
12205        fail_checkpoint_once: std::sync::atomic::AtomicBool,
12206    }
12207
12208    #[async_trait]
12209    impl RuntimeSessionPersistence for ActorFaultingPersistence {
12210        async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
12211            self.inner.merge_save_runtime(session).await
12212        }
12213
12214        async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
12215            if self
12216                .fail_checkpoint_once
12217                .swap(false, std::sync::atomic::Ordering::SeqCst)
12218            {
12219                return Err(std::io::Error::other("injected actor checkpoint failure"));
12220            }
12221            self.inner.checkpoint_runtime_session(session).await
12222        }
12223
12224        async fn load_runtime_session(&self, session_id: &str) -> std::io::Result<Option<Session>> {
12225            self.inner.storage().load_session(session_id).await
12226        }
12227    }
12228
12229    struct ActorFailBeforeAckInbox {
12230        inner: Arc<dyn SessionInboxPort>,
12231        fail_once: std::sync::atomic::AtomicBool,
12232    }
12233
12234    #[async_trait]
12235    impl SessionInboxPort for ActorFailBeforeAckInbox {
12236        async fn deliver(
12237            &self,
12238            envelope: &bamboo_domain::SessionMessageEnvelope,
12239        ) -> Result<bamboo_domain::SessionInboxReceipt, bamboo_domain::SessionInboxError> {
12240            self.inner.deliver(envelope).await
12241        }
12242
12243        async fn mark_activation_eligible(
12244            &self,
12245            target_session_id: &str,
12246            generation: u64,
12247            policy: bamboo_domain::SessionActivationPolicy,
12248        ) -> Result<(), bamboo_domain::SessionInboxError> {
12249            self.inner
12250                .mark_activation_eligible(target_session_id, generation, policy)
12251                .await
12252        }
12253
12254        async fn claim(
12255            &self,
12256            target_session_id: &str,
12257            limit: usize,
12258        ) -> Result<Vec<SessionInboxClaim>, bamboo_domain::SessionInboxError> {
12259            self.inner.claim(target_session_id, limit).await
12260        }
12261
12262        async fn was_admitted(
12263            &self,
12264            target_session_id: &str,
12265            id: &bamboo_domain::SessionMessageId,
12266        ) -> Result<bool, bamboo_domain::SessionInboxError> {
12267            self.inner.was_admitted(target_session_id, id).await
12268        }
12269
12270        async fn ack(
12271            &self,
12272            target_session_id: &str,
12273            claim: &SessionInboxClaim,
12274        ) -> Result<(), bamboo_domain::SessionInboxError> {
12275            if self
12276                .fail_once
12277                .swap(false, std::sync::atomic::Ordering::SeqCst)
12278            {
12279                return Err(bamboo_domain::SessionInboxError::Storage(
12280                    "injected actor pre-ack failure".to_string(),
12281                ));
12282            }
12283            self.inner.ack(target_session_id, claim).await
12284        }
12285
12286        async fn inspect(
12287            &self,
12288            target_session_id: &str,
12289        ) -> Result<bamboo_domain::SessionInboxBacklog, bamboo_domain::SessionInboxError> {
12290            self.inner.inspect(target_session_id).await
12291        }
12292    }
12293
12294    async fn actor_inbox_fixture(
12295        session_id: &str,
12296    ) -> (
12297        tempfile::TempDir,
12298        Arc<bamboo_storage::SessionStoreV2>,
12299        Arc<bamboo_storage::LockedSessionStore>,
12300        Arc<dyn SessionInboxPort>,
12301        Session,
12302        SessionInboxClaim,
12303    ) {
12304        let temp = tempfile::tempdir().unwrap();
12305        let store = Arc::new(
12306            bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
12307                .await
12308                .unwrap(),
12309        );
12310        let storage: Arc<dyn Storage> = store.clone();
12311        let locked = Arc::new(bamboo_storage::LockedSessionStore::new(storage));
12312        let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12313            store.clone(),
12314            bamboo_domain::SessionInboxLimits::default(),
12315        ));
12316        let session = Session::new(session_id, "model");
12317        store.save_session(&session).await.unwrap();
12318        let mut envelope =
12319            bamboo_domain::SessionMessageEnvelope::user_input(session_id, "actor follow-up");
12320        envelope.id =
12321            bamboo_domain::SessionMessageId::parse(format!("{session_id}-message")).unwrap();
12322        let receipt = inbox.deliver(&envelope).await.unwrap();
12323        inbox
12324            .mark_activation_eligible(
12325                session_id,
12326                receipt.generation,
12327                bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
12328            )
12329            .await
12330            .unwrap();
12331        let claim = inbox.claim(session_id, 1).await.unwrap().remove(0);
12332        (temp, store, locked, inbox, session, claim)
12333    }
12334
12335    fn actor_binding(
12336        store: Arc<bamboo_storage::SessionStoreV2>,
12337        inbox: Arc<dyn SessionInboxPort>,
12338        persistence: Arc<dyn RuntimeSessionPersistence>,
12339    ) -> SessionInboxRuntimeBinding {
12340        let storage: Arc<dyn Storage> = store;
12341        SessionInboxRuntimeBinding {
12342            router: SessionActivationRouter::new(),
12343            inbox,
12344            storage,
12345            persistence,
12346            parent_question_lock: None,
12347        }
12348    }
12349
12350    #[tokio::test]
12351    async fn remote_claim_seed_rejects_unsupported_inbox_before_dispatch() {
12352        let (_temp, store, locked, inbox, mut session, claim) =
12353            actor_inbox_fixture("unsupported-remote-inbox").await;
12354        let wrapped: Arc<dyn SessionInboxPort> = Arc::new(ActorFailBeforeAckInbox {
12355            inner: inbox.clone(),
12356            fail_once: std::sync::atomic::AtomicBool::new(false),
12357        });
12358        let binding = actor_binding(store.clone(), wrapped, locked);
12359        let fence = ActorActivationFence {
12360            schema_version: 1,
12361            actor_id: session.id.clone(),
12362            activation_id: "unsupported-inbox-activation".into(),
12363            attempt: 1,
12364            run_id: "unsupported-inbox-run".into(),
12365            lease_owner: "unsupported-inbox-host".into(),
12366            lease_epoch: 1,
12367        };
12368        let before = session.clone();
12369
12370        let error = checkpoint_claim_context_before_dispatch(
12371            &binding,
12372            &mut session,
12373            &[claim],
12374            Some((store.as_ref(), &fence)),
12375        )
12376        .await
12377        .unwrap_err();
12378        assert!(error
12379            .to_string()
12380            .contains("requires canonical FileSessionInbox"));
12381        assert_eq!(
12382            serde_json::to_value(&session.messages).unwrap(),
12383            serde_json::to_value(&before.messages).unwrap()
12384        );
12385        assert_eq!(
12386            serde_json::to_value(
12387                &store
12388                    .load_session(&session.id)
12389                    .await
12390                    .unwrap()
12391                    .unwrap()
12392                    .messages
12393            )
12394            .unwrap(),
12395            serde_json::to_value(&before.messages).unwrap()
12396        );
12397        assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12398    }
12399
12400    #[tokio::test]
12401    async fn remote_broker_lineage_rejects_missing_parent_and_project_drift() {
12402        let (_temp, store, locked, inbox, parent, _claim) =
12403            actor_inbox_fixture("remote-parent").await;
12404        let child = Session::new_child_of("remote-child", &parent, "model", "child");
12405        store.save_session(&child).await.unwrap();
12406        let binding = actor_binding(store.clone(), inbox, locked);
12407        let first = remote_canonical_lineage(&binding, &child).await.unwrap();
12408        assert_eq!(first.len(), 2);
12409        let mut changed = child.clone();
12410        changed.set_project_id_meta("different-project");
12411        assert!(remote_canonical_lineage(&binding, &changed).await.is_err());
12412        store.delete_session(&parent.id).await.unwrap();
12413        assert!(remote_canonical_lineage(&binding, &child).await.is_err());
12414    }
12415
12416    #[tokio::test]
12417    async fn spawn_depth_remote_lineage_accepts_existing_tree_above_default() {
12418        let (_temp, store, locked, inbox, root, _claim) =
12419            actor_inbox_fixture("deep-remote-root").await;
12420        let mut parent = root;
12421        for depth in 1..=6 {
12422            let child =
12423                Session::new_child_of(format!("remote-depth-{depth}"), &parent, "model", "child");
12424            store.save_session(&child).await.unwrap();
12425            parent = child;
12426        }
12427        let binding = actor_binding(store, inbox, locked);
12428        let chain = remote_canonical_lineage(&binding, &parent).await.unwrap();
12429        assert_eq!(chain.len(), 7);
12430        assert_eq!(chain.last().unwrap().spawn_depth, 0);
12431    }
12432
12433    #[tokio::test]
12434    async fn child_creation_requires_canonical_host_lifetime_not_cached_candidate() {
12435        let (_temp, store, locked, inbox, parent, _claim) =
12436            actor_inbox_fixture("creation-parent").await;
12437        let mut child = Session::new_child_of("creation-child", &parent, "model", "child");
12438        child.set_project_id_meta("creation-project");
12439        store.save_session(&child).await.unwrap();
12440        let binding = actor_binding(store.clone(), inbox, locked);
12441        let job = SpawnJob {
12442            parent_session_id: parent.id.clone(),
12443            child_session_id: child.id.clone(),
12444            model: "model".into(),
12445            disabled_tools: None,
12446        };
12447        let creation = canonical_child_creation(&binding, &child, &job)
12448            .await
12449            .unwrap();
12450        assert_eq!(creation.created_at, child.created_at);
12451        assert_eq!(creation.spawn_depth, child.spawn_depth);
12452        for damage in 0..7 {
12453            let mut held = child.clone();
12454            match damage {
12455                0 => held.created_at += chrono::Duration::nanoseconds(1),
12456                1 => held.parent_session_id = Some("foreign-parent".into()),
12457                2 => held.root_session_id = "foreign-root".into(),
12458                3 => held.spawn_depth += 1,
12459                4 => held.set_project_id_meta("foreign-project"),
12460                5 => held.kind = bamboo_domain::SessionKind::Root,
12461                _ => held.id = " creation-child ".into(),
12462            }
12463            assert!(
12464                canonical_child_creation(&binding, &held, &job)
12465                    .await
12466                    .is_err(),
12467                "damage {damage}"
12468            );
12469        }
12470        store.delete_session(&child.id).await.unwrap();
12471        assert!(canonical_child_creation(&binding, &child, &job)
12472            .await
12473            .is_err());
12474        let mut replacement = Session::new_child_of(&child.id, &parent, "model", "replacement");
12475        replacement.set_project_id_meta("creation-project");
12476        assert_ne!(replacement.created_at, child.created_at);
12477        store.save_session(&replacement).await.unwrap();
12478        assert!(
12479            canonical_child_creation(&binding, &child, &job)
12480                .await
12481                .is_err(),
12482            "old held spec cannot adopt replacement birth"
12483        );
12484        canonical_child_creation(&binding, &replacement, &job)
12485            .await
12486            .unwrap();
12487    }
12488
12489    #[tokio::test]
12490    async fn actor_immediate_claims_keep_exact_policy_across_mixed_order_ack_and_restart() {
12491        use bamboo_domain::{SessionActivationPolicy, SessionInboxLimits, SessionMessageEnvelope};
12492        for policies in [
12493            [
12494                SessionActivationPolicy::RespectSpecificWait,
12495                SessionActivationPolicy::InterruptSpecificWait,
12496            ],
12497            [
12498                SessionActivationPolicy::InterruptSpecificWait,
12499                SessionActivationPolicy::RespectSpecificWait,
12500            ],
12501        ] {
12502            let temp = tempfile::tempdir().unwrap();
12503            let store = Arc::new(
12504                bamboo_storage::SessionStoreV2::new(temp.path().into())
12505                    .await
12506                    .unwrap(),
12507            );
12508            let session_id = "actor-mixed-intent-policy";
12509            store
12510                .save_session(&Session::new(session_id, "model"))
12511                .await
12512                .unwrap();
12513            let inbox = bamboo_storage::FileSessionInbox::new(store, SessionInboxLimits::default());
12514            let staged = SessionMessageEnvelope::user_input(session_id, "staged sibling");
12515            let staged_receipt = inbox.deliver(&staged).await.unwrap();
12516            let mut sent = Vec::new();
12517            for policy in policies {
12518                let envelope = SessionMessageEnvelope::user_input(session_id, "immediate");
12519                let receipt = inbox
12520                    .deliver_with_activation_intent(&envelope, policy, None)
12521                    .await
12522                    .unwrap();
12523                sent.push((envelope, policy, receipt));
12524            }
12525            let restarted = Arc::new(
12526                bamboo_storage::SessionStoreV2::new(temp.path().into())
12527                    .await
12528                    .unwrap(),
12529            );
12530            let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12531                restarted.clone(),
12532                SessionInboxLimits::default(),
12533            ));
12534            let binding = actor_binding(
12535                restarted.clone(),
12536                inbox.clone(),
12537                Arc::new(bamboo_storage::LockedSessionStore::new(restarted.clone())),
12538            );
12539            let mut session = restarted.load_session(session_id).await.unwrap().unwrap();
12540            let pairs =
12541                claim_canonical_deliveries(&binding, &mut session, "run-1", usize::MAX, None)
12542                    .await
12543                    .unwrap();
12544            assert_eq!(pairs.len(), 2, "the staged sibling has no permission");
12545            for ((claim, delivery), (envelope, policy, receipt)) in pairs.iter().zip(&sent) {
12546                assert_eq!(claim.envelope.id, envelope.id);
12547                assert_eq!(claim.generation, receipt.generation);
12548                assert_eq!(claim.activation_policy, *policy);
12549                assert_eq!(delivery.activation_policy, *policy);
12550                checkpoint_and_ack_canonical_claim(&binding, &mut session, claim, None)
12551                    .await
12552                    .unwrap();
12553            }
12554            let restarted = Arc::new(
12555                bamboo_storage::SessionStoreV2::new(temp.path().into())
12556                    .await
12557                    .unwrap(),
12558            );
12559            let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12560                restarted.clone(),
12561                SessionInboxLimits::default(),
12562            ));
12563            let binding = actor_binding(
12564                restarted.clone(),
12565                inbox.clone(),
12566                Arc::new(bamboo_storage::LockedSessionStore::new(restarted.clone())),
12567            );
12568            session = restarted.load_session(session_id).await.unwrap().unwrap();
12569            for (envelope, policy, receipt) in &sent {
12570                assert_eq!(
12571                    inbox
12572                        .deliver_with_activation_intent(envelope, *policy, None)
12573                        .await
12574                        .unwrap(),
12575                    *receipt
12576                );
12577            }
12578            assert!(
12579                claim_canonical_deliveries(&binding, &mut session, "run-2", usize::MAX, None)
12580                    .await
12581                    .unwrap()
12582                    .is_empty()
12583            );
12584            inbox
12585                .mark_activation_eligible(
12586                    session_id,
12587                    staged_receipt.generation,
12588                    SessionActivationPolicy::RespectSpecificWait,
12589                )
12590                .await
12591                .unwrap();
12592            let pairs =
12593                claim_canonical_deliveries(&binding, &mut session, "run-2", usize::MAX, None)
12594                    .await
12595                    .unwrap();
12596            assert_eq!(pairs.len(), 1);
12597            assert_eq!(pairs[0].0.envelope.id, staged.id);
12598            assert_eq!(
12599                pairs[0].1.activation_policy,
12600                SessionActivationPolicy::RespectSpecificWait
12601            );
12602        }
12603    }
12604
12605    #[tokio::test]
12606    async fn actor_mismatched_typed_marker_id_collision_never_acks() {
12607        let (_temp, store, locked, inbox, mut session, claim) =
12608            actor_inbox_fixture("actor-live-id-collision").await;
12609        let mut forged = claim.envelope.to_provider_message().unwrap();
12610        forged.metadata = Some(serde_json::json!({
12611            "session_message": {
12612                "id": claim.envelope.id,
12613                "target_session_id": "different-session"
12614            }
12615        }));
12616        session.add_message(forged);
12617        let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12618        let binding = actor_binding(store, inbox.clone(), persistence);
12619
12620        assert!(
12621            checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12622                .await
12623                .is_err()
12624        );
12625        assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12626        assert!(!inbox
12627            .was_admitted(&session.id, &claim.envelope.id)
12628            .await
12629            .unwrap());
12630    }
12631
12632    #[tokio::test]
12633    async fn actor_concurrent_durable_id_collision_after_claim_never_acks() {
12634        let (_temp, store, locked, inbox, mut session, claim) =
12635            actor_inbox_fixture("actor-durable-id-collision").await;
12636        let mut concurrent = store.load_session(&session.id).await.unwrap().unwrap();
12637        let mut forged = bamboo_agent_core::Message::user("concurrent actor collision");
12638        forged.id = claim.envelope.id.to_string();
12639        concurrent.add_message(forged);
12640        store.save_session(&concurrent).await.unwrap();
12641        let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12642        let binding = actor_binding(store.clone(), inbox.clone(), persistence);
12643
12644        assert!(
12645            checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12646                .await
12647                .is_err()
12648        );
12649        assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12650        assert!(!inbox
12651            .was_admitted(&session.id, &claim.envelope.id)
12652            .await
12653            .unwrap());
12654        let durable = store.load_session(&session.id).await.unwrap().unwrap();
12655        assert!(!durable.messages.iter().any(|message| {
12656            bamboo_domain::is_matching_session_message(message, &claim.envelope)
12657        }));
12658    }
12659
12660    #[tokio::test]
12661    async fn actor_concurrent_durable_typed_body_mismatch_never_acks() {
12662        let (_temp, store, locked, inbox, mut session, claim) =
12663            actor_inbox_fixture("actor-durable-typed-body-collision").await;
12664        let mut different = claim.envelope.clone();
12665        different.body = bamboo_domain::SessionMessageBody::Content(
12666            bamboo_domain::SessionMessageContent::text("forged actor body"),
12667        );
12668        let mut concurrent = store.load_session(&session.id).await.unwrap().unwrap();
12669        concurrent.add_message(different.to_provider_message().unwrap());
12670        store.save_session(&concurrent).await.unwrap();
12671        let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12672        let binding = actor_binding(store.clone(), inbox.clone(), persistence);
12673
12674        assert!(
12675            checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12676                .await
12677                .is_err()
12678        );
12679        assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12680        assert!(!inbox
12681            .was_admitted(&session.id, &claim.envelope.id)
12682            .await
12683            .unwrap());
12684        let durable = store.load_session(&session.id).await.unwrap().unwrap();
12685        assert!(!durable
12686            .messages
12687            .iter()
12688            .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope)));
12689    }
12690
12691    #[tokio::test]
12692    async fn actor_checkpoint_failure_rolls_back_and_restart_admits_once() {
12693        let (_temp, store, locked, inbox, mut session, claim) =
12694            actor_inbox_fixture("actor-checkpoint-failure").await;
12695        let envelope_id = claim.envelope.id.clone();
12696        let fault: Arc<dyn RuntimeSessionPersistence> = Arc::new(ActorFaultingPersistence {
12697            inner: locked.clone(),
12698            fail_checkpoint_once: std::sync::atomic::AtomicBool::new(true),
12699        });
12700        let binding = actor_binding(store.clone(), inbox.clone(), fault);
12701
12702        assert!(
12703            checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12704                .await
12705                .is_err()
12706        );
12707        assert!(!session
12708            .messages
12709            .iter()
12710            .any(|message| message.id == envelope_id.as_str()));
12711        assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12712        assert!(!inbox.was_admitted(&session.id, &envelope_id).await.unwrap());
12713
12714        let reopened: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12715            store.clone(),
12716            bamboo_domain::SessionInboxLimits::default(),
12717        ));
12718        let recovered = reopened.claim(&session.id, 1).await.unwrap().remove(0);
12719        let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12720        let binding = actor_binding(store.clone(), reopened.clone(), persistence);
12721        let mut restarted = store.load_session(&session.id).await.unwrap().unwrap();
12722        checkpoint_and_ack_canonical_claim(&binding, &mut restarted, &recovered, None)
12723            .await
12724            .unwrap();
12725        assert_eq!(
12726            restarted
12727                .messages
12728                .iter()
12729                .filter(|message| message.id == envelope_id.as_str())
12730                .count(),
12731            1
12732        );
12733        assert!(reopened
12734            .was_admitted(&session.id, &envelope_id)
12735            .await
12736            .unwrap());
12737        let backlog = reopened.inspect(&session.id).await.unwrap();
12738        assert_eq!(backlog.pending + backlog.claimed, 0);
12739    }
12740
12741    #[tokio::test]
12742    async fn actor_checkpoint_success_pre_ack_failure_recovers_without_duplicate() {
12743        let (_temp, store, locked, real_inbox, mut session, claim) =
12744            actor_inbox_fixture("actor-pre-ack-failure").await;
12745        let envelope_id = claim.envelope.id.clone();
12746        let faulted: Arc<dyn SessionInboxPort> = Arc::new(ActorFailBeforeAckInbox {
12747            inner: real_inbox.clone(),
12748            fail_once: std::sync::atomic::AtomicBool::new(true),
12749        });
12750        let persistence: Arc<dyn RuntimeSessionPersistence> = locked.clone();
12751        let binding = actor_binding(store.clone(), faulted, persistence);
12752
12753        assert!(
12754            checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12755                .await
12756                .is_err()
12757        );
12758        let durable = store.load_session(&session.id).await.unwrap().unwrap();
12759        assert_eq!(
12760            durable
12761                .messages
12762                .iter()
12763                .filter(|message| message.id == envelope_id.as_str())
12764                .count(),
12765            1
12766        );
12767        assert_eq!(real_inbox.inspect(&session.id).await.unwrap().claimed, 1);
12768        assert!(!real_inbox
12769            .was_admitted(&session.id, &envelope_id)
12770            .await
12771            .unwrap());
12772
12773        let reopened: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12774            store.clone(),
12775            bamboo_domain::SessionInboxLimits::default(),
12776        ));
12777        let recovered = reopened.claim(&session.id, 1).await.unwrap().remove(0);
12778        let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12779        let binding = actor_binding(store.clone(), reopened.clone(), persistence);
12780        let mut restarted = durable;
12781        checkpoint_and_ack_canonical_claim(&binding, &mut restarted, &recovered, None)
12782            .await
12783            .unwrap();
12784        assert_eq!(
12785            restarted
12786                .messages
12787                .iter()
12788                .filter(|message| message.id == envelope_id.as_str())
12789                .count(),
12790            1
12791        );
12792        assert!(reopened
12793            .was_admitted(&session.id, &envelope_id)
12794            .await
12795            .unwrap());
12796        let backlog = reopened.inspect(&session.id).await.unwrap();
12797        assert_eq!(backlog.pending + backlog.claimed, 0);
12798    }
12799
12800    struct ConfirmationSequenceLink {
12801        frames: VecDeque<ChildFrame>,
12802        sent: Vec<ParentFrame>,
12803    }
12804
12805    #[async_trait]
12806    impl bamboo_subagent::ChildLink for ConfirmationSequenceLink {
12807        async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
12808            self.sent.push(frame);
12809            Ok(())
12810        }
12811
12812        async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
12813            match self.frames.pop_front() {
12814                Some(frame) => Ok(Some(frame)),
12815                None => std::future::pending().await,
12816            }
12817        }
12818    }
12819
12820    #[tokio::test]
12821    async fn failed_actor_retry_checkpoints_new_input_and_rejects_old_or_stale_state() {
12822        for case in [
12823            "accepted",
12824            "readonly-refusal",
12825            "prefix-refusal",
12826            "old-claim",
12827            "old-checkpoint",
12828            "succeeded",
12829            "changed-birth",
12830            "oversize",
12831        ] {
12832            let temp = tempfile::tempdir().unwrap();
12833            let store = Arc::new(
12834                bamboo_storage::SessionStoreV2::new(temp.path().into())
12835                    .await
12836                    .unwrap(),
12837            );
12838            let parent = Session::new("retry-parent", "model");
12839            store.save_session(&parent).await.unwrap();
12840            let mut child =
12841                Session::new_child_of("retry-child", &parent, "model", "original assignment");
12842            child.add_message(bamboo_agent_core::Message::user("original assignment"));
12843            store.save_session(&child).await.unwrap();
12844            let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
12845            bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
12846            let locked = Arc::new(bamboo_storage::LockedSessionStore::new(store.clone()));
12847            let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12848                store.clone(),
12849                bamboo_domain::SessionInboxLimits::default(),
12850            ));
12851            let binding = actor_binding(store.clone(), inbox, locked);
12852            let registration = binding
12853                .router
12854                .register_run(&child.id, "first-run")
12855                .await
12856                .unwrap();
12857            let first = PlainActorActivation::start(
12858                store.clone(),
12859                &child,
12860                &binding,
12861                Some("first-run"),
12862                Some(policy.clone()),
12863                true,
12864                None,
12865            )
12866            .await
12867            .unwrap();
12868            if matches!(case, "old-claim" | "old-checkpoint") {
12869                let old = bamboo_domain::SessionMessageEnvelope::user_input(
12870                    &child.id,
12871                    "unconfirmed old input",
12872                );
12873                let receipt = first.input_inbox.deliver(&old).await.unwrap();
12874                first
12875                    .input_inbox
12876                    .mark_activation_eligible(
12877                        &child.id,
12878                        receipt.generation,
12879                        bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
12880                    )
12881                    .await
12882                    .unwrap();
12883                let claim = first
12884                    .claim_input(&binding, &child, "first-run")
12885                    .await
12886                    .unwrap()
12887                    .unwrap();
12888                if case == "old-checkpoint" {
12889                    first
12890                        .checkpoint_input(&mut child, "first-run", &claim)
12891                        .await
12892                        .unwrap();
12893                }
12894            }
12895            first
12896                .finish(if case == "succeeded" {
12897                    ActorActivationFinish::Succeeded
12898                } else {
12899                    ActorActivationFinish::Failed
12900                })
12901                .await
12902                .unwrap();
12903            drop(registration);
12904            let envelope = bamboo_domain::SessionMessageEnvelope::user_input(
12905                &child.id,
12906                if case == "oversize" {
12907                    "x".repeat(8193)
12908                } else {
12909                    "NEW_CORRECTION".into()
12910                },
12911            );
12912            let receipt = first.input_inbox.deliver(&envelope).await.unwrap();
12913            first
12914                .input_inbox
12915                .mark_activation_eligible(
12916                    &child.id,
12917                    receipt.generation,
12918                    bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
12919                )
12920                .await
12921                .unwrap();
12922            let before = store.load_session(&child.id).await.unwrap().unwrap();
12923            let expected_messages = serde_json::to_value(&before.messages).unwrap();
12924            child = before.clone();
12925            if case == "prefix-refusal" {
12926                child.messages.last_mut().unwrap().content = "stale cached prefix".into();
12927            }
12928            if case == "changed-birth" {
12929                child.created_at += chrono::Duration::milliseconds(1);
12930            }
12931            let _retry_registration = binding
12932                .router
12933                .register_run(&child.id, "retry-run")
12934                .await
12935                .unwrap();
12936            let result = PlainActorActivation::start_and_prepare(
12937                store.clone(),
12938                &mut child,
12939                &binding,
12940                Some("retry-run"),
12941                Some(policy.clone()),
12942                case != "readonly-refusal",
12943                None,
12944            )
12945            .await;
12946            if case != "accepted" {
12947                assert!(result.is_err(), "{case}: no retry Run may be dispatched");
12948                let actual = store.load_session(&before.id).await.unwrap().unwrap();
12949                assert_eq!(
12950                    serde_json::to_value(&actual.messages).unwrap(),
12951                    expected_messages,
12952                    "{case}: failed preparation preserves Main history"
12953                );
12954                assert!(
12955                    !first
12956                        .input_inbox
12957                        .was_admitted(&before.id, &envelope.id)
12958                        .await
12959                        .unwrap(),
12960                    "{case}: no ACK"
12961                );
12962                if case == "readonly-refusal" {
12963                    let entry = store.inspect_actor(&before.id).await.unwrap();
12964                    assert_eq!(entry.actor.current_attempt, 1);
12965                    assert_eq!(
12966                        entry.activation.unwrap().activation_id,
12967                        first.fence.activation_id
12968                    );
12969                    let backlog = first.input_inbox.inspect(&before.id).await.unwrap();
12970                    assert_eq!((backlog.pending, backlog.claimed), (1, 0));
12971                }
12972                if matches!(case, "prefix-refusal" | "changed-birth" | "oversize") {
12973                    assert_eq!(
12974                        first.input_inbox.inspect(&before.id).await.unwrap().claimed,
12975                        1
12976                    );
12977                    assert_eq!(
12978                        store
12979                            .inspect_actor(&before.id)
12980                            .await
12981                            .unwrap()
12982                            .activation
12983                            .unwrap()
12984                            .status,
12985                        bamboo_domain::ActorActivationStatus::Failed
12986                    );
12987                }
12988                if case == "oversize" {
12989                    let reopened = Arc::new(
12990                        bamboo_storage::SessionStoreV2::new(temp.path().into())
12991                            .await
12992                            .unwrap(),
12993                    );
12994                    let cold_inbox = bamboo_storage::FileSessionInbox::new(
12995                        reopened,
12996                        bamboo_domain::SessionInboxLimits::default(),
12997                    );
12998                    let now = chrono::Utc::now();
12999                    let leases = cold_inbox
13000                        .inspect_owned_leases(&before.id, 2, now)
13001                        .await
13002                        .unwrap();
13003                    assert_eq!(leases.len(), 1);
13004                    assert_eq!(leases[0].failure_count, 1);
13005                    assert_eq!(
13006                        leases[0].last_error_code.as_deref(),
13007                        Some("unsupported_actor_input")
13008                    );
13009                    let retry_after = leases[0].retry_after.unwrap();
13010                    assert!(retry_after > now);
13011                    let readiness = cold_inbox
13012                        .inspect_wake_readiness(&before.id, now)
13013                        .await
13014                        .unwrap();
13015                    assert!(
13016                        readiness.ready.is_none(),
13017                        "failed correction must not hot-loop"
13018                    );
13019                    assert_eq!(readiness.next_due_at, Some(retry_after));
13020                }
13021                continue;
13022            }
13023            let (retry, prepared) = result.unwrap();
13024            let (prefix, mut deliveries) = prepared.unwrap();
13025            assert_eq!(deliveries.len(), 1);
13026            let delivery = deliveries.pop().unwrap();
13027            assert_eq!(
13028                prefix,
13029                serde_json::to_value(&before.messages)
13030                    .unwrap()
13031                    .as_array()
13032                    .unwrap()
13033                    .clone()
13034            );
13035            assert_eq!(delivery.envelope, envelope);
13036            assert_eq!(delivery.activation_run_id, "retry-run");
13037            assert_eq!(delivery.canonical_claim_generation, receipt.generation);
13038            assert!(store
13039                .validate_fence(&first.fence, chrono::Utc::now())
13040                .await
13041                .is_err());
13042            let running = store.inspect_actor(&child.id).await.unwrap();
13043            assert_eq!(running.actor.current_attempt, 2);
13044            assert_eq!(
13045                running.activation.as_ref().unwrap().status,
13046                bamboo_domain::ActorActivationStatus::Running
13047            );
13048            assert!(running.activation.unwrap().lease_epoch > first.fence.lease_epoch);
13049            let claim = retry.initial_inputs.first().unwrap();
13050            assert!(child
13051                .session_inbox_admission()
13052                .unwrap()
13053                .contains(&envelope.id));
13054            assert!(
13055                !retry
13056                    .input_inbox
13057                    .was_admitted(&child.id, &envelope.id)
13058                    .await
13059                    .unwrap(),
13060                "checkpoint is not startup confirmation"
13061            );
13062            let mut confirmation = admission_confirmation(&child.id, &claim.claim, "retry-run");
13063            confirmation.activation_run_id = "first-run".into();
13064            assert!(retry
13065                .confirm_input(&binding, &child, "retry-run", claim, &confirmation)
13066                .await
13067                .is_err());
13068            assert!(!retry
13069                .input_inbox
13070                .was_admitted(&child.id, &envelope.id)
13071                .await
13072                .unwrap());
13073            confirmation.activation_run_id = "retry-run".into();
13074            retry
13075                .confirm_input(&binding, &child, "retry-run", claim, &confirmation)
13076                .await
13077                .unwrap();
13078            let (tx, mut rx) = mpsc::channel(1);
13079            retry
13080                .append_reply(&mut child, "RETRY_REPLY".into(), &tx)
13081                .await
13082                .unwrap();
13083            rx.recv().await.unwrap();
13084            retry
13085                .finish(ActorActivationFinish::Succeeded)
13086                .await
13087                .unwrap();
13088            let reopened = Arc::new(
13089                bamboo_storage::SessionStoreV2::new(temp.path().into())
13090                    .await
13091                    .unwrap(),
13092            );
13093            let cold = reopened.load_session(&child.id).await.unwrap().unwrap();
13094            assert_eq!(cold.created_at, before.created_at);
13095            assert_eq!(cold.messages.len(), before.messages.len() + 2);
13096            assert_eq!(
13097                cold.messages[before.messages.len()].id,
13098                envelope.id.as_str()
13099            );
13100            assert_eq!(cold.messages.last().unwrap().content, "RETRY_REPLY");
13101            assert_eq!(
13102                cold.messages
13103                    .iter()
13104                    .filter(|m| m.id == envelope.id.as_str())
13105                    .count(),
13106                1
13107            );
13108            let cold_inbox = bamboo_storage::FileSessionInbox::new(
13109                reopened,
13110                bamboo_domain::SessionInboxLimits::default(),
13111            );
13112            assert!(cold_inbox
13113                .was_admitted(&cold.id, &envelope.id)
13114                .await
13115                .unwrap());
13116            assert_eq!(cold_inbox.inspect(&cold.id).await.unwrap().claimed, 0);
13117        }
13118    }
13119
13120    #[tokio::test]
13121    async fn pre_ack_owned_recovery_requires_expiry_provenance_and_verified_already() {
13122        for case in [
13123            "accepted",
13124            "actor-live",
13125            "claim-live",
13126            "legacy",
13127            "ack",
13128            "readonly",
13129            "prefix",
13130            "duplicate",
13131            "missing-marker",
13132            "mixed",
13133            "project",
13134            "birth",
13135        ] {
13136            let temp = tempfile::tempdir().unwrap();
13137            let store = Arc::new(
13138                bamboo_storage::SessionStoreV2::new(temp.path().into())
13139                    .await
13140                    .unwrap(),
13141            );
13142            let parent = Session::new("recovery-parent", "model");
13143            store.save_session(&parent).await.unwrap();
13144            let mut child =
13145                Session::new_child_of("recovery-child", &parent, "model", "original task");
13146            child.add_message(bamboo_agent_core::Message::user("original task"));
13147            store.save_session(&child).await.unwrap();
13148            let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
13149            bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
13150            let inbox = bamboo_storage::FileSessionInbox::new(
13151                store.clone(),
13152                bamboo_domain::SessionInboxLimits::default(),
13153            );
13154            // Complete unfenced persistence before starting the setup lease window.
13155            let envelope =
13156                bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "RECOVER_SAME_INPUT");
13157            let receipt = inbox.deliver(&envelope).await.unwrap();
13158            inbox
13159                .mark_activation_eligible(
13160                    &child.id,
13161                    receipt.generation,
13162                    bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
13163                )
13164                .await
13165                .unwrap();
13166            let now = chrono::Utc::now();
13167            let cutoff = now + chrono::Duration::seconds(10);
13168            let consumer = SessionInboxConsumerId::new();
13169            let old = store
13170                .claim_activation(&ActorActivationClaim {
13171                    actor_id: child.id.clone(),
13172                    run_id: "old-run".into(),
13173                    lease_owner: consumer.as_str().into(),
13174                    lease_expires_at: if case == "actor-live" {
13175                        now + chrono::Duration::minutes(5)
13176                    } else {
13177                        cutoff
13178                    },
13179                    inbox_generation: 0,
13180                    placement_ref: if case == "legacy" {
13181                        None
13182                    } else {
13183                        Some(bamboo_domain::ActorPlacementRef {
13184                            class: bamboo_domain::ActorPlacementClass::Local,
13185                            lease_id: "owned-initial-release-v1:actual-old-worker".into(),
13186                            slot_epoch: None,
13187                        })
13188                    },
13189                    now,
13190                })
13191                .await
13192                .unwrap();
13193            store
13194                .start_activation(&old.fence(), chrono::Utc::now())
13195                .await
13196                .unwrap();
13197            let claim_now = chrono::Utc::now();
13198            let claim = inbox
13199                .claim_owned(
13200                    &child.id,
13201                    1,
13202                    Some("old-run"),
13203                    &SessionInboxLeaseRequest {
13204                        consumer,
13205                        now: claim_now,
13206                        duration: if case == "claim-live" {
13207                            chrono::Duration::minutes(5)
13208                        } else {
13209                            cutoff - claim_now
13210                        },
13211                    },
13212                )
13213                .await
13214                .unwrap()
13215                .pop()
13216                .unwrap();
13217            child = inbox
13218                .checkpoint_actor_input(bamboo_storage::ActorInputCheckpoint {
13219                    fence: old.fence(),
13220                    expected_created_at: child.created_at,
13221                    claim: claim.clone(),
13222                    expected_messages: child.messages.clone(),
13223                    expected_provider_transcript: child.provider_transcript.clone(),
13224                    expected_admission: None,
13225                })
13226                .await
13227                .unwrap()
13228                .session;
13229            // The real checkpoint must succeed under the original live authority.
13230            store
13231                .validate_fence(&old.fence(), chrono::Utc::now())
13232                .await
13233                .unwrap_or_else(|error| panic!("{case}: setup fence: {error:?}"));
13234            let setup_leases = inbox
13235                .inspect_owned_leases(&child.id, 2, chrono::Utc::now())
13236                .await
13237                .unwrap();
13238            assert_eq!(setup_leases.len(), 1, "{case}: original setup claim");
13239            assert_eq!(setup_leases[0].epoch, claim.lease.epoch, "{case}");
13240            assert_eq!(setup_leases[0].expires_at, claim.lease.expires_at, "{case}");
13241            assert!(!setup_leases[0].expired, "{case}: setup claim must be live");
13242            let setup_at = chrono::Utc::now();
13243            assert!(
13244                setup_at < old.lease_expires_at,
13245                "{case}: setup Actor expired"
13246            );
13247            assert!(
13248                setup_at < claim.lease.expires_at,
13249                "{case}: setup claim expired"
13250            );
13251            eprintln!("pre-ACK {case}: setup live at {setup_at}, cutoff {cutoff}");
13252            if case == "ack" {
13253                inbox
13254                    .ack_owned(&child.id, &claim, chrono::Utc::now())
13255                    .await
13256                    .unwrap();
13257            }
13258            if case == "mixed" {
13259                inbox
13260                    .deliver(&bamboo_domain::SessionMessageEnvelope::user_input(
13261                        &child.id,
13262                        "different input",
13263                    ))
13264                    .await
13265                    .unwrap();
13266            }
13267            let directory = store
13268                .bamboo_home_dir()
13269                .join(store.resolve_rel_path(&child.id).await.unwrap());
13270            let main = std::fs::read(directory.join("session.json")).unwrap();
13271            let canonical = child.clone();
13272            match case {
13273                "prefix" => child.messages[0].content.push_str(" stale"),
13274                "duplicate" => child.messages.push(child.messages.last().unwrap().clone()),
13275                "missing-marker" => {
13276                    child
13277                        .messages
13278                        .last_mut()
13279                        .unwrap()
13280                        .metadata
13281                        .as_mut()
13282                        .unwrap()
13283                        .as_object_mut()
13284                        .unwrap()
13285                        .remove("_bamboo_owned_input_checkpoint");
13286                }
13287                "project" => child.set_project_id_meta(bamboo_domain::ProjectId::new().to_string()),
13288                "birth" => child.created_at += chrono::Duration::nanoseconds(1),
13289                _ => {}
13290            }
13291            // One shared short deadline per case; never wait for the live controls.
13292            // No reclaim, renewal or authority-file edit occurs between these stages.
13293            let delay = (cutoff - chrono::Utc::now()).to_std().unwrap_or_default();
13294            tokio::time::sleep(delay + Duration::from_millis(10)).await;
13295            let recovery_at = chrono::Utc::now();
13296            assert!(recovery_at >= cutoff, "{case}: real expiry is required");
13297            let before_recovery = store.inspect_actor(&child.id).await.unwrap();
13298            let original = before_recovery.activation.unwrap();
13299            assert_eq!(original.fence(), old.fence(), "{case}: same authority");
13300            assert_eq!(original.lease_expires_at, old.lease_expires_at, "{case}");
13301            assert_eq!(
13302                store
13303                    .validate_fence(&old.fence(), recovery_at)
13304                    .await
13305                    .is_ok(),
13306                case == "actor-live",
13307                "{case}: Actor expiry control"
13308            );
13309            let recovery_leases = inbox
13310                .inspect_owned_leases(&child.id, 2, recovery_at)
13311                .await
13312                .unwrap();
13313            if case == "ack" {
13314                assert!(recovery_leases.is_empty(), "{case}: terminal ACK");
13315            } else {
13316                assert_eq!(recovery_leases.len(), 1, "{case}: original recovery claim");
13317                assert_eq!(recovery_leases[0].epoch, claim.lease.epoch, "{case}");
13318                assert_eq!(
13319                    recovery_leases[0].expires_at, claim.lease.expires_at,
13320                    "{case}"
13321                );
13322                assert_eq!(recovery_leases[0].expired, case != "claim-live", "{case}");
13323            }
13324            eprintln!("pre-ACK {case}: recovery expiry control at {recovery_at}");
13325            let second = Arc::new(
13326                bamboo_storage::SessionStoreV2::new(temp.path().into())
13327                    .await
13328                    .unwrap(),
13329            );
13330            let binding = actor_binding(
13331                second.clone(),
13332                Arc::new(bamboo_storage::FileSessionInbox::new(
13333                    second.clone(),
13334                    bamboo_domain::SessionInboxLimits::default(),
13335                )),
13336                Arc::new(bamboo_storage::LockedSessionStore::new(second.clone())),
13337            );
13338            let _owner = binding
13339                .router
13340                .register_run(&child.id, "replacement-run")
13341                .await
13342                .unwrap();
13343            let prepared = PlainActorActivation::start_and_prepare(
13344                second.clone(),
13345                &mut child,
13346                &binding,
13347                Some("replacement-run"),
13348                Some(policy.clone()),
13349                case != "readonly",
13350                Some("probed-new-worker"),
13351            )
13352            .await;
13353            assert_eq!(
13354                std::fs::read(directory.join("session.json")).unwrap(),
13355                main,
13356                "{case}: Already or refusal never rewrites Main"
13357            );
13358            if case != "accepted" {
13359                assert!(prepared.is_err(), "{case}");
13360                assert_eq!(
13361                    inbox.was_admitted(&child.id, &envelope.id).await.unwrap(),
13362                    case == "ack"
13363                );
13364                continue;
13365            }
13366            let (replacement, delivery) = prepared.unwrap();
13367            assert!(replacement.recovering_pre_ack);
13368            let (messages, mut deliveries) = delivery.unwrap();
13369            assert_eq!(deliveries.len(), 1);
13370            let delivery = deliveries.pop().unwrap();
13371            let new_claim = replacement.initial_inputs.first().unwrap();
13372            assert_eq!(new_claim.claim.envelope, claim.claim.envelope);
13373            assert_eq!(new_claim.claim.generation, claim.claim.generation);
13374            assert_eq!(
13375                new_claim.claim.activation_policy,
13376                claim.claim.activation_policy
13377            );
13378            assert!(new_claim.lease.epoch > claim.lease.epoch);
13379            assert_ne!(new_claim.lease.incarnation, claim.lease.incarnation);
13380            assert_eq!(
13381                replacement.inbox_generation, 1,
13382                "old Actor generation was zero"
13383            );
13384            assert_eq!(
13385                second
13386                    .inspect_actor(&child.id)
13387                    .await
13388                    .unwrap()
13389                    .actor
13390                    .current_attempt,
13391                2
13392            );
13393            assert!(second
13394                .validate_fence(&old.fence(), chrono::Utc::now())
13395                .await
13396                .is_err());
13397            assert!(inbox
13398                .ack_owned(&child.id, &claim, chrono::Utc::now())
13399                .await
13400                .is_err());
13401            // The original Store/claim survives the handoff. Even with the
13402            // current exact prefix and dedupe cursor, its delayed checkpoint
13403            // must fail on authority before AlreadyCheckpointed can succeed.
13404            let authority = second.inspect_actor(&child.id).await.unwrap();
13405            let leases = inbox
13406                .inspect_owned_leases(&child.id, 2, chrono::Utc::now())
13407                .await
13408                .unwrap();
13409            let delayed = inbox
13410                .checkpoint_actor_input(bamboo_storage::ActorInputCheckpoint {
13411                    fence: old.fence(),
13412                    expected_created_at: child.created_at,
13413                    claim: claim.clone(),
13414                    expected_messages: child.messages.clone(),
13415                    expected_provider_transcript: child.provider_transcript.clone(),
13416                    expected_admission: child.session_inbox_admission().cloned(),
13417                })
13418                .await;
13419            assert!(matches!(
13420                delayed,
13421                Err(bamboo_storage::ActorInputCheckpointError::Actor(
13422                    bamboo_domain::ActorDirectoryError::StaleFence
13423                ))
13424            ));
13425            assert_eq!(std::fs::read(directory.join("session.json")).unwrap(), main);
13426            assert_eq!(second.inspect_actor(&child.id).await.unwrap(), authority);
13427            assert_eq!(
13428                inbox
13429                    .inspect_owned_leases(&child.id, 2, chrono::Utc::now())
13430                    .await
13431                    .unwrap(),
13432                leases
13433            );
13434            assert!(!inbox.was_admitted(&child.id, &envelope.id).await.unwrap());
13435            assert_eq!(
13436                serde_json::to_value(&child.messages).unwrap(),
13437                serde_json::to_value(&canonical.messages).unwrap()
13438            );
13439            let mut worker: Vec<bamboo_agent_core::Message> = messages
13440                .iter()
13441                .cloned()
13442                .map(|v| serde_json::from_value(v).unwrap())
13443                .collect();
13444            let target = worker.last_mut().unwrap();
13445            assert!(target
13446                .metadata
13447                .as_ref()
13448                .unwrap()
13449                .get("_bamboo_owned_input_checkpoint")
13450                .is_none());
13451            assert!(bamboo_domain::is_matching_session_message(
13452                target, &envelope
13453            ));
13454            let mut wanted = canonical.messages.last().unwrap().clone();
13455            wanted
13456                .metadata
13457                .as_mut()
13458                .unwrap()
13459                .as_object_mut()
13460                .unwrap()
13461                .remove("_bamboo_owned_input_checkpoint");
13462            assert_eq!(
13463                serde_json::to_value(target).unwrap(),
13464                serde_json::to_value(wanted).unwrap()
13465            );
13466            let run = RunSpec {
13467                assignment: "original task".into(),
13468                logical_session: Some(LogicalSessionIdentity {
13469                    session_id: child.id.clone(),
13470                    parent_session_id: child.parent_session_id.clone(),
13471                    root_session_id: child.root_session_id.clone(),
13472                    creation: Some(bamboo_subagent::proto::ChildCreationIdentity {
13473                        created_at: child.created_at,
13474                        spawn_depth: child.spawn_depth,
13475                    }),
13476                }),
13477                project_id: None,
13478                reasoning_effort: None,
13479                permission_policy: Some(PermissionPolicyContext {
13480                    revision: policy.policy_revision(),
13481                    requested_mode: "default".into(),
13482                    effective_mode: "default".into(),
13483                    bypass_permissions: false,
13484                    auto_approve_permissions: false,
13485                    session_id: child.id.clone(),
13486                    workspace_path: child.workspace.clone(),
13487                    environment_lease: None,
13488                    inherit_session_grants: false,
13489                    policy: serde_json::to_value(policy.to_serializable()).unwrap(),
13490                }),
13491                messages,
13492                activation_run_id: Some("replacement-run".into()),
13493                execution_epoch: 2,
13494                initial_session_messages: vec![delivery.clone()],
13495                secrets: Default::default(),
13496            };
13497            let policy_revision = policy.policy_revision();
13498            let mut expected = Some(expected_default_permission_posture(policy_revision));
13499            let ChildFrame::Event { event } = permission_posture_frame(&child.id, policy_revision)
13500            else {
13501                unreachable!()
13502            };
13503            let (events, mut rx) = mpsc::channel(1);
13504            process_actor_event(
13505                event,
13506                true,
13507                &mut PermissionPostureHandshake::Awaiting,
13508                expected.as_ref(),
13509                Some(&binding),
13510                &mut child,
13511                &events,
13512                &mut ActorEventDisplay::default(),
13513            )
13514            .await
13515            .unwrap();
13516            rx.recv().await.unwrap();
13517            let request = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
13518                &run,
13519                &delivery,
13520                uuid::Uuid::new_v4().to_string(),
13521            )
13522            .unwrap();
13523            replacement
13524                .release_initial_input(
13525                    &binding,
13526                    &child,
13527                    &run,
13528                    2,
13529                    new_claim,
13530                    &request,
13531                    &mut expected,
13532                    None,
13533                )
13534                .await
13535                .unwrap();
13536            assert!(inbox.was_admitted(&child.id, &envelope.id).await.unwrap());
13537            replacement
13538                .append_reply(&mut child, "RECOVERY_REPLY".into(), &events)
13539                .await
13540                .unwrap();
13541            rx.recv().await.unwrap();
13542            replacement
13543                .finish(ActorActivationFinish::Succeeded)
13544                .await
13545                .unwrap();
13546            let cold = second.load_session(&child.id).await.unwrap().unwrap();
13547            assert_eq!(cold.created_at, canonical.created_at);
13548            assert_eq!(
13549                serde_json::to_value(&cold.messages[..canonical.messages.len()]).unwrap(),
13550                serde_json::to_value(&canonical.messages).unwrap()
13551            );
13552            assert_eq!(cold.messages.last().unwrap().content, "RECOVERY_REPLY");
13553            assert_eq!(
13554                cold.messages
13555                    .iter()
13556                    .filter(|m| m.id == envelope.id.as_str())
13557                    .count(),
13558                1
13559            );
13560        }
13561    }
13562
13563    #[cfg(unix)]
13564    #[tokio::test]
13565    async fn owned_initial_release_missing_only_new_capability_rejects_before_spawn_or_run() {
13566        use bamboo_subagent::provision::*;
13567        use std::os::unix::fs::PermissionsExt;
13568        let temp = tempfile::tempdir().unwrap();
13569        let worker = temp.path().join("mixed-worker");
13570        let spawned = temp.path().join("spawned");
13571        let run = temp.path().join("run");
13572        let probes = temp.path().join("probes");
13573        let fabric = temp.path().join("fabric");
13574        let mut report = WorkerCapabilityReport::current();
13575        report
13576            .capabilities
13577            .retain(|name| name != INITIAL_INPUT_RELEASE_WORKER_CAPABILITY);
13578        assert!(!report.supports(INITIAL_INPUT_RELEASE_WORKER_CAPABILITY));
13579        let report = serde_json::to_string(&report).unwrap();
13580        std::fs::write(&worker, format!(
13581            "#!/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"
13582        )).unwrap();
13583        std::fs::set_permissions(&worker, std::fs::Permissions::from_mode(0o700)).unwrap();
13584        let args = vec![
13585            spawned.to_str().unwrap().into(),
13586            run.to_str().unwrap().into(),
13587            probes.to_str().unwrap().into(),
13588        ];
13589        // Real subprocess probes prove the rejection cannot be an earlier missing bit.
13590        for capability in [
13591            REQUIRED_CHILD_CONTEXT_WORKER_CAPABILITY,
13592            CHILD_CREATION_IDENTITY_WORKER_CAPABILITY,
13593            NATIVE_TOOL_CEILING_WORKER_CAPABILITY,
13594            TYPED_READ_ONLY_WORKER_CAPABILITY,
13595        ] {
13596            bamboo_subagent::fleet::require_worker_capability(&worker, &args, capability)
13597                .await
13598                .unwrap();
13599        }
13600        let runner = ActorChildRunner::new(
13601            "mixed".into(),
13602            worker,
13603            args,
13604            fabric.clone(),
13605            ExecutorSpec::BambooRuntime,
13606            vec![],
13607            "openai".into(),
13608            1,
13609        );
13610        // The actual Host method remains mandatory before claim/provision/Run.
13611        let error = runner
13612            .require_initial_input_release_worker()
13613            .await
13614            .unwrap_err();
13615        assert!(error
13616            .to_string()
13617            .contains("owned initial release worker capability unconfirmed"));
13618        assert_eq!(std::fs::read_to_string(probes).unwrap().lines().count(), 5);
13619        assert!(!spawned.exists() && !run.exists() && !fabric.exists());
13620        assert!(runner.pool.lock().await.is_empty());
13621    }
13622
13623    #[tokio::test]
13624    async fn ordinary_glob_only_observes_own_initialization_signals() {
13625        let temp = tempfile::tempdir().unwrap();
13626        let home = temp.path().canonicalize().unwrap();
13627        let store = bamboo_storage::SessionStoreV2::new(home.clone())
13628            .await
13629            .unwrap();
13630        let parent = Session::new("glob-standard-parent", "model");
13631        store.save_session(&parent).await.unwrap();
13632        let child = Session::new_child_of("glob-standard-child", &parent, "model", "one Glob");
13633        store.save_session(&child).await.unwrap();
13634        let directory = home.join(store.resolve_rel_path(&child.id).await.unwrap());
13635        // An unrelated bad child would reject a full tree snapshot, but is not
13636        // part of this own-session no-replay observation.
13637        let unrelated = directory.parent().unwrap().join("unrelated");
13638        std::fs::create_dir(&unrelated).unwrap();
13639        std::fs::write(unrelated.join("session.json"), b"invalid").unwrap();
13640        require_unowned_glob(&store, &child).await.unwrap();
13641        for name in ["actor-authority.json", "actor-authority.initialized.json"] {
13642            let path = directory.join(name);
13643            std::fs::write(&path, b"invalid prior signal").unwrap();
13644            assert!(require_unowned_glob(&store, &child).await.is_err());
13645            std::fs::remove_file(&path).unwrap();
13646        }
13647        #[cfg(unix)]
13648        {
13649            let marker = directory.join("actor-authority.initialized.json");
13650            std::os::unix::fs::symlink(directory.join("missing"), &marker).unwrap();
13651            assert!(require_unowned_glob(&store, &child).await.is_err());
13652        }
13653    }
13654
13655    #[test]
13656    fn owned_glob_collector_requires_actual_ordered_tool_trace_and_typed_tail() {
13657        let call = bamboo_domain::ToolCall {
13658            id: "glob".into(),
13659            tool_type: "function".into(),
13660            function: bamboo_domain::FunctionCall {
13661                name: "Glob".into(),
13662                arguments: r#"{"pattern":"marker.txt"}"#.into(),
13663            },
13664        };
13665        let messages = vec![
13666            bamboo_agent_core::Message::assistant("", Some(vec![call])),
13667            bamboo_agent_core::Message::tool_result("glob", "marker.txt"),
13668            bamboo_agent_core::Message::assistant("done", None),
13669        ];
13670        let start = serde_json::to_value(AgentEvent::ToolStart {
13671            tool_call_id: "glob".into(),
13672            tool_name: "Glob".into(),
13673            arguments: serde_json::json!({"pattern":"marker.txt"}),
13674        })
13675        .unwrap();
13676        let complete = serde_json::to_value(AgentEvent::ToolComplete {
13677            tool_call_id: "glob".into(),
13678            result: bamboo_domain::ToolResult::text(true, "marker.txt"),
13679        })
13680        .unwrap();
13681        let tail =
13682            serde_json::to_value(bamboo_subagent::proto::ReadOnlyActorTranscript::Complete {
13683                messages: messages
13684                    .iter()
13685                    .map(serde_json::to_value)
13686                    .collect::<Result<_, _>>()
13687                    .unwrap(),
13688            })
13689            .unwrap();
13690        let mut ordered = ReadOnlyActorCollector::default();
13691        assert!(ordered.event(&start).unwrap());
13692        assert!(ordered.event(&complete).unwrap());
13693        assert!(!ordered.event(&tail).unwrap());
13694        assert!(ordered.event(&tail).is_err(), "duplicate suffix");
13695        assert_eq!(ordered.finish(Some("done")).unwrap().len(), 3);
13696        assert!(ReadOnlyActorCollector::default()
13697            .finish(Some("done"))
13698            .is_err());
13699        let mut unfinished = ReadOnlyActorCollector::default();
13700        unfinished.event(&start).unwrap();
13701        unfinished.event(&complete).unwrap();
13702        assert!(unfinished.finish(Some("done")).is_err(), "missing suffix");
13703        let mut closed = tail.clone();
13704        closed["grant"] = true.into();
13705        assert!(
13706            serde_json::from_value::<bamboo_subagent::proto::ReadOnlyActorTranscript>(closed)
13707                .is_err()
13708        );
13709        let mut oversized = tail.clone();
13710        oversized["messages"][2]["content"] = "x".repeat(64 * 1024).into();
13711        assert!(
13712            serde_json::from_value::<bamboo_subagent::proto::ReadOnlyActorTranscript>(oversized)
13713                .unwrap()
13714                .validate()
13715                .is_err()
13716        );
13717        assert!(ReadOnlyActorCollector::default().event(&complete).is_err());
13718        assert!(ReadOnlyActorCollector::default().event(&tail).is_err());
13719        let mut bad = start.clone();
13720        bad["tool_name"] = "Bash".into();
13721        assert!(ReadOnlyActorCollector::default().event(&bad).is_err());
13722        let mut duplicate = ReadOnlyActorCollector::default();
13723        duplicate.event(&start).unwrap();
13724        assert!(duplicate.event(&start).is_err());
13725        let hidden = serde_json::to_value(AgentEvent::ReasoningToken {
13726            content: "hidden".into(),
13727        })
13728        .unwrap();
13729        assert!(ReadOnlyActorCollector::default().event(&hidden).is_err());
13730        let mut mismatched = ReadOnlyActorCollector::default();
13731        mismatched.event(&start).unwrap();
13732        let mut bad = complete.clone();
13733        bad["tool_call_id"] = "foreign".into();
13734        assert!(mismatched.event(&bad).is_err());
13735        mismatched.event(&complete).unwrap();
13736        let mut changed = tail.clone();
13737        changed["messages"][1]["content"] = "fabricated".into();
13738        assert!(mismatched.event(&changed).is_err());
13739    }
13740
13741    #[cfg(unix)]
13742    #[tokio::test]
13743    async fn owned_initial_release_requires_current_prefix_ack_and_same_receipt() {
13744        let temp = tempfile::tempdir().unwrap();
13745        let store = Arc::new(
13746            bamboo_storage::SessionStoreV2::new(temp.path().into())
13747                .await
13748                .unwrap(),
13749        );
13750        let parent = Session::new("release-host-parent", "model");
13751        store.save_session(&parent).await.unwrap();
13752        let mut child = Session::new_child_of("release-host-child", &parent, "model", "assignment");
13753        store.save_session(&child).await.unwrap();
13754        let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
13755            store.clone(),
13756            bamboo_domain::SessionInboxLimits::default(),
13757        ));
13758        let binding = actor_binding(
13759            store.clone(),
13760            inbox,
13761            Arc::new(bamboo_storage::LockedSessionStore::new(store.clone())),
13762        );
13763        let _owner = binding
13764            .router
13765            .register_run(&child.id, "release-host-run")
13766            .await
13767            .unwrap();
13768        let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
13769        let revision = policy.policy_revision();
13770        bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
13771        let activation = PlainActorActivation::start(
13772            store.clone(),
13773            &child,
13774            &binding,
13775            Some("release-host-run"),
13776            Some(policy.clone()),
13777            true,
13778            Some("probed-physical-worker"),
13779        )
13780        .await
13781        .unwrap();
13782        let envelope = bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "RELEASE_ONCE");
13783        let receipt = activation.input_inbox.deliver(&envelope).await.unwrap();
13784        activation
13785            .input_inbox
13786            .mark_activation_eligible(
13787                &child.id,
13788                receipt.generation,
13789                bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
13790            )
13791            .await
13792            .unwrap();
13793        let claim = activation
13794            .claim_input(&binding, &child, "release-host-run")
13795            .await
13796            .unwrap()
13797            .unwrap();
13798        let (prefix, delivery) = activation
13799            .checkpoint_input(&mut child, "release-host-run", &claim)
13800            .await
13801            .unwrap();
13802        let mut expected = Some(expected_default_permission_posture(revision));
13803        let ChildFrame::Event { event } = permission_posture_frame(&child.id, revision) else {
13804            unreachable!()
13805        };
13806        let (events, mut events_rx) = mpsc::channel(1);
13807        process_actor_event(
13808            event,
13809            true,
13810            &mut PermissionPostureHandshake::Awaiting,
13811            expected.as_ref(),
13812            Some(&binding),
13813            &mut child,
13814            &events,
13815            &mut ActorEventDisplay::default(),
13816        )
13817        .await
13818        .unwrap();
13819        events_rx.recv().await.unwrap();
13820        let run = RunSpec {
13821            assignment: "assignment".into(),
13822            logical_session: Some(LogicalSessionIdentity {
13823                session_id: child.id.clone(),
13824                parent_session_id: child.parent_session_id.clone(),
13825                root_session_id: child.root_session_id.clone(),
13826                creation: Some(bamboo_subagent::proto::ChildCreationIdentity {
13827                    created_at: child.created_at,
13828                    spawn_depth: child.spawn_depth,
13829                }),
13830            }),
13831            project_id: None,
13832            reasoning_effort: None,
13833            permission_policy: Some(bamboo_subagent::proto::PermissionPolicyContext {
13834                revision,
13835                requested_mode: "default".into(),
13836                effective_mode: "default".into(),
13837                bypass_permissions: false,
13838                auto_approve_permissions: false,
13839                session_id: child.id.clone(),
13840                workspace_path: child.workspace.clone(),
13841                environment_lease: None,
13842                inherit_session_grants: false,
13843                policy: serde_json::to_value(policy.to_serializable()).unwrap(),
13844            }),
13845            messages: prefix,
13846            activation_run_id: Some("release-host-run".into()),
13847            execution_epoch: 9,
13848            initial_session_messages: vec![delivery.clone()],
13849            secrets: Default::default(),
13850        };
13851        let request = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
13852            &run,
13853            &delivery,
13854            uuid::Uuid::new_v4().to_string(),
13855        )
13856        .unwrap();
13857        let mut stale = child.clone();
13858        stale.add_message(bamboo_agent_core::Message::user("not canonical"));
13859        assert!(activation
13860            .release_initial_input(
13861                &binding,
13862                &stale,
13863                &run,
13864                9,
13865                &claim,
13866                &request,
13867                &mut expected,
13868                None
13869            )
13870            .await
13871            .is_err());
13872        assert!(!activation
13873            .input_inbox
13874            .was_admitted(&child.id, &envelope.id)
13875            .await
13876            .unwrap());
13877        // Keep receipt lookup readable so Already reaches the real ACK write.
13878        // Unix root can bypass these modes; this FS fault needs an unprivileged user.
13879        use std::os::unix::fs::PermissionsExt;
13880        struct RestorePermissions(std::path::PathBuf, std::fs::Permissions);
13881        impl Drop for RestorePermissions {
13882            fn drop(&mut self) {
13883                let _ = std::fs::set_permissions(&self.0, self.1.clone());
13884            }
13885        }
13886        let admitted = store
13887            .bamboo_home_dir()
13888            .join(store.resolve_rel_path(&child.id).await.unwrap())
13889            .join("inbox/admitted");
13890        std::fs::create_dir_all(&admitted).unwrap();
13891        let restore = RestorePermissions(
13892            admitted.clone(),
13893            std::fs::metadata(&admitted).unwrap().permissions(),
13894        );
13895        std::fs::set_permissions(&admitted, std::fs::Permissions::from_mode(0o555)).unwrap();
13896        let verified = activation
13897            .input_inbox
13898            .checkpoint_actor_input(bamboo_storage::ActorInputCheckpoint {
13899                fence: activation.fence.clone(),
13900                expected_created_at: child.created_at,
13901                claim: claim.clone(),
13902                expected_messages: child.messages.clone(),
13903                expected_provider_transcript: child.provider_transcript.clone(),
13904                expected_admission: child.session_inbox_admission().cloned(),
13905            })
13906            .await
13907            .unwrap();
13908        assert_eq!(
13909            verified.status,
13910            bamboo_storage::ActorInputCheckpointStatus::AlreadyCheckpointed
13911        );
13912        let error = activation
13913            .release_initial_input(
13914                &binding,
13915                &child,
13916                &run,
13917                9,
13918                &claim,
13919                &request,
13920                &mut expected,
13921                None,
13922            )
13923            .await
13924            .unwrap_err();
13925        std::fs::set_permissions(&admitted, restore.1.clone()).unwrap();
13926        drop(restore);
13927        assert!(
13928            error.to_string().contains("ACK unresolved"),
13929            "actual release error (<=512B): {}",
13930            error.to_string().chars().take(128).collect::<String>()
13931        );
13932        let release = activation
13933            .release_initial_input(
13934                &binding,
13935                &child,
13936                &run,
13937                9,
13938                &claim,
13939                &request,
13940                &mut expected,
13941                None,
13942            )
13943            .await
13944            .unwrap();
13945        assert!(activation
13946            .input_inbox
13947            .was_admitted(&child.id, &envelope.id)
13948            .await
13949            .unwrap());
13950        assert_eq!(
13951            release.expires_at,
13952            activation
13953                .actor_deadline
13954                .min(claim.lease.expires_at)
13955                .min(activation.input_deadline)
13956        );
13957        assert_eq!(
13958            activation
13959                .release_initial_input(
13960                    &binding,
13961                    &child,
13962                    &run,
13963                    9,
13964                    &claim,
13965                    &request,
13966                    &mut expected,
13967                    Some(&release)
13968                )
13969                .await
13970                .unwrap(),
13971            release
13972        );
13973        let mut wrong = request.clone();
13974        wrong.nonce = uuid::Uuid::new_v4().to_string();
13975        assert!(activation
13976            .release_initial_input(
13977                &binding,
13978                &child,
13979                &run,
13980                9,
13981                &claim,
13982                &wrong,
13983                &mut expected,
13984                Some(&release)
13985            )
13986            .await
13987            .is_err());
13988        assert!(activation
13989            .release_initial_input(
13990                &binding,
13991                &child,
13992                &run,
13993                10,
13994                &claim,
13995                &request,
13996                &mut expected,
13997                Some(&release)
13998            )
13999            .await
14000            .is_err());
14001        // A second real checkpoint/release reuses the activation, not its old epoch or nonce.
14002        let deadlines = (
14003            activation.fence.clone(),
14004            activation.actor_deadline,
14005            activation.input_deadline,
14006        );
14007        let second = bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "RELEASE_TWICE");
14008        let receipt = activation.input_inbox.deliver(&second).await.unwrap();
14009        activation
14010            .input_inbox
14011            .mark_activation_eligible(
14012                &child.id,
14013                receipt.generation,
14014                bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
14015            )
14016            .await
14017            .unwrap();
14018        let mut link = ConfirmationSequenceLink {
14019            frames: VecDeque::new(),
14020            sent: Vec::new(),
14021        };
14022        let epochs = AtomicU64::new(9);
14023        let (claim2, epoch2) = activation
14024            .continue_input(
14025                &mut link,
14026                &binding,
14027                &mut child,
14028                "release-host-run",
14029                Some((&run, &epochs)),
14030                &mut expected,
14031                "SECOND_REPLY".into(),
14032                &events,
14033            )
14034            .await
14035            .unwrap()
14036            .unwrap();
14037        events_rx.recv().await.unwrap();
14038        assert_eq!(epoch2, 10);
14039        let ParentFrame::Run(next) = &link.sent[0] else {
14040            panic!("second correlated Run")
14041        };
14042        assert_eq!(next.logical_session, run.logical_session);
14043        assert_eq!(next.activation_run_id, run.activation_run_id);
14044        assert_eq!(next.initial_session_messages.len(), 1);
14045        assert!(next
14046            .messages
14047            .iter()
14048            .any(|m| m["id"] == envelope.id.as_str()));
14049        assert!(next.messages.iter().any(|m| m["content"] == "SECOND_REPLY"));
14050        assert!(!next.messages.iter().any(|m| m["id"] == second.id.as_str()));
14051        let request2 = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
14052            next,
14053            &next.initial_session_messages[0],
14054            uuid::Uuid::new_v4().to_string(),
14055        )
14056        .unwrap();
14057        assert_ne!(request2.nonce, request.nonce);
14058        macro_rules! release_second {
14059            ($candidate:expr, $request:expr, $epoch:expr, $previous:expr) => {
14060                activation
14061                    .release_initial_input(
14062                        &binding,
14063                        $candidate,
14064                        next,
14065                        $epoch,
14066                        &claim2,
14067                        $request,
14068                        &mut expected,
14069                        $previous,
14070                    )
14071                    .await
14072            };
14073        }
14074        assert!(release_second!(&child, &request, epoch2, None).is_err());
14075        assert!(release_second!(&child, &request2, 9, None).is_err());
14076        let mut stale2 = child.clone();
14077        stale2.add_message(bamboo_agent_core::Message::user("stale second prefix"));
14078        assert!(release_second!(&stale2, &request2, epoch2, None).is_err());
14079        assert!(!activation
14080            .input_inbox
14081            .was_admitted(&child.id, &second.id)
14082            .await
14083            .unwrap());
14084        let release2 = release_second!(&child, &request2, epoch2, None).unwrap();
14085        assert_eq!(
14086            release_second!(&child, &request2, epoch2, Some(&release2)).unwrap(),
14087            release2
14088        );
14089        let mut wrong2 = request2.clone();
14090        wrong2.nonce = request.nonce.clone();
14091        assert!(release_second!(&child, &wrong2, epoch2, Some(&release2)).is_err());
14092        assert!(activation
14093            .input_inbox
14094            .was_admitted(&child.id, &second.id)
14095            .await
14096            .unwrap());
14097        let expired = bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "EXPIRED_INPUT");
14098        let receipt = activation.input_inbox.deliver(&expired).await.unwrap();
14099        activation
14100            .input_inbox
14101            .mark_activation_eligible(
14102                &child.id,
14103                receipt.generation,
14104                bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
14105            )
14106            .await
14107            .unwrap();
14108        let now = chrono::Utc::now();
14109        let expired_claim = activation
14110            .input_inbox
14111            .claim_owned(
14112                &child.id,
14113                1,
14114                Some("release-host-run"),
14115                &SessionInboxLeaseRequest {
14116                    consumer: activation.input_consumer.clone(),
14117                    now,
14118                    duration: chrono::Duration::seconds(1),
14119                },
14120            )
14121            .await
14122            .unwrap()
14123            .pop()
14124            .unwrap();
14125        let (prefix, delivery3) = activation
14126            .checkpoint_input(&mut child, "release-host-run", &expired_claim)
14127            .await
14128            .unwrap();
14129        let mut expired_run = next.clone();
14130        expired_run.execution_epoch = 11;
14131        expired_run.messages = prefix;
14132        expired_run.initial_session_messages = vec![delivery3.clone()];
14133        let expired_request = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
14134            &expired_run,
14135            &delivery3,
14136            uuid::Uuid::new_v4().to_string(),
14137        )
14138        .unwrap();
14139        tokio::time::sleep(
14140            (expired_claim.lease.expires_at - chrono::Utc::now())
14141                .to_std()
14142                .unwrap_or_default(),
14143        )
14144        .await;
14145        assert!(chrono::Utc::now() >= expired_claim.lease.expires_at);
14146        assert!(activation
14147            .release_initial_input(
14148                &binding,
14149                &child,
14150                &expired_run,
14151                11,
14152                &expired_claim,
14153                &expired_request,
14154                &mut expected,
14155                None
14156            )
14157            .await
14158            .is_err());
14159        assert!(!activation
14160            .input_inbox
14161            .was_admitted(&child.id, &expired.id)
14162            .await
14163            .unwrap());
14164        assert_eq!(
14165            (
14166                activation.fence.clone(),
14167                activation.actor_deadline,
14168                activation.input_deadline
14169            ),
14170            deadlines
14171        );
14172        assert_eq!(
14173            link.sent.len(),
14174            1,
14175            "no dispatch from rejected release checks"
14176        );
14177        let cold = store.load_session(&child.id).await.unwrap().unwrap();
14178        for id in [&envelope.id, &second.id] {
14179            assert_eq!(
14180                cold.messages.iter().filter(|m| m.id == id.as_str()).count(),
14181                1
14182            );
14183            assert!(cold.session_inbox_admission().unwrap().contains(id));
14184        }
14185        // Mutate the actual captured policy Arc; cached ACK/release is not a fresh grant.
14186        policy.deny_scoped_session_permission(
14187            &child.id,
14188            bamboo_tools::permission::PermissionType::ExecuteCommand,
14189            "blocked-action",
14190        );
14191        assert!(release_second!(&child, &request2, epoch2, Some(&release2)).is_err());
14192        assert!(activation
14193            .input_inbox
14194            .was_admitted(&child.id, &second.id)
14195            .await
14196            .unwrap());
14197        activation
14198            .finish(ActorActivationFinish::Failed)
14199            .await
14200            .unwrap();
14201        // Terminal ACK can be idempotent; stopped/expired Actor cannot grant.
14202        activation
14203            .input_inbox
14204            .ack_owned(&child.id, &claim, chrono::Utc::now())
14205            .await
14206            .unwrap();
14207        assert!(activation
14208            .release_initial_input(
14209                &binding,
14210                &child,
14211                &run,
14212                9,
14213                &claim,
14214                &request,
14215                &mut expected,
14216                Some(&release)
14217            )
14218            .await
14219            .is_err());
14220        let cold = bamboo_storage::SessionStoreV2::new(temp.path().into())
14221            .await
14222            .unwrap()
14223            .load_session(&child.id)
14224            .await
14225            .unwrap()
14226            .unwrap();
14227        assert_eq!(
14228            cold.messages
14229                .iter()
14230                .filter(|m| m.id == envelope.id.as_str())
14231                .count(),
14232            1
14233        );
14234    }
14235
14236    #[tokio::test]
14237    async fn owned_actor_input_checkpoint_precedes_dispatch_and_exact_ack() {
14238        for reject_checkpoint in [false, true] {
14239            let temp = tempfile::tempdir().unwrap();
14240            let store = Arc::new(
14241                bamboo_storage::SessionStoreV2::new(temp.path().into())
14242                    .await
14243                    .unwrap(),
14244            );
14245            let parent = Session::new("owned-input-parent", "model");
14246            store.save_session(&parent).await.unwrap();
14247            let mut child =
14248                Session::new_child_of("owned-input-child", &parent, "model", "one plain task");
14249            store.save_session(&child).await.unwrap();
14250            let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
14251            let raw_storage: Arc<dyn Storage> = store.clone();
14252            let locked = Arc::new(bamboo_storage::LockedSessionStore::new(raw_storage));
14253            let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
14254                store.clone(),
14255                bamboo_domain::SessionInboxLimits::default(),
14256            ));
14257            let binding = actor_binding(store.clone(), inbox, locked);
14258            let run_id = "owned-input-run";
14259            let _registration = binding
14260                .router
14261                .register_run(&child.id, run_id)
14262                .await
14263                .unwrap();
14264            assert!(PlainActorActivation::start(
14265                store.clone(),
14266                &child,
14267                &binding,
14268                Some(run_id),
14269                None,
14270                true,
14271                None,
14272            )
14273            .await
14274            .is_err());
14275            assert!(PlainActorActivation::start(
14276                store.clone(),
14277                &child,
14278                &binding,
14279                Some(run_id),
14280                Some(policy.clone()),
14281                true,
14282                None,
14283            )
14284            .await
14285            .is_err());
14286            assert_eq!(
14287                store
14288                    .inspect_actor(&child.id)
14289                    .await
14290                    .unwrap()
14291                    .actor
14292                    .current_attempt,
14293                0
14294            );
14295            bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
14296            let activation = PlainActorActivation::start(
14297                store.clone(),
14298                &child,
14299                &binding,
14300                Some(run_id),
14301                Some(policy.clone()),
14302                true,
14303                None,
14304            )
14305            .await
14306            .unwrap();
14307            let envelope =
14308                bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "CORRECTION_ONCE");
14309            let receipt = activation.input_inbox.deliver(&envelope).await.unwrap();
14310            activation
14311                .input_inbox
14312                .mark_activation_eligible(
14313                    &child.id,
14314                    receipt.generation,
14315                    bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
14316                )
14317                .await
14318                .unwrap();
14319            let mut link = ConfirmationSequenceLink {
14320                frames: VecDeque::new(),
14321                sent: Vec::new(),
14322            };
14323            let policy_revision = policy.policy_revision();
14324            let run = RunSpec {
14325                assignment: "one plain task".into(),
14326                logical_session: None,
14327                project_id: None,
14328                reasoning_effort: None,
14329                permission_policy: Some(PermissionPolicyContext {
14330                    revision: policy_revision,
14331                    requested_mode: "default".into(),
14332                    effective_mode: "default".into(),
14333                    bypass_permissions: false,
14334                    auto_approve_permissions: false,
14335                    session_id: child.id.clone(),
14336                    workspace_path: child.workspace.clone(),
14337                    environment_lease: None,
14338                    inherit_session_grants: false,
14339                    policy: serde_json::to_value(policy.to_serializable()).unwrap(),
14340                }),
14341                messages: vec![],
14342                activation_run_id: Some(run_id.into()),
14343                execution_epoch: 1,
14344                initial_session_messages: vec![],
14345                secrets: Default::default(),
14346            };
14347            let epochs = AtomicU64::new(1);
14348            let (tx, mut rx) = mpsc::channel(1);
14349            let mut expected = Some(expected_default_permission_posture(policy_revision));
14350            let ChildFrame::Event { event } = permission_posture_frame(&child.id, policy_revision)
14351            else {
14352                unreachable!()
14353            };
14354            process_actor_event(
14355                event,
14356                true,
14357                &mut PermissionPostureHandshake::Awaiting,
14358                expected.as_ref(),
14359                Some(&binding),
14360                &mut child,
14361                &tx,
14362                &mut ActorEventDisplay::default(),
14363            )
14364            .await
14365            .unwrap();
14366            rx.recv().await.unwrap();
14367            let first_audit =
14368                bamboo_domain::PermissionAuditSnapshot::from_metadata(&child.metadata)
14369                    .unwrap()
14370                    .audit_revision;
14371            if reject_checkpoint {
14372                // A real bounded publication channel blocks AFTER first-reply
14373                // commit, so change actual Actor authority before checkpoint.
14374                tx.send(AgentEvent::Complete {
14375                    usage: Default::default(),
14376                })
14377                .await
14378                .unwrap();
14379            }
14380            let mut operation = activation.continue_input(
14381                &mut link,
14382                &binding,
14383                &mut child,
14384                run_id,
14385                Some((&run, &epochs)),
14386                &mut expected,
14387                "FIRST_PLAIN_REPLY".into(),
14388                &tx,
14389            );
14390            if reject_checkpoint {
14391                tokio::select! {
14392                    result = &mut operation => panic!("publication barrier released early: {result:?}"),
14393                    _ = async {
14394                        tokio::time::timeout(Duration::from_secs(5), async {
14395                            loop {
14396                                let actual = store.load_session("owned-input-child").await.unwrap().unwrap();
14397                                if actual.messages.iter().any(|m| m.content == "FIRST_PLAIN_REPLY") { break; }
14398                                tokio::task::yield_now().await;
14399                            }
14400                        }).await.unwrap();
14401                        activation.finish(ActorActivationFinish::Failed).await.unwrap();
14402                        rx.recv().await.unwrap();
14403                    } => {}
14404                }
14405            }
14406            let result = operation.await;
14407            if reject_checkpoint {
14408                assert!(result
14409                    .unwrap_err()
14410                    .to_string()
14411                    .contains("checkpoint unconfirmed"));
14412                assert!(
14413                    link.sent.is_empty(),
14414                    "zero worker/provider correction dispatch"
14415                );
14416                let durable = store.load_session(&child.id).await.unwrap().unwrap();
14417                assert!(durable
14418                    .messages
14419                    .iter()
14420                    .any(|m| m.content == "FIRST_PLAIN_REPLY"));
14421                assert!(!durable
14422                    .messages
14423                    .iter()
14424                    .any(|m| m.id == envelope.id.as_str()));
14425                assert!(!activation
14426                    .input_inbox
14427                    .was_admitted(&child.id, &envelope.id)
14428                    .await
14429                    .unwrap());
14430                assert_eq!(
14431                    activation
14432                        .input_inbox
14433                        .inspect(&child.id)
14434                        .await
14435                        .unwrap()
14436                        .claimed,
14437                    1
14438                );
14439                continue;
14440            }
14441            let (claim, epoch) = result.unwrap().unwrap();
14442            rx.recv().await.unwrap(); // Actual first-reply publication.
14443            assert_eq!(epoch, 2);
14444            assert_eq!(link.sent.len(), 1);
14445            let ParentFrame::Run(next) = &link.sent[0] else {
14446                panic!("correlated continuation Run")
14447            };
14448            assert_eq!(next.execution_epoch, 2);
14449            assert_eq!(next.activation_run_id.as_deref(), Some(run_id));
14450            assert!(next
14451                .messages
14452                .iter()
14453                .any(|m| m["content"] == "FIRST_PLAIN_REPLY"));
14454            assert!(!next
14455                .messages
14456                .iter()
14457                .any(|m| m["id"] == envelope.id.as_str()));
14458            let delivery = &next.initial_session_messages[0];
14459            assert_eq!(delivery.envelope, envelope);
14460            let durable = store.load_session(&child.id).await.unwrap().unwrap();
14461            assert!(durable
14462                .session_inbox_admission()
14463                .unwrap()
14464                .contains(&envelope.id));
14465            assert_eq!(
14466                durable
14467                    .messages
14468                    .iter()
14469                    .filter(|m| m.id == envelope.id.as_str())
14470                    .count(),
14471                1
14472            );
14473            assert!(
14474                !activation
14475                    .input_inbox
14476                    .was_admitted(&child.id, &envelope.id)
14477                    .await
14478                    .unwrap(),
14479                "checkpoint is not a worker admission receipt"
14480            );
14481            let mut confirmation = admission_confirmation(&child.id, &claim.claim, run_id);
14482            confirmation.canonical_claim_generation += 1;
14483            assert!(activation
14484                .confirm_input(&binding, &child, run_id, &claim, &confirmation)
14485                .await
14486                .is_err());
14487            assert!(!activation
14488                .input_inbox
14489                .was_admitted(&child.id, &envelope.id)
14490                .await
14491                .unwrap());
14492            confirmation.canonical_claim_generation = claim.claim.generation;
14493            activation
14494                .confirm_input(&binding, &child, run_id, &claim, &confirmation)
14495                .await
14496                .unwrap();
14497            assert!(activation
14498                .input_inbox
14499                .was_admitted(&child.id, &envelope.id)
14500                .await
14501                .unwrap());
14502            assert_eq!(
14503                expected.as_ref().unwrap().expected_audit_revision,
14504                Some(first_audit)
14505            );
14506            let ChildFrame::Event { event } = permission_posture_frame(&child.id, policy_revision)
14507            else {
14508                unreachable!()
14509            };
14510            process_actor_event(
14511                event,
14512                true,
14513                &mut PermissionPostureHandshake::Awaiting,
14514                expected.as_ref(),
14515                Some(&binding),
14516                &mut child,
14517                &tx,
14518                &mut ActorEventDisplay::default(),
14519            )
14520            .await
14521            .unwrap();
14522            rx.recv().await.unwrap();
14523            assert!(
14524                bamboo_domain::PermissionAuditSnapshot::from_metadata(&child.metadata)
14525                    .unwrap()
14526                    .audit_revision
14527                    > first_audit
14528            );
14529            let reopened = bamboo_storage::SessionStoreV2::new(temp.path().into())
14530                .await
14531                .unwrap();
14532            let cold = reopened.load_session(&child.id).await.unwrap().unwrap();
14533            assert_eq!(
14534                cold.messages
14535                    .iter()
14536                    .filter(|m| m.id == envelope.id.as_str())
14537                    .count(),
14538                1
14539            );
14540            assert!(cold
14541                .session_inbox_admission()
14542                .unwrap()
14543                .contains(&envelope.id));
14544        }
14545    }
14546
14547    fn admission_confirmation(
14548        session_id: &str,
14549        claim: &SessionInboxClaim,
14550        run_id: &str,
14551    ) -> bamboo_subagent::proto::SessionMessageAdmissionConfirmation {
14552        bamboo_subagent::proto::SessionMessageAdmissionConfirmation {
14553            target_session_id: session_id.to_string(),
14554            envelope_id: claim.envelope.id.to_string(),
14555            canonical_claim_generation: claim.generation,
14556            activation_run_id: run_id.to_string(),
14557        }
14558    }
14559
14560    fn expected_default_permission_posture(policy_revision: u64) -> ExpectedPermissionPosture {
14561        ExpectedPermissionPosture {
14562            policy_revision,
14563            resolution: bamboo_domain::PermissionModeResolution {
14564                requested: bamboo_domain::SessionPermissionMode::Default,
14565                effective: bamboo_domain::PermissionMode::Default,
14566            },
14567            expected_audit_revision: None,
14568            executor_mapping: "test_actor:permission_mode=default".to_string(),
14569        }
14570    }
14571
14572    fn permission_posture_frame(session_id: &str, policy_revision: u64) -> ChildFrame {
14573        ChildFrame::Event {
14574            event: serde_json::to_value(AgentEvent::PermissionPostureActivated {
14575                session_id: session_id.to_string(),
14576                policy_revision,
14577                requested_mode: "default".to_string(),
14578                effective_mode: "default".to_string(),
14579                executor_mapping: "test_actor:permission_mode=default".to_string(),
14580            })
14581            .expect("serialize permission posture event"),
14582        }
14583    }
14584
14585    fn actor_event_frame(event: AgentEvent) -> ChildFrame {
14586        ChildFrame::Event {
14587            event: serde_json::to_value(event).expect("serialize actor event"),
14588        }
14589    }
14590
14591    #[test]
14592    fn actor_event_batch_is_fenced_by_session_activation_and_epoch() {
14593        let session = Session::new_child("logical-child", "logical-parent", "model", "child");
14594        let mut batch = ActorEventBatch {
14595            logical_session: Some(LogicalSessionIdentity {
14596                creation: None,
14597                session_id: session.id.clone(),
14598                parent_session_id: session.parent_session_id.clone(),
14599                root_session_id: session.root_session_id.clone(),
14600            }),
14601            activation_id: Some("activation-7".into()),
14602            execution_epoch: 11,
14603            source_node_id: Some("node-a".into()),
14604            source_actor_id: Some("worker-a".into()),
14605            first_seq: 1,
14606            last_seq: 1,
14607            qos: bamboo_subagent::ActorEventQos::Ephemeral,
14608            events: vec![serde_json::json!({"type":"token","content":"ok"})],
14609        };
14610        validate_actor_event_batch(
14611            &batch,
14612            &session,
14613            "logical-parent",
14614            Some("activation-7"),
14615            11,
14616            "worker-a",
14617            None,
14618        )
14619        .unwrap();
14620
14621        let creation = bamboo_subagent::proto::ChildCreationIdentity {
14622            created_at: session.created_at,
14623            spawn_depth: session.spawn_depth,
14624        };
14625        assert!(validate_actor_event_batch(
14626            &batch,
14627            &session,
14628            "logical-parent",
14629            Some("activation-7"),
14630            11,
14631            "worker-a",
14632            Some(&creation)
14633        )
14634        .is_err());
14635        batch.logical_session.as_mut().unwrap().creation = Some(creation.clone());
14636        validate_actor_event_batch(
14637            &batch,
14638            &session,
14639            "logical-parent",
14640            Some("activation-7"),
14641            11,
14642            "worker-a",
14643            Some(&creation),
14644        )
14645        .unwrap();
14646        batch
14647            .logical_session
14648            .as_mut()
14649            .unwrap()
14650            .creation
14651            .as_mut()
14652            .unwrap()
14653            .created_at += chrono::Duration::nanoseconds(1);
14654        assert!(validate_actor_event_batch(
14655            &batch,
14656            &session,
14657            "logical-parent",
14658            Some("activation-7"),
14659            11,
14660            "worker-a",
14661            Some(&creation)
14662        )
14663        .is_err());
14664        batch.logical_session.as_mut().unwrap().creation = None;
14665
14666        batch.execution_epoch = 10;
14667        assert!(validate_actor_event_batch(
14668            &batch,
14669            &session,
14670            "logical-parent",
14671            Some("activation-7"),
14672            11,
14673            "worker-a",
14674            None,
14675        )
14676        .unwrap_err()
14677        .to_string()
14678        .contains("execution epoch"));
14679        batch.execution_epoch = 11;
14680        batch.activation_id = Some("replaced-activation".into());
14681        assert!(validate_actor_event_batch(
14682            &batch,
14683            &session,
14684            "logical-parent",
14685            Some("activation-7"),
14686            11,
14687            "worker-a",
14688            None,
14689        )
14690        .unwrap_err()
14691        .to_string()
14692        .contains("stale activation"));
14693        batch.activation_id = Some("activation-7".into());
14694        batch
14695            .logical_session
14696            .as_mut()
14697            .expect("logical identity")
14698            .session_id = "foreign-child".into();
14699        assert!(validate_actor_event_batch(
14700            &batch,
14701            &session,
14702            "logical-parent",
14703            Some("activation-7"),
14704            11,
14705            "worker-a",
14706            None,
14707        )
14708        .unwrap_err()
14709        .to_string()
14710        .contains("different logical session"));
14711        batch
14712            .logical_session
14713            .as_mut()
14714            .expect("logical identity")
14715            .session_id = session.id.clone();
14716        batch.source_actor_id = Some("worker-b".into());
14717        assert!(validate_actor_event_batch(
14718            &batch,
14719            &session,
14720            "logical-parent",
14721            Some("activation-7"),
14722            11,
14723            "worker-a",
14724            None,
14725        )
14726        .unwrap_err()
14727        .to_string()
14728        .contains("different physical actor"));
14729    }
14730
14731    #[tokio::test]
14732    async fn directory_event_ingress_rejects_old_attempt_and_forged_identity() {
14733        use bamboo_domain::Storage;
14734
14735        let temp = tempfile::tempdir().unwrap();
14736        let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
14737            .await
14738            .unwrap();
14739        let parent = Session::new("event-parent", "model");
14740        let child = Session::new_child_of("event-child", &parent, "model", "task");
14741        store.save_session(&parent).await.unwrap();
14742        store.save_session(&child).await.unwrap();
14743        store.ensure_actor(&child.id).await.unwrap();
14744        let now = chrono::Utc::now();
14745        let claim = |run_id: &str| ActorActivationClaim {
14746            actor_id: child.id.clone(),
14747            run_id: run_id.into(),
14748            lease_owner: "event-owner".into(),
14749            lease_expires_at: now + chrono::Duration::minutes(2),
14750            inbox_generation: 0,
14751            placement_ref: None,
14752            now,
14753        };
14754        let first = store.claim_activation(&claim("run-one")).await.unwrap();
14755        store.start_activation(&first.fence(), now).await.unwrap();
14756        let first_fence = first.fence();
14757        validate_directory_actor_event(&store, &first_fence, &child, Some("run-one"))
14758            .await
14759            .unwrap();
14760        validate_directory_actor_event(&store, &first_fence, &child, None)
14761            .await
14762            .unwrap(); // rolling-upgrade frame uses the same owner
14763        assert!(
14764            validate_directory_actor_event(&store, &first_fence, &child, Some("forged-run"))
14765                .await
14766                .is_err()
14767        );
14768        let mut forged = child.clone();
14769        forged.root_session_id = "foreign-root".into();
14770        assert!(
14771            validate_directory_actor_event(&store, &first_fence, &forged, Some("run-one"))
14772                .await
14773                .is_err()
14774        );
14775
14776        store
14777            .finish_activation(&first_fence, now, ActorActivationFinish::Failed)
14778            .await
14779            .unwrap();
14780        let second = store.claim_activation(&claim("run-two")).await.unwrap();
14781        store.start_activation(&second.fence(), now).await.unwrap();
14782        assert!(
14783            validate_directory_actor_event(&store, &first_fence, &child, Some("run-one"))
14784                .await
14785                .is_err()
14786        );
14787        assert!(
14788            validate_directory_actor_event(&store, &first_fence, &child, None)
14789                .await
14790                .is_err()
14791        );
14792        validate_directory_actor_event(&store, &second.fence(), &child, Some("run-two"))
14793            .await
14794            .unwrap();
14795    }
14796
14797    fn completed_actor_frame() -> ChildFrame {
14798        ChildFrame::Terminal {
14799            status: TerminalStatus::Completed,
14800            result: Some("done".to_string()),
14801            error: None,
14802            final_event_watermark: None,
14803            transcript: Vec::new(),
14804        }
14805    }
14806
14807    async fn drive_permission_handshake_frames(
14808        session_id: &str,
14809        frames: impl IntoIterator<Item = ChildFrame>,
14810        expected: ExpectedPermissionPosture,
14811    ) -> (
14812        crate::runtime::runner::Result<Option<String>>,
14813        Session,
14814        Vec<AgentEvent>,
14815        Vec<ParentFrame>,
14816    ) {
14817        let mut link = ConfirmationSequenceLink {
14818            frames: frames.into_iter().collect(),
14819            sent: Vec::new(),
14820        };
14821        let (event_tx, mut event_rx) = mpsc::channel(128);
14822        let cancel = CancellationToken::new();
14823        let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
14824        let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
14825        let mut session = Session::new(session_id, "model");
14826        let result = drive(ActorDriveContext {
14827            expected_creation: None,
14828            client: &mut link,
14829            parent_session_id: "permission-parent",
14830            child_session_id: session_id,
14831            child_attempt: 0,
14832            approval_registry: None,
14833            approval_decider: None,
14834            approval_reviewer: None,
14835            escalation_bridge: None,
14836            event_tx: &event_tx,
14837            cancel_token: &cancel,
14838            live_rx: &mut live_rx,
14839            delivery_rx: &mut delivery_rx,
14840            logical_session: &mut session,
14841            expected_permission_posture: Some(expected),
14842            session_inbox_runtime: None,
14843            actor_directory_store: None,
14844            canonical_subagent_tool: None,
14845            ticket_service: None,
14846            activation_run_id: None,
14847            execution_epoch: 0,
14848            expected_source_actor_id: session_id,
14849            initial_inflight_claims: VecDeque::new(),
14850            plain_actor: false,
14851            remote_environment_lease: false,
14852            readonly_output: None,
14853            local_history_tools: None,
14854            local_history_read_only: false,
14855            strict_history_output: None,
14856            plain_input: None,
14857            canonical_activation: None,
14858            canonical_placement_ref: None,
14859            actor_event_observer: None,
14860            plain_run: None,
14861            first_frame_timeout: Some(Duration::from_secs(1)),
14862        })
14863        .await;
14864        let events = std::iter::from_fn(|| event_rx.try_recv().ok()).collect();
14865        (result, session, events, link.sent)
14866    }
14867
14868    #[tokio::test]
14869    async fn actor_permission_handshake_rejects_terminal_without_posture() {
14870        let session_id = "permission-missing";
14871        let (result, session, events, _) = drive_permission_handshake_frames(
14872            session_id,
14873            [completed_actor_frame()],
14874            expected_default_permission_posture(7),
14875        )
14876        .await;
14877
14878        assert!(result
14879            .unwrap_err()
14880            .to_string()
14881            .contains("terminated before permission posture confirmation"));
14882        assert!(events.is_empty());
14883        assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
14884    }
14885
14886    #[tokio::test]
14887    async fn remote_environment_rejection_accepts_only_allowlisted_durable_terminal() {
14888        struct TerminalProofProbe {
14889            frame: Option<ChildFrame>,
14890            accepted: bool,
14891        }
14892
14893        #[async_trait]
14894        impl bamboo_subagent::ChildLink for TerminalProofProbe {
14895            async fn send(&mut self, _frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
14896                Ok(())
14897            }
14898
14899            async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
14900                Ok(self.frame.take())
14901            }
14902
14903            fn accept_durable_terminal(&mut self, status: TerminalStatus) {
14904                self.accepted = status == TerminalStatus::Error;
14905            }
14906
14907            fn has_pending_durable_terminal(&self) -> bool {
14908                self.accepted
14909            }
14910        }
14911
14912        for (status, error, expected_proof) in [
14913            (
14914                TerminalStatus::Error,
14915                Some("remote_environment_checkout_not_clean"),
14916                true,
14917            ),
14918            (TerminalStatus::Error, Some("untrusted worker error"), false),
14919            (
14920                TerminalStatus::Cancelled,
14921                Some("remote_environment_checkout_not_clean"),
14922                false,
14923            ),
14924        ] {
14925            let mut link = TerminalProofProbe {
14926                frame: Some(ChildFrame::Terminal {
14927                    status,
14928                    result: None,
14929                    error: error.map(str::to_owned),
14930                    final_event_watermark: None,
14931                    transcript: Vec::new(),
14932                }),
14933                accepted: false,
14934            };
14935            let (event_tx, _event_rx) = mpsc::channel(8);
14936            let cancel = CancellationToken::new();
14937            let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
14938            let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
14939            let mut session = Session::new("remote-terminal-proof", "model");
14940            let result = drive(ActorDriveContext {
14941                client: &mut link,
14942                parent_session_id: "proof-parent",
14943                child_session_id: "remote-terminal-proof",
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_default_permission_posture(7)),
14955                expected_creation: None,
14956                session_inbox_runtime: None,
14957                actor_directory_store: None,
14958                canonical_subagent_tool: None,
14959                ticket_service: None,
14960                activation_run_id: None,
14961                execution_epoch: 0,
14962                expected_source_actor_id: "remote-terminal-proof",
14963                initial_inflight_claims: VecDeque::new(),
14964                plain_actor: false,
14965                remote_environment_lease: true,
14966                readonly_output: None,
14967                local_history_tools: None,
14968                local_history_read_only: false,
14969                strict_history_output: None,
14970                plain_input: None,
14971                canonical_activation: None,
14972                canonical_placement_ref: None,
14973                actor_event_observer: None,
14974                plain_run: None,
14975                first_frame_timeout: Some(Duration::from_secs(1)),
14976            })
14977            .await;
14978            assert!(result.is_err());
14979            assert_eq!(
14980                bamboo_subagent::ChildLink::has_pending_durable_terminal(&link),
14981                expected_proof
14982            );
14983        }
14984    }
14985
14986    #[tokio::test(start_paused = true)]
14987    async fn remote_cancel_requires_selected_worker_terminal_for_release_proof() {
14988        struct CancelOutcomeLink {
14989            cancel_sent: bool,
14990            settle: bool,
14991            accepted: bool,
14992        }
14993
14994        #[async_trait]
14995        impl bamboo_subagent::ChildLink for CancelOutcomeLink {
14996            async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
14997                self.cancel_sent = matches!(frame, ParentFrame::Cancel);
14998                Ok(())
14999            }
15000
15001            async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
15002                if !self.cancel_sent || !self.settle {
15003                    std::future::pending::<bamboo_subagent::TransportResult<Option<ChildFrame>>>()
15004                        .await
15005                } else {
15006                    self.cancel_sent = false;
15007                    Ok(Some(ChildFrame::Terminal {
15008                        status: TerminalStatus::Cancelled,
15009                        result: None,
15010                        error: None,
15011                        final_event_watermark: None,
15012                        transcript: Vec::new(),
15013                    }))
15014                }
15015            }
15016
15017            fn accept_durable_terminal(&mut self, status: TerminalStatus) {
15018                self.accepted = status == TerminalStatus::Cancelled;
15019            }
15020
15021            fn has_pending_durable_terminal(&self) -> bool {
15022                self.accepted
15023            }
15024        }
15025
15026        let temp = tempfile::tempdir().unwrap();
15027        let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
15028            .await
15029            .unwrap();
15030        let fence = ActorActivationFence {
15031            schema_version: 2,
15032            actor_id: "cancelled-remote-child".into(),
15033            activation_id: "cancelled-remote-activation".into(),
15034            attempt: 1,
15035            run_id: "cancelled-remote-run".into(),
15036            lease_owner: "cancelled-remote-host".into(),
15037            lease_epoch: 1,
15038        };
15039        for settle in [true, false] {
15040            let mut link = CancelOutcomeLink {
15041                cancel_sent: false,
15042                settle,
15043                accepted: false,
15044            };
15045            let (event_tx, _event_rx) = mpsc::channel(8);
15046            let cancel = CancellationToken::new();
15047            cancel.cancel();
15048            let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
15049            let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
15050            let mut session = Session::new("cancelled-remote-child", "model");
15051            let result = drive(ActorDriveContext {
15052                expected_creation: None,
15053                client: &mut link,
15054                parent_session_id: "cancelled-remote-parent",
15055                child_session_id: "cancelled-remote-child",
15056                child_attempt: 1,
15057                approval_registry: None,
15058                approval_decider: None,
15059                approval_reviewer: None,
15060                escalation_bridge: None,
15061                event_tx: &event_tx,
15062                cancel_token: &cancel,
15063                live_rx: &mut live_rx,
15064                delivery_rx: &mut delivery_rx,
15065                logical_session: &mut session,
15066                expected_permission_posture: Some(expected_default_permission_posture(7)),
15067                session_inbox_runtime: None,
15068                actor_directory_store: Some(&store),
15069                canonical_subagent_tool: None,
15070                ticket_service: None,
15071                activation_run_id: Some("cancelled-remote-run"),
15072                execution_epoch: 0,
15073                expected_source_actor_id: "cancelled-remote-worker",
15074                initial_inflight_claims: VecDeque::new(),
15075                plain_actor: false,
15076                remote_environment_lease: true,
15077                readonly_output: None,
15078                local_history_tools: None,
15079                local_history_read_only: false,
15080                strict_history_output: None,
15081                plain_input: None,
15082                canonical_activation: Some(&fence),
15083                canonical_placement_ref: None,
15084                actor_event_observer: None,
15085                plain_run: None,
15086                first_frame_timeout: None,
15087            })
15088            .await;
15089            assert!(matches!(result, Err(AgentError::Cancelled)));
15090            assert_eq!(
15091                link.accepted, settle,
15092                "only a consumed exact terminal can release the remote fence"
15093            );
15094            assert_eq!(
15095                bamboo_subagent::ChildLink::has_pending_durable_terminal(&link),
15096                settle
15097            );
15098        }
15099    }
15100
15101    #[tokio::test]
15102    async fn actor_permission_handshake_rejects_malformed_agent_event() {
15103        let session_id = "permission-malformed";
15104        let (result, session, events, _) = drive_permission_handshake_frames(
15105            session_id,
15106            [ChildFrame::Event {
15107                event: serde_json::json!({"type": "token", "content": 42}),
15108            }],
15109            expected_default_permission_posture(7),
15110        )
15111        .await;
15112
15113        assert!(result
15114            .unwrap_err()
15115            .to_string()
15116            .contains("malformed AgentEvent"));
15117        assert!(events.is_empty());
15118        assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
15119
15120        let (result, _, events, _) = drive_permission_handshake_frames(
15121            session_id,
15122            [
15123                permission_posture_frame(session_id, 7),
15124                ChildFrame::Event {
15125                    event: serde_json::json!({"type":"private-download-secret"}),
15126                },
15127            ],
15128            expected_default_permission_posture(7),
15129        )
15130        .await;
15131        assert!(!result
15132            .unwrap_err()
15133            .to_string()
15134            .contains("private-download-secret"));
15135        assert_eq!(
15136            events.len(),
15137            1,
15138            "only the confirmed posture may be forwarded"
15139        );
15140    }
15141
15142    #[tokio::test]
15143    async fn actor_permission_handshake_rejects_execution_event_before_posture() {
15144        let session_id = "permission-early-event";
15145        let early_events = [
15146            (
15147                "progress",
15148                AgentEvent::RunnerProgress {
15149                    session_id: session_id.to_string(),
15150                    round_count: 1,
15151                },
15152            ),
15153            (
15154                "token",
15155                AgentEvent::Token {
15156                    content: "must-not-forward".to_string(),
15157                },
15158            ),
15159            (
15160                "tool",
15161                AgentEvent::ToolStart {
15162                    tool_call_id: "early-tool".to_string(),
15163                    tool_name: "Read".to_string(),
15164                    arguments: serde_json::json!({"file_path": "README.md"}),
15165                },
15166            ),
15167        ];
15168        for (kind, event) in early_events {
15169            let (result, session, events, _) = drive_permission_handshake_frames(
15170                session_id,
15171                [
15172                    actor_event_frame(event),
15173                    permission_posture_frame(session_id, 7),
15174                    completed_actor_frame(),
15175                ],
15176                expected_default_permission_posture(7),
15177            )
15178            .await;
15179
15180            assert!(
15181                result
15182                    .unwrap_err()
15183                    .to_string()
15184                    .contains("execution event before permission posture confirmation"),
15185                "{kind} must fail closed before posture"
15186            );
15187            assert!(events.is_empty(), "{kind} must not be forwarded");
15188            assert!(
15189                bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none(),
15190                "{kind} must not advance the permission audit"
15191            );
15192        }
15193    }
15194
15195    #[tokio::test]
15196    async fn actor_permission_handshake_rejects_approval_before_posture() {
15197        let session_id = "permission-early-approval";
15198        let (result, session, events, sent) = drive_permission_handshake_frames(
15199            session_id,
15200            [ChildFrame::ApprovalRequest {
15201                id: "approval-before-posture".to_string(),
15202                body: serde_json::json!({
15203                    "tool_name": "Bash",
15204                    "permission": "execute",
15205                    "resource": "echo must-not-run"
15206                }),
15207            }],
15208            expected_default_permission_posture(7),
15209        )
15210        .await;
15211
15212        assert!(result
15213            .unwrap_err()
15214            .to_string()
15215            .contains("requested approval before permission posture confirmation"));
15216        assert!(events.is_empty());
15217        assert!(
15218            sent.is_empty(),
15219            "an unconfirmed actor must receive no approval reply"
15220        );
15221        assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
15222    }
15223
15224    #[tokio::test]
15225    async fn actor_permission_handshake_rejects_mismatched_posture() {
15226        let session_id = "permission-mismatch";
15227        let (result, session, events, _) = drive_permission_handshake_frames(
15228            session_id,
15229            [permission_posture_frame(session_id, 8)],
15230            expected_default_permission_posture(7),
15231        )
15232        .await;
15233
15234        assert!(result
15235            .unwrap_err()
15236            .to_string()
15237            .contains("does not match the host-dispatched policy"));
15238        assert!(events.is_empty());
15239        assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
15240    }
15241
15242    #[tokio::test]
15243    async fn actor_permission_handshake_rejects_untrusted_executor_mapping() {
15244        let session_id = "permission-hostile-mapping";
15245        for hostile_mapping in [
15246            "wrong_executor:permission_mode=default",
15247            "test_actor:permission_mode=default;credential=must-not-persist",
15248        ] {
15249            let frame = ChildFrame::Event {
15250                event: serde_json::to_value(AgentEvent::PermissionPostureActivated {
15251                    session_id: session_id.to_string(),
15252                    policy_revision: 7,
15253                    requested_mode: "default".to_string(),
15254                    effective_mode: "default".to_string(),
15255                    executor_mapping: hostile_mapping.to_string(),
15256                })
15257                .expect("serialize hostile posture fixture"),
15258            };
15259            let (result, session, events, _) = drive_permission_handshake_frames(
15260                session_id,
15261                [frame],
15262                expected_default_permission_posture(7),
15263            )
15264            .await;
15265
15266            let error = result.unwrap_err().to_string();
15267            assert!(error.contains("host-dispatched executor mapping"));
15268            assert!(
15269                !error.contains(hostile_mapping),
15270                "untrusted mapping must not be reflected in host errors"
15271            );
15272            assert!(events.is_empty());
15273            assert!(
15274                bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none(),
15275                "untrusted mapping must not reach durable or in-memory audit state"
15276            );
15277        }
15278    }
15279
15280    #[tokio::test]
15281    async fn actor_permission_handshake_rejects_duplicate_posture() {
15282        let session_id = "permission-duplicate";
15283        let (result, session, events, _) = drive_permission_handshake_frames(
15284            session_id,
15285            [
15286                permission_posture_frame(session_id, 7),
15287                permission_posture_frame(session_id, 7),
15288                completed_actor_frame(),
15289            ],
15290            expected_default_permission_posture(7),
15291        )
15292        .await;
15293
15294        assert!(result
15295            .unwrap_err()
15296            .to_string()
15297            .contains("duplicate permission posture activation"));
15298        assert_eq!(
15299            events.len(),
15300            1,
15301            "only the confirmed posture may be forwarded"
15302        );
15303        assert!(matches!(
15304            events[0],
15305            AgentEvent::PermissionPostureActivated { .. }
15306        ));
15307        let audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
15308            .expect("the first matching posture must be recorded");
15309        assert_eq!(audit.policy_revision, 7);
15310    }
15311
15312    #[tokio::test]
15313    async fn actor_permission_handshake_happy_path_persists_before_forwarding_execution() {
15314        let session_id = "permission-happy";
15315        let (result, session, events, _) = drive_permission_handshake_frames(
15316            session_id,
15317            [
15318                permission_posture_frame(session_id, 7),
15319                actor_event_frame(AgentEvent::RunnerProgress {
15320                    session_id: session_id.to_string(),
15321                    round_count: 1,
15322                }),
15323                actor_event_frame(AgentEvent::Token {
15324                    content: "working".to_string(),
15325                }),
15326                actor_event_frame(AgentEvent::ToolStart {
15327                    tool_call_id: "tool-1".to_string(),
15328                    tool_name: "Read".to_string(),
15329                    arguments: serde_json::json!({"file_path": "README.md"}),
15330                }),
15331                completed_actor_frame(),
15332            ],
15333            expected_default_permission_posture(7),
15334        )
15335        .await;
15336
15337        assert_eq!(result.unwrap().as_deref(), Some("done"));
15338        assert!(matches!(
15339            events.as_slice(),
15340            [
15341                AgentEvent::PermissionPostureActivated { .. },
15342                AgentEvent::RunnerProgress { .. },
15343                AgentEvent::Token { .. },
15344                AgentEvent::ToolStart { .. }
15345            ]
15346        ));
15347        let audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
15348            .expect("matching posture must be recorded before execution events are accepted");
15349        assert_eq!(audit.policy_revision, 7);
15350        assert_eq!(audit.executor_mapping, "test_actor:permission_mode=default");
15351    }
15352
15353    #[tokio::test]
15354    async fn actor_download_tool_start_is_projected_before_parent_forwarding() {
15355        for (index, tool_name) in ["browser", "default::browser", "private-namespace::browser"]
15356            .into_iter()
15357            .enumerate()
15358        {
15359            let session_id = format!("actor-download-{index}");
15360            let args = serde_json::json!({
15361                "action":"download",
15362                "selector":"a[data-secret='private-selector']",
15363                "expected_epoch":17,
15364                "data_base64":"private-extra-bytes",
15365                "extra":{"url":"https://private.test/file"},
15366            });
15367            let original = args.clone();
15368            let read_args = serde_json::json!({"file_path":"README.md"});
15369            let (result, _session, events, _) = drive_permission_handshake_frames(
15370                &session_id,
15371                [
15372                    permission_posture_frame(&session_id, 7),
15373                    actor_event_frame(AgentEvent::ToolStart {
15374                        tool_call_id: "download-call".to_string(),
15375                        tool_name: tool_name.to_string(),
15376                        arguments: args.clone(),
15377                    }),
15378                    actor_event_frame(AgentEvent::ToolStart {
15379                        tool_call_id: "read-call".to_string(),
15380                        tool_name: "Read".to_string(),
15381                        arguments: read_args.clone(),
15382                    }),
15383                    completed_actor_frame(),
15384                ],
15385                expected_default_permission_posture(7),
15386            )
15387            .await;
15388            assert_eq!(result.expect("actor completed").as_deref(), Some("done"));
15389            assert_eq!(args, original);
15390            let starts: Vec<_> = events
15391                .iter()
15392                .filter_map(|event| match event {
15393                    AgentEvent::ToolStart {
15394                        tool_name,
15395                        arguments,
15396                        ..
15397                    } => Some((tool_name.as_str(), arguments)),
15398                    _ => None,
15399                })
15400                .collect();
15401            assert_eq!(starts.len(), 2);
15402            assert_eq!(starts[0].0, "browser");
15403            assert_eq!(
15404                starts[0].1,
15405                &serde_json::json!({"action":"download","expected_epoch":17})
15406            );
15407            assert_eq!(starts[1], ("Read", &read_args));
15408            assert!(!format!("{events:?}").contains("private"));
15409        }
15410    }
15411
15412    #[tokio::test]
15413    async fn actor_hook_lifecycle_hides_every_decision_text_across_event_batch_and_nested_stream() {
15414        let session_id = "actor-hook-display";
15415        let private = "private-selector-url-filename-response";
15416        let hook_name = format!("audit-{private}");
15417        let hook = |duration_ms, phase: &str, decision| AgentEvent::HookLifecycle {
15418            hook_name: hook_name.clone(),
15419            point: bamboo_domain::AgentHookPoint::BeforeToolExecution,
15420            phase: phase.into(),
15421            duration_ms,
15422            decision,
15423        };
15424        let decisions = [
15425            HookResult::Deny {
15426                reason: private.into(),
15427            },
15428            HookResult::InjectContext {
15429                text: private.into(),
15430            },
15431            HookResult::WithContext {
15432                result: Box::new(HookResult::WithContext {
15433                    result: Box::new(HookResult::Deny {
15434                        reason: private.into(),
15435                    }),
15436                    text: private.into(),
15437                }),
15438                text: private.into(),
15439            },
15440            HookResult::Suspend {
15441                reason: private.into(),
15442            },
15443            HookResult::Abort {
15444                reason: private.into(),
15445            },
15446        ];
15447        let originals = decisions.to_vec();
15448        let batch_events = [
15449            hook(2, "completed", decisions[1].clone()),
15450            hook(3, "completed", decisions[2].clone()),
15451            hook(4, "completed", decisions[3].clone()),
15452        ]
15453        .into_iter()
15454        .map(|event| serde_json::to_value(event).unwrap())
15455        .collect::<Vec<_>>();
15456        let batch = ActorEventBatch {
15457            logical_session: Some(LogicalSessionIdentity {
15458                creation: None,
15459                session_id: session_id.into(),
15460                parent_session_id: Some("permission-parent".into()),
15461                root_session_id: session_id.into(),
15462            }),
15463            activation_id: None,
15464            execution_epoch: 0,
15465            source_node_id: None,
15466            source_actor_id: Some(session_id.into()),
15467            first_seq: 1,
15468            last_seq: batch_events.len() as u64,
15469            qos: bamboo_subagent::ActorEventQos::classify(&batch_events[0]),
15470            events: batch_events,
15471        };
15472        let nested = AgentEvent::SubAgentEvent {
15473            parent_session_id: "parent".into(),
15474            child_session_id: "child".into(),
15475            event: Box::new(hook(5, "completed", decisions[4].clone())),
15476        };
15477        let raw = serde_json::to_string(&originals).unwrap();
15478        assert!(raw.contains(private));
15479
15480        let (outcome, _session, forwarded, _) = drive_permission_handshake_frames(
15481            session_id,
15482            [
15483                permission_posture_frame(session_id, 7),
15484                actor_event_frame(hook(1, private, decisions[0].clone())),
15485                ChildFrame::EventBatch { batch },
15486                actor_event_frame(nested),
15487                completed_actor_frame(),
15488            ],
15489            expected_default_permission_posture(7),
15490        )
15491        .await;
15492        assert_eq!(outcome.unwrap().as_deref(), Some("done"));
15493        let wire = serde_json::to_string(&forwarded).unwrap();
15494        assert!(
15495            !wire.contains(private),
15496            "private hook text reached SSE: {wire}"
15497        );
15498
15499        let hooks = forwarded
15500            .iter()
15501            .filter_map(|event| match event {
15502                AgentEvent::HookLifecycle {
15503                    hook_name,
15504                    point,
15505                    phase,
15506                    duration_ms,
15507                    decision,
15508                } => Some((hook_name, point, phase, duration_ms, decision)),
15509                _ => None,
15510            })
15511            .collect::<Vec<_>>();
15512        assert_eq!(hooks.len(), 4);
15513        assert!(hooks.iter().all(|(name, point, _, _, _)| {
15514            name == &&"actor-hook-1".to_string()
15515                && **point == bamboo_domain::AgentHookPoint::BeforeToolExecution
15516        }));
15517        assert_eq!(hooks[0].2, "event");
15518        assert_eq!(hooks[1].2, "completed");
15519        assert_eq!(*hooks[0].3, 1);
15520        assert_eq!(*hooks[3].3, 4);
15521        assert!(matches!(hooks[0].4, HookResult::Deny { reason } if reason == "Hook denied"));
15522        assert!(
15523            matches!(hooks[1].4, HookResult::InjectContext { text } if text == "Hook context hidden")
15524        );
15525        assert!(
15526            matches!(hooks[2].4, HookResult::WithContext { result, text }
15527            if text == "Hook context hidden" && matches!(result.as_ref(),
15528                HookResult::WithContext { result, text }
15529                    if text == "Hook context hidden" && matches!(result.as_ref(),
15530                        HookResult::Deny { reason } if reason == "Hook denied")))
15531        );
15532        assert!(matches!(hooks[3].4, HookResult::Suspend { reason } if reason == "Hook suspended"));
15533        assert!(forwarded.iter().any(|event| matches!(event,
15534            AgentEvent::SubAgentEvent { event, .. }
15535                if matches!(event.as_ref(), AgentEvent::HookLifecycle {
15536                    hook_name, duration_ms: 5,
15537                    decision: HookResult::Abort { reason }, ..
15538                } if hook_name == "actor-hook-1" && reason == "Hook aborted")
15539        )));
15540    }
15541
15542    #[test]
15543    fn actor_hook_display_depth_and_identity_limits_fail_closed() {
15544        let mut display = ActorEventDisplay::default();
15545        let mut deep = HookResult::Allow;
15546        for _ in 0..=MAX_DISPLAY_HOOK_RESULT_DEPTH {
15547            deep = HookResult::WithContext {
15548                result: Box::new(deep),
15549                text: "private-download-bytes".into(),
15550            };
15551        }
15552        let event = |hook_name: String, decision| AgentEvent::HookLifecycle {
15553            hook_name,
15554            point: bamboo_domain::AgentHookPoint::BeforeToolExecution,
15555            phase: "completed".into(),
15556            duration_ms: 7,
15557            decision,
15558        };
15559        assert!(display.project(event("deep".into(), deep), 0).is_none());
15560        assert!(display.overflowed);
15561        assert!(display
15562            .project(
15563                event(
15564                    "private-download-bytes".into(),
15565                    HookResult::Deny {
15566                        reason: "private-download-bytes".into(),
15567                    },
15568                ),
15569                0,
15570            )
15571            .is_none());
15572
15573        let mut oversized = ActorEventDisplay::default();
15574        assert!(oversized
15575            .project(
15576                event("x".repeat(MAX_DISPLAY_ID_BYTES + 1), HookResult::Allow),
15577                0,
15578            )
15579            .is_none());
15580        assert!(oversized.overflowed);
15581
15582        // A new actor connection gets a fresh opaque alias, never the raw
15583        // hook name. Repeated names keep the same alias until the shared
15584        // identity budget is exhausted, then the stream fails closed.
15585        let mut reconnected = ActorEventDisplay::default();
15586        let private = "private-download-bytes";
15587        for _ in 0..2 {
15588            let projected = reconnected
15589                .project(event(private.into(), HookResult::Allow), 0)
15590                .unwrap();
15591            assert!(
15592                matches!(projected, AgentEvent::HookLifecycle { hook_name, .. }
15593                if hook_name == "actor-hook-1")
15594            );
15595        }
15596        assert_eq!(reconnected.identities.load(Ordering::Relaxed), 1);
15597        for index in 1..MAX_DISPLAY_CALLS {
15598            assert!(reconnected
15599                .project(event(format!("hook-{index}"), HookResult::Allow), 0)
15600                .is_some());
15601        }
15602        assert!(reconnected
15603            .project(event("overflow-private".into(), HookResult::Allow), 0)
15604            .is_none());
15605        assert!(reconnected.overflowed);
15606        assert!(reconnected.hook_names.is_empty());
15607    }
15608
15609    #[test]
15610    fn actor_hook_display_preserves_nontext_decision_kinds() {
15611        for result in [
15612            HookResult::Continue,
15613            HookResult::Mutated,
15614            HookResult::Allow,
15615            HookResult::Ask,
15616        ] {
15617            assert_eq!(hook_result_for_display(result.clone(), 0), Some(result));
15618        }
15619    }
15620
15621    #[tokio::test]
15622    async fn malformed_actor_hook_events_never_fall_back_to_raw_display() {
15623        let session_id = "actor-malformed-hook";
15624        let private = "private-download-selector";
15625        let original = AgentEvent::HookLifecycle {
15626            hook_name: "audit".into(),
15627            point: bamboo_domain::AgentHookPoint::BeforeToolExecution,
15628            phase: "completed".into(),
15629            duration_ms: 9,
15630            decision: HookResult::Deny {
15631                reason: private.into(),
15632            },
15633        };
15634        let mut invalid = serde_json::to_value(&original).unwrap();
15635        invalid["decision"]["type"] = serde_json::json!("unknown_decision");
15636        let nested = AgentEvent::SubAgentEvent {
15637            parent_session_id: "parent".into(),
15638            child_session_id: "child".into(),
15639            event: Box::new(original),
15640        };
15641        let mut invalid_nested = serde_json::to_value(nested).unwrap();
15642        invalid_nested["event"]["decision"]["type"] = serde_json::json!("unknown_decision");
15643        let batch = ActorEventBatch {
15644            logical_session: Some(LogicalSessionIdentity {
15645                creation: None,
15646                session_id: session_id.into(),
15647                parent_session_id: Some("permission-parent".into()),
15648                root_session_id: session_id.into(),
15649            }),
15650            activation_id: None,
15651            execution_epoch: 0,
15652            source_node_id: None,
15653            source_actor_id: Some(session_id.into()),
15654            first_seq: 1,
15655            last_seq: 1,
15656            qos: bamboo_subagent::ActorEventQos::classify(&invalid),
15657            events: vec![invalid.clone()],
15658        };
15659        for frame in [
15660            ChildFrame::Event {
15661                event: invalid.clone(),
15662            },
15663            ChildFrame::EventBatch { batch },
15664            ChildFrame::Event {
15665                event: invalid_nested,
15666            },
15667        ] {
15668            let (outcome, _session, forwarded, _) = drive_permission_handshake_frames(
15669                session_id,
15670                [permission_posture_frame(session_id, 7), frame],
15671                expected_default_permission_posture(7),
15672            )
15673            .await;
15674            let error = outcome.unwrap_err().to_string();
15675            assert!(error.contains("malformed AgentEvent"), "{error}");
15676            assert!(!error.contains(private));
15677            assert!(!serde_json::to_string(&forwarded).unwrap().contains(private));
15678        }
15679
15680        let (event_tx, mut event_rx) = mpsc::channel(8);
15681        let mut session = Session::new("legacy-hook", "model");
15682        let mut handshake = PermissionPostureHandshake::NotRequired;
15683        let mut display = ActorEventDisplay::default();
15684        process_actor_event(
15685            invalid,
15686            false,
15687            &mut handshake,
15688            None,
15689            None,
15690            &mut session,
15691            &event_tx,
15692            &mut display,
15693        )
15694        .await
15695        .unwrap();
15696        assert!(display.overflowed);
15697        assert!(event_rx.try_recv().is_err());
15698    }
15699
15700    #[tokio::test]
15701    async fn actor_download_event_batch_hides_output_and_preserves_read_output() {
15702        let session_id = "actor-download-batch";
15703        let private = "private-download-bytes-and-url";
15704        let events = vec![
15705            AgentEvent::ToolStart {
15706                tool_call_id: "download-call".into(),
15707                tool_name: "default::browser".into(),
15708                arguments: serde_json::json!({
15709                    "action":"download", "selector": private, "expected_epoch": 17,
15710                }),
15711            },
15712            AgentEvent::ToolApprovalRequested {
15713                tool_call_id: "download-call".into(),
15714                tool_name: format!("{private}::browser"),
15715                parameters: serde_json::json!({
15716                    "action":"download", "selector": private, "expected_epoch": 17,
15717                }),
15718            },
15719            AgentEvent::ToolStart {
15720                tool_call_id: "read-call".into(),
15721                tool_name: "Read".into(),
15722                arguments: serde_json::json!({"file_path":"README.md"}),
15723            },
15724        ];
15725        let batch = ActorEventBatch {
15726            logical_session: Some(LogicalSessionIdentity {
15727                creation: None,
15728                session_id: session_id.into(),
15729                parent_session_id: Some("permission-parent".into()),
15730                root_session_id: session_id.into(),
15731            }),
15732            activation_id: None,
15733            execution_epoch: 0,
15734            source_node_id: None,
15735            source_actor_id: Some(session_id.into()),
15736            first_seq: 1,
15737            last_seq: events.len() as u64,
15738            qos: bamboo_subagent::ActorEventQos::Durable,
15739            events: events
15740                .into_iter()
15741                .map(|event| serde_json::to_value(event).unwrap())
15742                .collect(),
15743        };
15744        let result = bamboo_agent_core::tools::ToolResult {
15745            success: true,
15746            result: format!("{{\"data_base64\":\"{private}\"}}"),
15747            display_preference: Some(private.into()),
15748            images: vec![bamboo_agent_core::tools::ToolResultImage {
15749                mime_type: "image/png".into(),
15750                data: private.into(),
15751            }],
15752        };
15753        let (outcome, _, forwarded, _) = drive_permission_handshake_frames(
15754            session_id,
15755            [
15756                permission_posture_frame(session_id, 7),
15757                ChildFrame::EventBatch { batch },
15758                actor_event_frame(AgentEvent::ToolToken {
15759                    tool_call_id: "download-call".into(),
15760                    content: private.into(),
15761                }),
15762                actor_event_frame(AgentEvent::ToolToken {
15763                    tool_call_id: "read-call".into(),
15764                    content: "public live output".into(),
15765                }),
15766                actor_event_frame(AgentEvent::ToolComplete {
15767                    tool_call_id: "download-call".into(),
15768                    result,
15769                }),
15770                actor_event_frame(AgentEvent::ToolLifecycle {
15771                    tool_call_id: "download-call".into(),
15772                    tool_name: "browser".into(),
15773                    phase: "finished".into(),
15774                    elapsed_ms: Some(1),
15775                    is_mutating: false,
15776                    auto_approved: false,
15777                    summary: Some(private.into()),
15778                    error: Some(private.into()),
15779                }),
15780                actor_event_frame(AgentEvent::ToolError {
15781                    tool_call_id: "download-call".into(),
15782                    error: private.into(),
15783                }),
15784                actor_event_frame(AgentEvent::ToolComplete {
15785                    tool_call_id: "read-call".into(),
15786                    result: bamboo_agent_core::tools::ToolResult::text(true, "public README text"),
15787                }),
15788                completed_actor_frame(),
15789            ],
15790            expected_default_permission_posture(7),
15791        )
15792        .await;
15793        assert_eq!(outcome.unwrap().as_deref(), Some("done"));
15794        let wire = serde_json::to_string(&forwarded).unwrap();
15795        assert!(
15796            !wire.contains(private),
15797            "private content reached actor SSE: {wire}"
15798        );
15799        assert!(wire.contains("public README text"));
15800        assert!(wire.contains("public live output"));
15801        assert!(forwarded.iter().any(|event| matches!(event,
15802            AgentEvent::ToolComplete { tool_call_id, result }
15803                if tool_call_id == "download-call"
15804                && result.result == "Browser tool result hidden"
15805                && result.images.is_empty() && result.display_preference.is_none()
15806        )));
15807    }
15808
15809    #[tokio::test]
15810    async fn actor_missing_reused_and_nested_tool_identity_fails_closed() {
15811        let private = "private-download-content";
15812        let nested = |event| AgentEvent::SubAgentEvent {
15813            parent_session_id: "parent".into(),
15814            child_session_id: "nested-child".into(),
15815            event: Box::new(event),
15816        };
15817        let (outcome, _, forwarded, _) = drive_permission_handshake_frames(
15818            "actor-reorder",
15819            [
15820                permission_posture_frame("actor-reorder", 7),
15821                actor_event_frame(AgentEvent::ToolComplete {
15822                    tool_call_id: "missing".into(),
15823                    result: bamboo_agent_core::tools::ToolResult::text(true, private),
15824                }),
15825                actor_event_frame(AgentEvent::ToolLifecycle {
15826                    tool_call_id: "missing".into(),
15827                    tool_name: "browser".into(),
15828                    phase: private.into(),
15829                    elapsed_ms: None,
15830                    is_mutating: false,
15831                    auto_approved: false,
15832                    summary: Some(private.into()),
15833                    error: Some(private.into()),
15834                }),
15835                actor_event_frame(AgentEvent::ToolApprovalRequested {
15836                    tool_call_id: "approval-only".into(),
15837                    tool_name: "browser".into(),
15838                    parameters: serde_json::json!({"action":"download","selector":private}),
15839                }),
15840                actor_event_frame(AgentEvent::ToolToken {
15841                    tool_call_id: "approval-only".into(),
15842                    content: private.into(),
15843                }),
15844                actor_event_frame(AgentEvent::ToolStart {
15845                    tool_call_id: "malformed-browser".into(),
15846                    tool_name: format!("{private}::browser"),
15847                    arguments: serde_json::json!({"selector":private}),
15848                }),
15849                actor_event_frame(AgentEvent::ToolComplete {
15850                    tool_call_id: "malformed-browser".into(),
15851                    result: bamboo_agent_core::tools::ToolResult::text(true, private),
15852                }),
15853                actor_event_frame(AgentEvent::ToolStart {
15854                    tool_call_id: "reused".into(),
15855                    tool_name: "Read".into(),
15856                    arguments: serde_json::json!({"file_path":"README.md"}),
15857                }),
15858                actor_event_frame(AgentEvent::ToolStart {
15859                    tool_call_id: "reused".into(),
15860                    tool_name: "browser".into(),
15861                    arguments: serde_json::json!({"action":"download","selector":private}),
15862                }),
15863                actor_event_frame(AgentEvent::ToolToken {
15864                    tool_call_id: "reused".into(),
15865                    content: private.into(),
15866                }),
15867                actor_event_frame(nested(AgentEvent::ToolStart {
15868                    tool_call_id: "nested".into(),
15869                    tool_name: "browser".into(),
15870                    arguments: serde_json::json!({"action":"download","selector":private}),
15871                })),
15872                actor_event_frame(nested(AgentEvent::ToolComplete {
15873                    tool_call_id: "nested".into(),
15874                    result: bamboo_agent_core::tools::ToolResult::text(true, private),
15875                })),
15876                actor_event_frame(AgentEvent::ChildApprovalRequested {
15877                    child_session_id: "nested-child".into(),
15878                    request_id: "approval".into(),
15879                    tool_name: format!("{private}::browser"),
15880                    permission: private.into(),
15881                    resource: private.into(),
15882                }),
15883                actor_event_frame(AgentEvent::ChildApprovalChanged {
15884                    parent_session_id: "parent".into(),
15885                    child_session_id: "nested-child".into(),
15886                    child_attempt: 1,
15887                    request_id: "approval".into(),
15888                    version: 1,
15889                    status: "pending".into(),
15890                    reason: Some(private.into()),
15891                    tool_name: "browser".into(),
15892                    permission: private.into(),
15893                    resource: private.into(),
15894                    created_at: "2026-01-01T00:00:00Z".into(),
15895                    resolved_at: None,
15896                }),
15897                completed_actor_frame(),
15898            ],
15899            expected_default_permission_posture(7),
15900        )
15901        .await;
15902        assert_eq!(outcome.unwrap().as_deref(), Some("done"));
15903        let wire = serde_json::to_string(&forwarded).unwrap();
15904        assert!(
15905            !wire.contains(private),
15906            "private content reached actor SSE: {wire}"
15907        );
15908        assert!(wire.contains("Tool result hidden"));
15909        assert!(wire.contains("Browser tool result hidden"));
15910        assert!(wire.contains("Browser interaction approval"));
15911        assert!(!forwarded
15912            .iter()
15913            .any(|event| matches!(event, AgentEvent::ChildApprovalChanged { .. })));
15914    }
15915
15916    #[test]
15917    fn actor_display_tracking_overflow_never_reclassifies_old_private_id() {
15918        let mut display = ActorEventDisplay::default();
15919        display
15920            .project(
15921                AgentEvent::ToolStart {
15922                    tool_call_id: "private-call".into(),
15923                    tool_name: "browser".into(),
15924                    arguments: serde_json::json!({"action":"download","selector":"private"}),
15925                },
15926                0,
15927            )
15928            .unwrap();
15929        for index in 0..MAX_DISPLAY_CALLS {
15930            display
15931                .project(
15932                    AgentEvent::ToolStart {
15933                        tool_call_id: format!("ordinary-{index}"),
15934                        tool_name: "Read".into(),
15935                        arguments: serde_json::json!({"file_path":"README.md"}),
15936                    },
15937                    0,
15938                )
15939                .unwrap();
15940        }
15941        assert!(display.overflowed);
15942        let forwarded = display
15943            .project(
15944                AgentEvent::ToolComplete {
15945                    tool_call_id: "private-call".into(),
15946                    result: bamboo_agent_core::tools::ToolResult::text(true, "private-bytes"),
15947                },
15948                0,
15949            )
15950            .unwrap();
15951        assert!(!serde_json::to_string(&forwarded)
15952            .unwrap()
15953            .contains("private-bytes"));
15954    }
15955
15956    #[test]
15957    fn nested_actor_display_uses_one_identity_budget_and_bounded_keys() {
15958        let mut display = ActorEventDisplay::default();
15959        let nested = |index: usize| AgentEvent::SubAgentEvent {
15960            parent_session_id: "parent".into(),
15961            child_session_id: format!("child-{index}"),
15962            event: Box::new(AgentEvent::ToolStart {
15963                tool_call_id: format!("read-{index}"),
15964                tool_name: "Read".into(),
15965                arguments: serde_json::json!({"file_path":"README.md"}),
15966            }),
15967        };
15968        for index in 0..(MAX_DISPLAY_CALLS / 2) {
15969            assert!(display.project(nested(index), 0).is_some());
15970        }
15971        assert_eq!(
15972            display.identities.load(Ordering::Relaxed),
15973            MAX_DISPLAY_CALLS
15974        );
15975        assert!(display.project(nested(MAX_DISPLAY_CALLS / 2), 0).is_none());
15976        assert!(display.overflowed);
15977        assert!(display.children.is_empty());
15978
15979        let mut oversized = ActorEventDisplay::default();
15980        assert!(oversized
15981            .project(
15982                AgentEvent::SubAgentEvent {
15983                    parent_session_id: "parent".into(),
15984                    child_session_id: "x".repeat(MAX_DISPLAY_ID_BYTES + 1),
15985                    event: Box::new(AgentEvent::ToolComplete {
15986                        tool_call_id: "call".into(),
15987                        result: bamboo_agent_core::tools::ToolResult::text(
15988                            true,
15989                            "private-download-bytes",
15990                        ),
15991                    }),
15992                },
15993                0,
15994            )
15995            .is_none());
15996        assert!(oversized.overflowed);
15997        assert!(oversized
15998            .project(
15999                AgentEvent::ToolStart {
16000                    tool_call_id: "x".repeat(MAX_DISPLAY_ID_BYTES + 1),
16001                    tool_name: "Read".into(),
16002                    arguments: serde_json::json!({"file_path":"README.md"}),
16003                },
16004                0,
16005            )
16006            .is_none());
16007    }
16008
16009    #[tokio::test]
16010    async fn actor_batch_sequence_gap_invalidates_prior_tool_identity() {
16011        let session_id = "actor-gap";
16012        let batch = |seq, event: AgentEvent| {
16013            let event = serde_json::to_value(event).unwrap();
16014            ChildFrame::EventBatch {
16015                batch: ActorEventBatch {
16016                    logical_session: Some(LogicalSessionIdentity {
16017                        creation: None,
16018                        session_id: session_id.into(),
16019                        parent_session_id: Some("permission-parent".into()),
16020                        root_session_id: session_id.into(),
16021                    }),
16022                    activation_id: None,
16023                    execution_epoch: 0,
16024                    source_node_id: None,
16025                    source_actor_id: Some(session_id.into()),
16026                    first_seq: seq,
16027                    last_seq: seq,
16028                    qos: bamboo_subagent::ActorEventQos::classify(&event),
16029                    events: vec![event],
16030                },
16031            }
16032        };
16033        let (outcome, _, forwarded, _) = drive_permission_handshake_frames(
16034            session_id,
16035            [
16036                permission_posture_frame(session_id, 7),
16037                batch(
16038                    1,
16039                    AgentEvent::ToolStart {
16040                        tool_call_id: "reused-call".into(),
16041                        tool_name: "Read".into(),
16042                        arguments: serde_json::json!({"file_path":"README.md"}),
16043                    },
16044                ),
16045                // Sequence 2 may have replaced this ID with a private browser
16046                // call. The actor's next result must not inherit Read display.
16047                batch(
16048                    3,
16049                    AgentEvent::ToolComplete {
16050                        tool_call_id: "reused-call".into(),
16051                        result: bamboo_agent_core::tools::ToolResult::text(
16052                            true,
16053                            "private-download-bytes",
16054                        ),
16055                    },
16056                ),
16057                completed_actor_frame(),
16058            ],
16059            expected_default_permission_posture(7),
16060        )
16061        .await;
16062        assert_eq!(outcome.unwrap().as_deref(), Some("done"));
16063        let wire = serde_json::to_string(&forwarded).unwrap();
16064        assert!(!wire.contains("private-download-bytes"), "{wire}");
16065        assert!(wire.contains("Tool result hidden"));
16066    }
16067
16068    #[tokio::test]
16069    async fn legacy_malformed_actor_event_invalidates_prior_tool_identity() {
16070        let (event_tx, mut event_rx) = mpsc::channel(8);
16071        let mut session = Session::new("legacy-malformed-private", "model");
16072        let mut handshake = PermissionPostureHandshake::NotRequired;
16073        let mut display = ActorEventDisplay::default();
16074        let read_start = serde_json::to_value(AgentEvent::ToolStart {
16075            tool_call_id: "reused-call".into(),
16076            tool_name: "Read".into(),
16077            arguments: serde_json::json!({"file_path":"README.md"}),
16078        })
16079        .unwrap();
16080        process_actor_event(
16081            read_start,
16082            false,
16083            &mut handshake,
16084            None,
16085            None,
16086            &mut session,
16087            &event_tx,
16088            &mut display,
16089        )
16090        .await
16091        .unwrap();
16092
16093        // A mixed-version actor may send a browser ToolStart whose name has
16094        // the wrong wire type. Serde drops it; the reused ID is no longer safe.
16095        let mut malformed = serde_json::to_value(AgentEvent::ToolStart {
16096            tool_call_id: "reused-call".into(),
16097            tool_name: "browser".into(),
16098            arguments: serde_json::json!({
16099                "action":"download", "selector":"private-download-selector"
16100            }),
16101        })
16102        .unwrap();
16103        malformed["tool_name"] = serde_json::json!(7);
16104        process_actor_event(
16105            malformed,
16106            false,
16107            &mut handshake,
16108            None,
16109            None,
16110            &mut session,
16111            &event_tx,
16112            &mut display,
16113        )
16114        .await
16115        .unwrap();
16116        process_actor_event(
16117            serde_json::to_value(AgentEvent::ToolComplete {
16118                tool_call_id: "reused-call".into(),
16119                result: bamboo_agent_core::tools::ToolResult::text(true, "private-download-bytes"),
16120            })
16121            .unwrap(),
16122            false,
16123            &mut handshake,
16124            None,
16125            None,
16126            &mut session,
16127            &event_tx,
16128            &mut display,
16129        )
16130        .await
16131        .unwrap();
16132        let forwarded = std::iter::from_fn(|| event_rx.try_recv().ok()).collect::<Vec<_>>();
16133        assert_eq!(forwarded.len(), 2);
16134        let wire = serde_json::to_string(&forwarded).unwrap();
16135        assert!(!wire.contains("private-download"), "{wire}");
16136        assert!(wire.contains("Tool result hidden"));
16137    }
16138
16139    #[tokio::test]
16140    async fn actor_initial_batch_acks_in_order_and_rejects_stale_confirmation() {
16141        let temp = tempfile::tempdir().unwrap();
16142        let store = Arc::new(
16143            bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
16144                .await
16145                .unwrap(),
16146        );
16147        let storage: Arc<dyn Storage> = store.clone();
16148        let locked = Arc::new(bamboo_storage::LockedSessionStore::new(storage.clone()));
16149        let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
16150            store.clone(),
16151            bamboo_domain::SessionInboxLimits::default(),
16152        ));
16153        let session_id = "actor-confirmation-order";
16154        let run_id = "actor-run-current";
16155        let mut session = Session::new(session_id, "model");
16156        store.save_session(&session).await.unwrap();
16157        for (id, text) in [("actor-first", "first"), ("actor-second", "second")] {
16158            let mut envelope = bamboo_domain::SessionMessageEnvelope::user_input(session_id, text);
16159            envelope.id = bamboo_domain::SessionMessageId::parse(id).unwrap();
16160            inbox.deliver(&envelope).await.unwrap();
16161        }
16162        inbox
16163            .mark_activation_eligible(
16164                session_id,
16165                2,
16166                bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
16167            )
16168            .await
16169            .unwrap();
16170        let router = SessionActivationRouter::new();
16171        let mut owner_registration = router.register_run(session_id, run_id).await.unwrap();
16172        let binding = SessionInboxRuntimeBinding {
16173            router,
16174            inbox: inbox.clone(),
16175            storage,
16176            persistence: locked,
16177            parent_question_lock: None,
16178        };
16179        let pairs = claim_canonical_deliveries(&binding, &mut session, run_id, usize::MAX, None)
16180            .await
16181            .unwrap();
16182        assert_eq!(
16183            pairs
16184                .iter()
16185                .map(|(claim, _)| claim.envelope.id.as_str())
16186                .collect::<Vec<_>>(),
16187            vec!["actor-first", "actor-second"]
16188        );
16189        let seeded = store.load_session(session_id).await.unwrap().unwrap();
16190        assert_eq!(
16191            seeded
16192                .messages
16193                .iter()
16194                .filter(|message| matches!(message.id.as_str(), "actor-first" | "actor-second"))
16195                .map(|message| message.id.as_str())
16196                .collect::<Vec<_>>(),
16197            vec!["actor-first", "actor-second"],
16198            "host context must be durable before actor dispatch"
16199        );
16200        for (claim, _) in &pairs {
16201            assert_eq!(
16202                seeded
16203                    .messages
16204                    .iter()
16205                    .filter(|message| bamboo_domain::is_matching_session_message(
16206                        message,
16207                        &claim.envelope
16208                    ))
16209                    .count(),
16210                1,
16211                "pre-dispatch host checkpoint must contain exactly one canonical marker for {}",
16212                claim.envelope.id
16213            );
16214        }
16215        assert!(
16216            seeded.session_inbox_admission().is_none_or(|cursor| {
16217                !cursor.contains(&pairs[0].0.envelope.id)
16218                    && !cursor.contains(&pairs[1].0.envelope.id)
16219            }),
16220            "pre-dispatch transcript seeding must not forge worker confirmation"
16221        );
16222        assert_eq!(inbox.inspect(session_id).await.unwrap().claimed, 2);
16223        let claims = pairs
16224            .into_iter()
16225            .map(|(claim, _)| claim)
16226            .collect::<VecDeque<_>>();
16227        let first = claims[0].clone();
16228        let second = claims[1].clone();
16229        let mut stale = admission_confirmation(session_id, &first, "stale-run");
16230        stale.canonical_claim_generation = second.generation;
16231        let mut link = ConfirmationSequenceLink {
16232            frames: VecDeque::from([
16233                ChildFrame::SessionMessageAdmitted {
16234                    confirmation: stale,
16235                },
16236                ChildFrame::SessionMessageAdmitted {
16237                    confirmation: admission_confirmation(session_id, &first, run_id),
16238                },
16239                ChildFrame::SessionMessageAdmitted {
16240                    confirmation: admission_confirmation(session_id, &second, run_id),
16241                },
16242                ChildFrame::Terminal {
16243                    status: TerminalStatus::Completed,
16244                    result: Some("done".to_string()),
16245                    error: None,
16246                    final_event_watermark: None,
16247                    transcript: Vec::new(),
16248                },
16249            ]),
16250            sent: Vec::new(),
16251        };
16252        let (event_tx, _event_rx) = mpsc::channel(8);
16253        let cancel = CancellationToken::new();
16254        let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
16255        let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
16256        let result = drive(ActorDriveContext {
16257            expected_creation: None,
16258            client: &mut link,
16259            parent_session_id: "parent",
16260            child_session_id: session_id,
16261            child_attempt: 0,
16262            approval_registry: None,
16263            approval_decider: None,
16264            approval_reviewer: None,
16265            escalation_bridge: None,
16266            event_tx: &event_tx,
16267            cancel_token: &cancel,
16268            live_rx: &mut live_rx,
16269            delivery_rx: &mut delivery_rx,
16270            logical_session: &mut session,
16271            expected_permission_posture: None,
16272            session_inbox_runtime: Some(&binding),
16273            actor_directory_store: None,
16274            canonical_subagent_tool: None,
16275            ticket_service: None,
16276            activation_run_id: Some(run_id),
16277            execution_epoch: 0,
16278            expected_source_actor_id: session_id,
16279            initial_inflight_claims: claims,
16280            plain_actor: false,
16281            remote_environment_lease: false,
16282            readonly_output: None,
16283            local_history_tools: None,
16284            local_history_read_only: false,
16285            strict_history_output: None,
16286            plain_input: None,
16287            canonical_activation: None,
16288            canonical_placement_ref: None,
16289            actor_event_observer: None,
16290            plain_run: None,
16291            first_frame_timeout: Some(Duration::from_secs(1)),
16292        })
16293        .await
16294        .unwrap();
16295        assert_eq!(result.as_deref(), Some("done"));
16296        assert_eq!(
16297            session
16298                .messages
16299                .iter()
16300                .filter(|message| matches!(message.id.as_str(), "actor-first" | "actor-second"))
16301                .map(|message| message.id.as_str())
16302                .collect::<Vec<_>>(),
16303            vec!["actor-first", "actor-second"]
16304        );
16305        let backlog = inbox.inspect(session_id).await.unwrap();
16306        assert_eq!(backlog.pending + backlog.claimed, 0);
16307        let confirmed = store.load_session(session_id).await.unwrap().unwrap();
16308        let cursor = confirmed
16309            .session_inbox_admission()
16310            .expect("exact worker confirmation must checkpoint the admission cursor");
16311        assert!(cursor.contains(&first.envelope.id));
16312        assert!(cursor.contains(&second.envelope.id));
16313        for claim in [&first, &second] {
16314            assert_eq!(
16315                confirmed
16316                    .messages
16317                    .iter()
16318                    .filter(|message| bamboo_domain::is_matching_session_message(
16319                        message,
16320                        &claim.envelope
16321                    ))
16322                    .count(),
16323                1,
16324                "confirmation must retain one exact canonical marker for {}",
16325                claim.envelope.id
16326            );
16327        }
16328        assert!(inbox
16329            .was_admitted(session_id, &first.envelope.id)
16330            .await
16331            .unwrap());
16332        assert!(inbox
16333            .was_admitted(session_id, &second.envelope.id)
16334            .await
16335            .unwrap());
16336        owner_registration.begin_finalization().await;
16337        owner_registration.finish(2).await.unwrap();
16338    }
16339
16340    #[derive(Default)]
16341    struct RecordingCodexTokenAuthority {
16342        issued_for: std::sync::Mutex<Vec<String>>,
16343        revoked: std::sync::Mutex<Vec<String>>,
16344    }
16345
16346    impl CodexRunTokenAuthority for RecordingCodexTokenAuthority {
16347        fn issue(&self, session_id: &str) -> Result<IssuedCodexRunToken, String> {
16348            self.issued_for
16349                .lock()
16350                .expect("issued fixture lock")
16351                .push(session_id.to_string());
16352            Ok(IssuedCodexRunToken {
16353                token_id: format!("id-{session_id}"),
16354                token: format!("bcx1_secret-{session_id}"),
16355            })
16356        }
16357
16358        fn revoke(&self, token_id: &str) {
16359            self.revoked
16360                .lock()
16361                .expect("revoked fixture lock")
16362                .push(token_id.to_string());
16363        }
16364    }
16365
16366    fn codex_executor(auth_mode: Option<&str>, inherit_user_config: Option<bool>) -> ExecutorSpec {
16367        let bamboo_mode = auth_mode == Some("bamboo")
16368            || (auth_mode.is_none() && !inherit_user_config.unwrap_or(false));
16369        ExecutorSpec::Codex {
16370            binary: None,
16371            model: None,
16372            mode: None,
16373            sandbox: None,
16374            inherit_user_config,
16375            auth_mode: auth_mode.map(str::to_string),
16376            base_url: bamboo_mode.then(|| "http://127.0.0.1:9562/openai/v1".to_string()),
16377            wire_api: Some("responses".to_string()),
16378            provider_key_ref: None,
16379            forward_env: None,
16380            approval_policy: None,
16381            network_access: None,
16382            allow_danger_bypass: None,
16383            permission_profile: None,
16384            workspace_owned: None,
16385        }
16386    }
16387
16388    fn permission_resolution(
16389        requested: bamboo_domain::SessionPermissionMode,
16390        effective: bamboo_domain::PermissionMode,
16391    ) -> bamboo_domain::PermissionModeResolution {
16392        bamboo_domain::PermissionModeResolution {
16393            requested,
16394            effective,
16395        }
16396    }
16397
16398    #[test]
16399    fn permission_posture_mapping_contract_is_exact_for_supported_executors() {
16400        use bamboo_domain::{PermissionMode, SessionPermissionMode};
16401
16402        let default =
16403            permission_resolution(SessionPermissionMode::Default, PermissionMode::Default);
16404        assert_eq!(
16405            expected_permission_executor_mapping(&ExecutorSpec::BambooRuntime, default, false)
16406                .unwrap()
16407                .as_deref(),
16408            Some("bamboo_runtime:default")
16409        );
16410        assert_eq!(
16411            expected_permission_executor_mapping(&ExecutorSpec::Echo, default, false).unwrap(),
16412            None,
16413            "transport-only Echo must not claim the typed permission contract"
16414        );
16415        assert_eq!(
16416            expected_permission_executor_mapping(
16417                &ExecutorSpec::CliAdapter {
16418                    command: "must-not-appear-in-contract".to_string(),
16419                    args: vec!["credential-like-argument".to_string()],
16420                },
16421                default,
16422                false,
16423            )
16424            .unwrap(),
16425            None,
16426            "unimplemented CliAdapter must not leak command data into a contract"
16427        );
16428
16429        let claude = ExecutorSpec::ClaudeCode {
16430            binary: None,
16431            model: None,
16432            permission_mode: Some("default".to_string()),
16433            inherit_user_config: None,
16434            forward_env: None,
16435        };
16436        for (resolution, mapping) in [
16437            (
16438                permission_resolution(SessionPermissionMode::Default, PermissionMode::Plan),
16439                "claude_code:permission_mode=plan",
16440            ),
16441            (
16442                permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
16443                "claude_code:permission_mode=bypassPermissions",
16444            ),
16445            (
16446                permission_resolution(SessionPermissionMode::Default, PermissionMode::AcceptEdits),
16447                "claude_code:permission_mode=acceptEdits",
16448            ),
16449            (
16450                permission_resolution(SessionPermissionMode::Default, PermissionMode::DontAsk),
16451                "claude_code:permission_mode=dontAsk",
16452            ),
16453            (
16454                permission_resolution(
16455                    SessionPermissionMode::Bypass,
16456                    PermissionMode::BypassPermissions,
16457                ),
16458                "claude_code:permission_mode=default",
16459            ),
16460        ] {
16461            assert_eq!(
16462                expected_permission_executor_mapping(&claude, resolution, false)
16463                    .unwrap()
16464                    .as_deref(),
16465                Some(mapping)
16466            );
16467        }
16468        assert_eq!(
16469            expected_permission_executor_mapping(&claude, default, true)
16470                .unwrap()
16471                .as_deref(),
16472            Some("claude_code:blocked_explicit_deny")
16473        );
16474
16475        let mut codex_exec = codex_executor(Some("inherit"), Some(true));
16476        if let ExecutorSpec::Codex {
16477            approval_policy, ..
16478        } = &mut codex_exec
16479        {
16480            *approval_policy = Some("on-failure".to_string());
16481        }
16482        assert_eq!(
16483            expected_permission_executor_mapping(&codex_exec, default, false)
16484                .unwrap()
16485                .as_deref(),
16486            Some("codex_exec:approval_policy=on-failure")
16487        );
16488        assert_eq!(
16489            expected_permission_executor_mapping(
16490                &codex_exec,
16491                permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
16492                false,
16493            )
16494            .unwrap()
16495            .as_deref(),
16496            Some("codex_exec:approval_policy=never")
16497        );
16498        assert_eq!(
16499            expected_permission_executor_mapping(&codex_exec, default, true)
16500                .unwrap()
16501                .as_deref(),
16502            Some("codex_exec:blocked_explicit_deny")
16503        );
16504
16505        let mut codex_app_server = codex_executor(Some("inherit"), Some(true));
16506        if let ExecutorSpec::Codex {
16507            mode,
16508            approval_policy,
16509            ..
16510        } = &mut codex_app_server
16511        {
16512            *mode = Some("app_server".to_string());
16513            *approval_policy = Some("on-request".to_string());
16514        }
16515        assert_eq!(
16516            expected_permission_executor_mapping(&codex_app_server, default, false)
16517                .unwrap()
16518                .as_deref(),
16519            Some("codex_app_server:approvalPolicy=on-request")
16520        );
16521        assert_eq!(
16522            expected_permission_executor_mapping(
16523                &codex_app_server,
16524                permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
16525                false,
16526            )
16527            .unwrap()
16528            .as_deref(),
16529            Some("codex_app_server:approvalPolicy=never")
16530        );
16531        assert_eq!(
16532            expected_permission_executor_mapping(&codex_app_server, default, true)
16533                .unwrap()
16534                .as_deref(),
16535            Some("codex_app_server:blocked_explicit_deny")
16536        );
16537    }
16538
16539    #[test]
16540    fn only_bamboo_managed_non_git_workspaces_are_marked_owned() {
16541        let project = tempfile::tempdir().unwrap();
16542        let managed = project.path().join(".bamboo/worktree/child-571");
16543        std::fs::create_dir_all(&managed).unwrap();
16544        assert!(!workspace_is_bamboo_owned(managed.to_str().unwrap()));
16545        let marker = project
16546            .path()
16547            .join(".bamboo/worktree/.bamboo-owned/child-571");
16548        std::fs::create_dir_all(marker.parent().unwrap()).unwrap();
16549        std::fs::write(&marker, "bamboo/child-571").unwrap();
16550        assert!(workspace_is_bamboo_owned(managed.to_str().unwrap()));
16551        let nested = managed.join("nested/path");
16552        std::fs::create_dir_all(&nested).unwrap();
16553        assert!(workspace_is_bamboo_owned(nested.to_str().unwrap()));
16554
16555        let arbitrary = tempfile::tempdir().unwrap();
16556        assert!(!workspace_is_bamboo_owned(
16557            arbitrary.path().to_str().unwrap()
16558        ));
16559    }
16560
16561    #[test]
16562    fn bamboo_codex_token_is_per_run_redacted_and_revoked_on_guard_drop() {
16563        let authority = Arc::new(RecordingCodexTokenAuthority::default());
16564        let authority_dyn: Arc<dyn CodexRunTokenAuthority> = authority.clone();
16565
16566        let (secrets, guard) = build_codex_run_secrets(
16567            &codex_executor(Some("bamboo"), None),
16568            Some(authority_dyn),
16569            "child-570",
16570        )
16571        .unwrap();
16572
16573        let token = secrets
16574            .codex_provider_token
16575            .as_ref()
16576            .expect("bamboo mode mints a token");
16577        assert_eq!(token.expose(), "bcx1_secret-child-570");
16578        assert!(!format!("{token:?}").contains("secret-child-570"));
16579        assert_eq!(
16580            authority.issued_for.lock().unwrap().as_slice(),
16581            ["child-570"]
16582        );
16583        assert!(authority.revoked.lock().unwrap().is_empty());
16584
16585        drop(guard);
16586        assert_eq!(
16587            authority.revoked.lock().unwrap().as_slice(),
16588            ["id-child-570"]
16589        );
16590    }
16591
16592    #[test]
16593    fn non_bamboo_codex_never_mints_and_bamboo_fails_closed_without_authority() {
16594        let authority = Arc::new(RecordingCodexTokenAuthority::default());
16595        let authority_dyn: Arc<dyn CodexRunTokenAuthority> = authority.clone();
16596        let (secrets, guard) = build_codex_run_secrets(
16597            &codex_executor(Some("custom"), None),
16598            Some(authority_dyn),
16599            "child-custom",
16600        )
16601        .unwrap();
16602        assert!(secrets.codex_provider_token.is_none());
16603        assert!(guard.is_none());
16604        assert!(authority.issued_for.lock().unwrap().is_empty());
16605
16606        let error = build_codex_run_secrets(
16607            &codex_executor(Some("bamboo"), None),
16608            None,
16609            "child-no-authority",
16610        )
16611        .err()
16612        .expect("bamboo mode without an authority must fail closed");
16613        assert!(error.to_string().contains("per-run token authority"));
16614    }
16615
16616    #[test]
16617    fn codex_provisioning_never_leaks_the_session_provider_credential() {
16618        let credentials = vec![ScopedCredential {
16619            provider: "openai".to_string(),
16620            api_key: "upstream-secret-must-not-cross".to_string(),
16621            base_url: None,
16622            provider_type: Some("openai".to_string()),
16623            credential_ref: Some("provider.openai.api_key".to_string()),
16624        }];
16625
16626        for (mode, label) in [
16627            (Some("inherit"), "inherit"),
16628            (Some("api_key"), "api_key"),
16629            (Some("bamboo"), "bamboo"),
16630            (None, "default-bamboo"),
16631        ] {
16632            let runner = ActorChildRunner::new(
16633                format!("codex-{label}-test"),
16634                PathBuf::from("/bin/false"),
16635                Vec::new(),
16636                std::env::temp_dir().join(format!("bamboo-codex-{label}-570")),
16637                codex_executor(mode, None),
16638                credentials.clone(),
16639                "openai".to_string(),
16640                1,
16641            );
16642            let mut session = Session::new(format!("child-{label}"), "model");
16643            session.add_message(bamboo_agent_core::Message::user("test"));
16644            let spec = runner.build_spec(
16645                &session,
16646                &crate::runtime::execution::SpawnJob {
16647                    parent_session_id: "parent".to_string(),
16648                    child_session_id: format!("child-{label}"),
16649                    model: "gpt-5.4".to_string(),
16650                    disabled_tools: None,
16651                },
16652            );
16653            assert!(
16654                spec.secrets.provider_credentials.is_empty(),
16655                "{label} Codex must not receive the session provider key"
16656            );
16657        }
16658    }
16659
16660    #[test]
16661    fn non_codex_provisioning_still_receives_only_its_selected_provider_credential() {
16662        let credentials = vec![
16663            ScopedCredential {
16664                provider: "openai".to_string(),
16665                api_key: "selected-openai-secret".to_string(),
16666                base_url: None,
16667                provider_type: Some("openai".to_string()),
16668                credential_ref: Some("provider.openai.api_key".to_string()),
16669            },
16670            ScopedCredential {
16671                provider: "other".to_string(),
16672                api_key: "unrelated-secret".to_string(),
16673                base_url: None,
16674                provider_type: Some("openai".to_string()),
16675                credential_ref: Some("provider.other.api_key".to_string()),
16676            },
16677        ];
16678        let runner = ActorChildRunner::new(
16679            "echo-test".to_string(),
16680            PathBuf::from("/bin/false"),
16681            Vec::new(),
16682            std::env::temp_dir().join("bamboo-echo-provider-570"),
16683            ExecutorSpec::Echo,
16684            credentials,
16685            "openai".to_string(),
16686            1,
16687        );
16688        let spec = runner.build_spec(
16689            &Session::new("child-echo", "model"),
16690            &crate::runtime::execution::SpawnJob {
16691                parent_session_id: "parent".to_string(),
16692                child_session_id: "child-echo".to_string(),
16693                model: "gpt-5.4".to_string(),
16694                disabled_tools: None,
16695            },
16696        );
16697
16698        assert_eq!(spec.secrets.provider_credentials.len(), 1);
16699        assert_eq!(
16700            spec.secrets.provider_credentials[0].api_key,
16701            "selected-openai-secret"
16702        );
16703    }
16704
16705    #[tokio::test]
16706    async fn live_provider_config_refreshes_credentials_and_default_per_activation() {
16707        fn provider_instance(
16708            provider_type: &str,
16709            api_key: &str,
16710        ) -> bamboo_config::ProviderInstanceConfig {
16711            bamboo_config::ProviderInstanceConfig {
16712                provider_type: provider_type.to_string(),
16713                label: None,
16714                api_key: api_key.to_string(),
16715                api_key_encrypted: None,
16716                credential_ref: None,
16717                base_url: None,
16718                model: None,
16719                fast_model: None,
16720                vision_model: None,
16721                reasoning_effort: None,
16722                responses_only_models: Vec::new(),
16723                request_overrides: None,
16724                enabled: true,
16725                extra: Default::default(),
16726            }
16727        }
16728
16729        let mut initial = bamboo_llm::Config::default();
16730        initial.provider_instances.insert(
16731            "old-provider".to_string(),
16732            provider_instance("openai", "old-key"),
16733        );
16734        initial.default_provider_instance = Some("old-provider".to_string());
16735        let live = Arc::new(tokio::sync::RwLock::new(initial));
16736
16737        let runner = ActorChildRunner::new(
16738            "echo-live-config-test".to_string(),
16739            PathBuf::from("/bin/false"),
16740            Vec::new(),
16741            std::env::temp_dir().join("bamboo-echo-live-config"),
16742            ExecutorSpec::Echo,
16743            Vec::new(),
16744            "stale-provider".to_string(),
16745            1,
16746        )
16747        .with_live_provider_config(live.clone());
16748        let job = crate::runtime::execution::SpawnJob {
16749            parent_session_id: "parent".to_string(),
16750            child_session_id: "child".to_string(),
16751            model: "model".to_string(),
16752            disabled_tools: None,
16753        };
16754
16755        let first = runner
16756            .build_live_spec(&Session::new("child", "model"), &job)
16757            .await;
16758        assert_eq!(
16759            first.model.as_ref().map(|model| model.provider.as_str()),
16760            Some("old-provider")
16761        );
16762        assert_eq!(first.secrets.provider_credentials.len(), 1);
16763        assert_eq!(first.secrets.provider_credentials[0].api_key, "old-key");
16764
16765        let mut reloaded = bamboo_llm::Config::default();
16766        reloaded.provider_instances.insert(
16767            "new-provider".to_string(),
16768            provider_instance("openai", "new-key"),
16769        );
16770        reloaded.default_provider_instance = Some("new-provider".to_string());
16771        *live.write().await = reloaded;
16772
16773        let second = runner
16774            .build_live_spec(&Session::new("child", "model"), &job)
16775            .await;
16776        assert_eq!(
16777            second.model.as_ref().map(|model| model.provider.as_str()),
16778            Some("new-provider")
16779        );
16780        assert_eq!(second.secrets.provider_credentials.len(), 1);
16781        assert_eq!(second.secrets.provider_credentials[0].api_key, "new-key");
16782    }
16783
16784    #[test]
16785    fn custom_codex_provisioning_scopes_only_the_referenced_credential() {
16786        let mut executor = codex_executor(Some("custom"), None);
16787        if let ExecutorSpec::Codex {
16788            base_url,
16789            provider_key_ref,
16790            ..
16791        } = &mut executor
16792        {
16793            *base_url = Some("https://provider.example/v1".to_string());
16794            *provider_key_ref = Some("provider.custom.api_key".to_string());
16795        }
16796        let credentials = vec![
16797            ScopedCredential {
16798                provider: "openai".to_string(),
16799                api_key: "session-provider-secret".to_string(),
16800                base_url: None,
16801                provider_type: Some("openai".to_string()),
16802                credential_ref: Some("provider.openai.api_key".to_string()),
16803            },
16804            ScopedCredential {
16805                provider: "custom".to_string(),
16806                api_key: "selected-secret".to_string(),
16807                base_url: None,
16808                provider_type: Some("openai".to_string()),
16809                credential_ref: Some("provider.custom.api_key".to_string()),
16810            },
16811            ScopedCredential {
16812                provider: "other".to_string(),
16813                api_key: "unrelated-secret".to_string(),
16814                base_url: None,
16815                provider_type: Some("openai".to_string()),
16816                credential_ref: Some("provider.other.api_key".to_string()),
16817            },
16818        ];
16819        let runner = ActorChildRunner::new(
16820            "codex-test".to_string(),
16821            PathBuf::from("/bin/false"),
16822            Vec::new(),
16823            std::env::temp_dir().join("bamboo-codex-570"),
16824            executor,
16825            credentials,
16826            "openai".to_string(),
16827            1,
16828        );
16829        let mut session = Session::new("child-custom", "model");
16830        session.add_message(bamboo_agent_core::Message::user("test"));
16831        let spec = runner.build_spec(
16832            &session,
16833            &crate::runtime::execution::SpawnJob {
16834                parent_session_id: "parent".to_string(),
16835                child_session_id: "child-custom".to_string(),
16836                model: "gpt-5.4".to_string(),
16837                disabled_tools: None,
16838            },
16839        );
16840
16841        assert_eq!(spec.secrets.provider_credentials.len(), 1);
16842        assert_eq!(
16843            spec.secrets.provider_credentials[0]
16844                .credential_ref
16845                .as_deref(),
16846            Some("provider.custom.api_key")
16847        );
16848        assert_eq!(
16849            spec.secrets.provider_credentials[0].api_key,
16850            "selected-secret"
16851        );
16852    }
16853
16854    fn spec_with(
16855        role: &str,
16856        provider: &str,
16857        model: &str,
16858        workspace: Option<&str>,
16859        disabled: Option<Vec<&str>>,
16860    ) -> ProvisionSpec {
16861        let mut spec = ProvisionSpec::new(
16862            ChildIdentity {
16863                child_id: "c".into(),
16864                parent_id: None,
16865                project_key: None,
16866                role: role.into(),
16867                depth: 0,
16868            },
16869            ExecutorSpec::Echo,
16870            "/tmp/fab".into(),
16871        );
16872        spec.workspace = workspace.map(|w| w.to_string());
16873        spec.model = Some(ModelRefSpec {
16874            provider: provider.into(),
16875            model: model.into(),
16876        });
16877        spec.disabled_tools = disabled.map(|d| d.into_iter().map(String::from).collect());
16878        spec
16879    }
16880
16881    #[test]
16882    fn fingerprint_matches_interchangeable_children() {
16883        // Same role/provider/model/workspace and equal tool sets (order-insensitive)
16884        // are interchangeable on one warm worker — and differ only in child_id.
16885        let a = spec_with(
16886            "explorer",
16887            "p",
16888            "m",
16889            Some("/ws"),
16890            Some(vec!["Bash", "Edit"]),
16891        );
16892        let mut b = spec_with(
16893            "explorer",
16894            "p",
16895            "m",
16896            Some("/ws"),
16897            Some(vec!["Edit", "Bash"]),
16898        );
16899        b.identity.child_id = "other".into();
16900        assert_eq!(
16901            ActorChildRunner::fingerprint(&a),
16902            ActorChildRunner::fingerprint(&b)
16903        );
16904    }
16905
16906    #[test]
16907    fn logical_identity_is_invariant_across_local_remote_scheduled_and_warm_reuse() {
16908        let mut session =
16909            Session::new_child("logical-child-681", "logical-parent-681", "model", "child");
16910        session.root_session_id = "logical-root-681".to_string();
16911        let job = SpawnJob {
16912            parent_session_id: "logical-parent-681".to_string(),
16913            child_session_id: "logical-child-681".to_string(),
16914            model: "model".to_string(),
16915            disabled_tools: None,
16916        };
16917        let expected = LogicalSessionIdentity {
16918            creation: None,
16919            session_id: "logical-child-681".to_string(),
16920            parent_session_id: Some("logical-parent-681".to_string()),
16921            root_session_id: "logical-root-681".to_string(),
16922        };
16923
16924        let placements_and_transport_ids = [
16925            (Placement::Local, "local-mailbox-first"),
16926            (
16927                Placement::Remote {
16928                    endpoint: "wss://remote.example/actor".to_string(),
16929                },
16930                "remote-process-44",
16931            ),
16932            (
16933                Placement::Schedulable {
16934                    pool: "gpu-pool".to_string(),
16935                },
16936                "scheduled-mailbox-9",
16937            ),
16938            // Same logical child reactivated on a different pooled mailbox.
16939            (Placement::Local, "warm-mailbox-reused-77"),
16940        ];
16941        for (placement, transport_id) in placements_and_transport_ids {
16942            let mut provision = spec_with("worker", "provider", "model", None, None);
16943            provision.placement = placement;
16944            provision.identity.child_id = transport_id.to_string();
16945            assert_eq!(logical_identity_for_actor_run(&session, &job), expected);
16946            assert_ne!(
16947                provision.identity.child_id, expected.session_id,
16948                "test fixture must prove transport identity is independent"
16949            );
16950        }
16951        let mut original = spec_with("worker", "provider", "model", Some("/ws"), None);
16952        original.executor = ExecutorSpec::BambooRuntime;
16953        original.identity.depth = session.spawn_depth;
16954        original.capabilities.required_child_context = true;
16955        original.capabilities.child_creation_identity = true;
16956        original.capabilities.enforce_permissions = true;
16957        original.capabilities.native_tool_ceiling_required = true;
16958        original.capabilities.native_tool_ceiling =
16959            Some(bamboo_subagent::proto::NativeToolCeiling {
16960                version: 1,
16961                child_session_id: session.id.clone(),
16962                parent_session_id: job.parent_session_id.clone(),
16963                root_session_id: session.root_session_id.clone(),
16964                created_at: session.created_at,
16965                spawn_depth: session.spawn_depth,
16966                project_id: None,
16967                tools: vec![],
16968            });
16969        original.validate().unwrap();
16970        let first = ActorChildRunner::fresh_required_worker_spec(&original);
16971        let second = ActorChildRunner::fresh_required_worker_spec(&original);
16972        assert_ne!(first.identity.child_id, second.identity.child_id);
16973        assert_ne!(first.identity.child_id, original.identity.child_id);
16974        for physical in [first, second] {
16975            physical.validate().unwrap();
16976            let mut actual = serde_json::to_value(&physical).unwrap();
16977            actual["identity"]["child_id"] = serde_json::json!(original.identity.child_id);
16978            assert_eq!(actual, serde_json::to_value(&original).unwrap());
16979            assert_eq!(logical_identity_for_actor_run(&session, &job), expected);
16980        }
16981    }
16982
16983    #[test]
16984    fn fingerprint_separates_distinct_runtimes() {
16985        let base = spec_with("explorer", "p", "m", Some("/ws"), None);
16986        let base_fp = ActorChildRunner::fingerprint(&base);
16987        // Each axis that is baked into the worker must split the pool bucket.
16988        assert_ne!(
16989            base_fp,
16990            ActorChildRunner::fingerprint(&spec_with("writer", "p", "m", Some("/ws"), None))
16991        );
16992        assert_ne!(
16993            base_fp,
16994            ActorChildRunner::fingerprint(&spec_with("explorer", "p2", "m", Some("/ws"), None))
16995        );
16996        assert_ne!(
16997            base_fp,
16998            ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m2", Some("/ws"), None))
16999        );
17000        assert_ne!(
17001            base_fp,
17002            ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m", Some("/ws2"), None))
17003        );
17004        assert_ne!(
17005            base_fp,
17006            ActorChildRunner::fingerprint(&spec_with(
17007                "explorer",
17008                "p",
17009                "m",
17010                Some("/ws"),
17011                Some(vec!["Bash"])
17012            ))
17013        );
17014    }
17015
17016    #[test]
17017    fn fingerprint_splits_rotated_provider_credentials_without_exposing_them() {
17018        let mut before = spec_with("explorer", "provider", "model", Some("/ws"), None);
17019        before.secrets.provider_credentials.push(ScopedCredential {
17020            provider: "provider".to_string(),
17021            api_key: "old-secret-key".to_string(),
17022            base_url: Some("https://provider.example/v1".to_string()),
17023            provider_type: Some("openai".to_string()),
17024            credential_ref: None,
17025        });
17026        let mut after = before.clone();
17027        after.secrets.provider_credentials[0].api_key = "new-secret-key".to_string();
17028
17029        let before_fingerprint = ActorChildRunner::fingerprint(&before);
17030        let after_fingerprint = ActorChildRunner::fingerprint(&after);
17031        assert_ne!(before_fingerprint, after_fingerprint);
17032        assert!(!before_fingerprint.contains("old-secret-key"));
17033        assert!(!after_fingerprint.contains("new-secret-key"));
17034    }
17035
17036    #[test]
17037    fn fingerprint_splits_on_baked_capabilities() {
17038        // Every capability baked once at provision time must split the pool
17039        // bucket, else a worker baked for one posture gets reused for another
17040        // (e.g. a depth-1 worker re-stamping spawn_depth onto a depth-4 child,
17041        // breaking the depth cap; or a bypass worker reused for a non-bypass one).
17042        let base_fp =
17043            ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m", Some("/ws"), None));
17044
17045        let mut creation = spec_with("explorer", "p", "m", Some("/ws"), None);
17046        creation.capabilities.child_creation_identity = true;
17047        let typed = ActorChildRunner::fingerprint(&creation);
17048        assert_ne!(
17049            base_fp, typed,
17050            "legacy worker pool cannot satisfy birth-required Run"
17051        );
17052        creation.identity.child_id = "different-logical-child".into();
17053        assert_eq!(
17054            typed,
17055            ActorChildRunner::fingerprint(&creation),
17056            "birth-required mode retains cross-Child pool reuse"
17057        );
17058
17059        let mut depth = spec_with("explorer", "p", "m", Some("/ws"), None);
17060        depth.identity.depth = 2;
17061        assert_ne!(
17062            base_fp,
17063            ActorChildRunner::fingerprint(&depth),
17064            "depth must split"
17065        );
17066
17067        let mut nested = spec_with("explorer", "p", "m", Some("/ws"), None);
17068        nested.capabilities.nested_spawn = true;
17069        assert_ne!(
17070            base_fp,
17071            ActorChildRunner::fingerprint(&nested),
17072            "nested_spawn must split"
17073        );
17074
17075        let mut bypass = spec_with("explorer", "p", "m", Some("/ws"), None);
17076        bypass.capabilities.bypass = true;
17077        assert_ne!(
17078            base_fp,
17079            ActorChildRunner::fingerprint(&bypass),
17080            "bypass must split"
17081        );
17082
17083        let mut auto = spec_with("explorer", "p", "m", Some("/ws"), None);
17084        auto.capabilities.auto_approve_permissions = true;
17085        assert_ne!(
17086            base_fp,
17087            ActorChildRunner::fingerprint(&auto),
17088            "auto_approve_permissions must split"
17089        );
17090
17091        let mut global_auto = spec_with("explorer", "p", "m", Some("/ws"), None);
17092        global_auto.capabilities.permission_requested_mode = "default".to_string();
17093        global_auto.capabilities.permission_effective_mode = "auto".to_string();
17094        global_auto.capabilities.auto_approve_permissions = true;
17095        let mut explicit_auto = global_auto.clone();
17096        explicit_auto.capabilities.permission_requested_mode = "auto".to_string();
17097        assert_ne!(
17098            ActorChildRunner::fingerprint(&global_auto),
17099            ActorChildRunner::fingerprint(&explicit_auto),
17100            "permission_requested_mode must split global and explicit Auto"
17101        );
17102
17103        let mut plan_overlay = explicit_auto.clone();
17104        plan_overlay.capabilities.permission_effective_mode = "plan".to_string();
17105        assert_ne!(
17106            ActorChildRunner::fingerprint(&explicit_auto),
17107            ActorChildRunner::fingerprint(&plan_overlay),
17108            "permission_effective_mode must split Plan overlay from Auto"
17109        );
17110
17111        let mut enforce = spec_with("explorer", "p", "m", Some("/ws"), None);
17112        enforce.capabilities.enforce_permissions = true;
17113        assert_ne!(
17114            base_fp,
17115            ActorChildRunner::fingerprint(&enforce),
17116            "enforce_permissions must split"
17117        );
17118
17119        let mut cap = spec_with("explorer", "p", "m", Some("/ws"), None);
17120        cap.capabilities.max_spawn_depth = Some(8);
17121        assert_ne!(
17122            base_fp,
17123            ActorChildRunner::fingerprint(&cap),
17124            "max_spawn_depth must split"
17125        );
17126
17127        // #73 (P1): the worker bakes `no_human_review` from this flag once at
17128        // build(), so it MUST split the pool or a worker baked for one approval
17129        // posture is reused for the opposite one.
17130        let mut nha = spec_with("explorer", "p", "m", Some("/ws"), None);
17131        nha.capabilities.no_human_approver = true;
17132        assert_ne!(
17133            base_fp,
17134            ActorChildRunner::fingerprint(&nha),
17135            "no_human_approver must split"
17136        );
17137
17138        // The generic read-only no-shell checker is baked once at build(), so a
17139        // planner/Guardian worker must not be reused for an ordinary child.
17140        let mut read_only = spec_with("explorer", "p", "m", Some("/ws"), None);
17141        read_only.capabilities.read_only = true;
17142        assert_ne!(
17143            base_fp,
17144            ActorChildRunner::fingerprint(&read_only),
17145            "read_only must split"
17146        );
17147
17148        // Preserve the legacy Guardian bit as its own rolling-upgrade axis.
17149        let mut gro = spec_with("explorer", "p", "m", Some("/ws"), None);
17150        gro.capabilities.guardian_read_only = true;
17151        assert_ne!(
17152            base_fp,
17153            ActorChildRunner::fingerprint(&gro),
17154            "guardian_read_only must split"
17155        );
17156    }
17157
17158    #[test]
17159    fn fingerprint_splits_codex_exec_and_app_server_workers() {
17160        let mut exec = spec_with("explorer", "p", "m", Some("/ws"), None);
17161        exec.executor = codex_executor(Some("inherit"), None);
17162        let mut app_server = exec.clone();
17163        if let ExecutorSpec::Codex { mode, .. } = &mut app_server.executor {
17164            *mode = Some("app_server".to_string());
17165        }
17166        assert_ne!(
17167            ActorChildRunner::fingerprint(&exec),
17168            ActorChildRunner::fingerprint(&app_server)
17169        );
17170    }
17171
17172    struct StaticDecider(bool);
17173
17174    #[async_trait]
17175    impl ChildApprovalDecider for StaticDecider {
17176        async fn decide(&self, _child: &str, _req: &serde_json::Value) -> bool {
17177            self.0
17178        }
17179    }
17180
17181    struct RecordingReviewer {
17182        reviewed: mpsc::UnboundedSender<(String, String, serde_json::Value)>,
17183    }
17184
17185    #[async_trait]
17186    impl ChildApprovalReviewer for RecordingReviewer {
17187        async fn review(&self, parent: &str, child: &str, request: &serde_json::Value) -> bool {
17188            let _ = self
17189                .reviewed
17190                .send((parent.to_string(), child.to_string(), request.clone()));
17191            true
17192        }
17193    }
17194
17195    // ---- first-frame watchdog (dead-pooled-worker recovery) -----------------
17196
17197    /// A link that never yields a frame — models a worker that died (or never
17198    /// subscribed) so its Run sits queued with no server.
17199    struct SilentLink;
17200    #[async_trait]
17201    impl bamboo_subagent::ChildLink for SilentLink {
17202        async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
17203            Ok(())
17204        }
17205        async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
17206            std::future::pending().await
17207        }
17208    }
17209
17210    /// A link that immediately yields one terminal frame (a healthy fast worker).
17211    struct InstantTerminalLink {
17212        done: bool,
17213    }
17214
17215    struct ApprovalRoundTripLink {
17216        step: u8,
17217        approval_reply: Option<(String, bool)>,
17218    }
17219
17220    #[async_trait]
17221    impl bamboo_subagent::ChildLink for ApprovalRoundTripLink {
17222        async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
17223            if let ParentFrame::ApprovalReply { id, approved } = frame {
17224                self.approval_reply = Some((id, approved));
17225                self.step = 2;
17226            }
17227            Ok(())
17228        }
17229
17230        async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
17231            match self.step {
17232                0 => {
17233                    self.step = 1;
17234                    Ok(Some(ChildFrame::ApprovalRequest {
17235                        id: "approval-1".into(),
17236                        body: serde_json::json!({
17237                            "tool_name": "Bash",
17238                            "permission": "execute",
17239                            "resource": "rm -rf target",
17240                            "permission_request": {"reason_code": "hard_dangerous"}
17241                        }),
17242                    }))
17243                }
17244                1 => std::future::pending().await,
17245                2 => {
17246                    self.step = 3;
17247                    Ok(Some(ChildFrame::Terminal {
17248                        status: TerminalStatus::Completed,
17249                        result: Some("done".into()),
17250                        error: None,
17251                        final_event_watermark: None,
17252                        transcript: vec![],
17253                    }))
17254                }
17255                _ => std::future::pending().await,
17256            }
17257        }
17258    }
17259
17260    #[tokio::test]
17261    async fn drive_routes_forced_ask_to_parent_reviewer_without_human_event() {
17262        let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(8);
17263        let (review_tx, mut review_rx) = mpsc::unbounded_channel();
17264        let reviewer: Arc<dyn ChildApprovalReviewer> = Arc::new(RecordingReviewer {
17265            reviewed: review_tx,
17266        });
17267        let cancel = CancellationToken::new();
17268        let (live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
17269        let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
17270        let mut logical_session = Session::new("child-reviewer", "model");
17271        let live_guard = crate::external_agents::live::register("child-reviewer", live_tx, 0, None);
17272        let mut link = ApprovalRoundTripLink {
17273            step: 0,
17274            approval_reply: None,
17275        };
17276
17277        let result = tokio::time::timeout(
17278            Duration::from_secs(1),
17279            drive(ActorDriveContext {
17280                expected_creation: None,
17281                client: &mut link,
17282                parent_session_id: "parent-reviewer",
17283                child_session_id: "child-reviewer",
17284                child_attempt: 0,
17285                approval_registry: None,
17286                approval_decider: None,
17287                approval_reviewer: Some(&reviewer),
17288                escalation_bridge: None,
17289                event_tx: &event_tx,
17290                cancel_token: &cancel,
17291                live_rx: &mut live_rx,
17292                delivery_rx: &mut delivery_rx,
17293                logical_session: &mut logical_session,
17294                expected_permission_posture: None,
17295                session_inbox_runtime: None,
17296                actor_directory_store: None,
17297                canonical_subagent_tool: None,
17298                ticket_service: None,
17299                activation_run_id: None,
17300                execution_epoch: 0,
17301                expected_source_actor_id: "child-reviewer",
17302                initial_inflight_claims: VecDeque::new(),
17303                plain_actor: false,
17304                remote_environment_lease: false,
17305                readonly_output: None,
17306                local_history_tools: None,
17307                local_history_read_only: false,
17308                strict_history_output: None,
17309                plain_input: None,
17310                canonical_activation: None,
17311                canonical_placement_ref: None,
17312                actor_event_observer: None,
17313                plain_run: None,
17314                first_frame_timeout: None,
17315            }),
17316        )
17317        .await
17318        .expect("worker must receive the reviewer verdict before terminating");
17319
17320        assert_eq!(result.ok().flatten().as_deref(), Some("done"));
17321        assert_eq!(
17322            link.approval_reply,
17323            Some(("approval-1".to_string(), true)),
17324            "reviewer verdict must traverse the live route back to the worker"
17325        );
17326        let (parent, child, body) = tokio::time::timeout(Duration::from_secs(1), review_rx.recv())
17327            .await
17328            .expect("reviewer should be invoked off-loop")
17329            .expect("review channel should remain open");
17330        assert_eq!(parent, "parent-reviewer");
17331        assert_eq!(child, "child-reviewer");
17332        assert_eq!(
17333            body.pointer("/permission_request/reason_code")
17334                .and_then(serde_json::Value::as_str),
17335            Some("hard_dangerous")
17336        );
17337        assert!(
17338            event_rx.try_recv().is_err(),
17339            "must not emit a human-review event"
17340        );
17341        drop(live_guard);
17342    }
17343
17344    #[tokio::test]
17345    async fn drive_denies_forced_ask_without_parent_reviewer_or_manual_event() {
17346        let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(8);
17347        let cancel = CancellationToken::new();
17348        let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
17349        let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
17350        let mut logical_session = Session::new("child-no-reviewer", "model");
17351        let mut link = ApprovalRoundTripLink {
17352            step: 0,
17353            approval_reply: None,
17354        };
17355
17356        let result = tokio::time::timeout(
17357            Duration::from_secs(1),
17358            drive(ActorDriveContext {
17359                expected_creation: None,
17360                client: &mut link,
17361                parent_session_id: "parent-no-reviewer",
17362                child_session_id: "child-no-reviewer",
17363                child_attempt: 0,
17364                approval_registry: None,
17365                approval_decider: None,
17366                approval_reviewer: None,
17367                escalation_bridge: None,
17368                event_tx: &event_tx,
17369                cancel_token: &cancel,
17370                live_rx: &mut live_rx,
17371                delivery_rx: &mut delivery_rx,
17372                logical_session: &mut logical_session,
17373                expected_permission_posture: None,
17374                session_inbox_runtime: None,
17375                actor_directory_store: None,
17376                canonical_subagent_tool: None,
17377                ticket_service: None,
17378                activation_run_id: None,
17379                execution_epoch: 0,
17380                expected_source_actor_id: "child-no-reviewer",
17381                initial_inflight_claims: VecDeque::new(),
17382                plain_actor: false,
17383                remote_environment_lease: false,
17384                readonly_output: None,
17385                local_history_tools: None,
17386                local_history_read_only: false,
17387                strict_history_output: None,
17388                plain_input: None,
17389                canonical_activation: None,
17390                canonical_placement_ref: None,
17391                actor_event_observer: None,
17392                plain_run: None,
17393                first_frame_timeout: None,
17394            }),
17395        )
17396        .await
17397        .expect("fail-closed reply must unblock the child immediately");
17398
17399        assert_eq!(result.ok().flatten().as_deref(), Some("done"));
17400        assert_eq!(link.approval_reply, Some(("approval-1".to_string(), false)));
17401        assert!(
17402            event_rx.try_recv().is_err(),
17403            "missing parent review must not surface a manual approval event"
17404        );
17405    }
17406    #[async_trait]
17407    impl bamboo_subagent::ChildLink for InstantTerminalLink {
17408        async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
17409            Ok(())
17410        }
17411        async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
17412            if self.done {
17413                std::future::pending().await
17414            } else {
17415                self.done = true;
17416                Ok(Some(ChildFrame::Terminal {
17417                    status: TerminalStatus::Completed,
17418                    result: Some("done".into()),
17419                    error: None,
17420                    final_event_watermark: None,
17421                    transcript: vec![],
17422                }))
17423            }
17424        }
17425    }
17426
17427    #[tokio::test]
17428    async fn drive_trips_first_frame_watchdog_on_a_silent_worker() {
17429        let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
17430        let cancel = CancellationToken::new();
17431        let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
17432        let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
17433        let mut logical_session = Session::new("child-x", "model");
17434        let mut link = SilentLink;
17435        let r = drive(ActorDriveContext {
17436            expected_creation: None,
17437            client: &mut link,
17438            parent_session_id: "parent-x",
17439            child_session_id: "child-x",
17440            child_attempt: 0,
17441            approval_registry: None,
17442            approval_decider: None,
17443            approval_reviewer: None,
17444            escalation_bridge: None,
17445            event_tx: &event_tx,
17446            cancel_token: &cancel,
17447            live_rx: &mut live_rx,
17448            delivery_rx: &mut delivery_rx,
17449            logical_session: &mut logical_session,
17450            expected_permission_posture: None,
17451            session_inbox_runtime: None,
17452            actor_directory_store: None,
17453            canonical_subagent_tool: None,
17454            ticket_service: None,
17455            activation_run_id: None,
17456            execution_epoch: 0,
17457            expected_source_actor_id: "child-x",
17458            initial_inflight_claims: VecDeque::new(),
17459            plain_actor: false,
17460            remote_environment_lease: false,
17461            readonly_output: None,
17462            local_history_tools: None,
17463            local_history_read_only: false,
17464            strict_history_output: None,
17465            plain_input: None,
17466            canonical_activation: None,
17467            canonical_placement_ref: None,
17468            actor_event_observer: None,
17469            plain_run: None,
17470            first_frame_timeout: Some(Duration::from_millis(100)),
17471        })
17472        .await;
17473        assert!(
17474            matches!(r, Err(AgentError::WorkerUnresponsive(_))),
17475            "a silent worker must trip the first-frame watchdog, got {r:?}"
17476        );
17477    }
17478
17479    #[tokio::test]
17480    async fn drive_does_not_trip_when_a_frame_arrives() {
17481        let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
17482        let cancel = CancellationToken::new();
17483        let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
17484        let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
17485        let mut logical_session = Session::new("child-y", "model");
17486        let mut link = InstantTerminalLink { done: false };
17487        // Even a tiny timeout must NOT trip: the terminal frame arrives first and
17488        // disarms the watchdog.
17489        let r = drive(ActorDriveContext {
17490            expected_creation: None,
17491            client: &mut link,
17492            parent_session_id: "parent-y",
17493            child_session_id: "child-y",
17494            child_attempt: 0,
17495            approval_registry: None,
17496            approval_decider: None,
17497            approval_reviewer: None,
17498            escalation_bridge: None,
17499            event_tx: &event_tx,
17500            cancel_token: &cancel,
17501            live_rx: &mut live_rx,
17502            delivery_rx: &mut delivery_rx,
17503            logical_session: &mut logical_session,
17504            expected_permission_posture: None,
17505            session_inbox_runtime: None,
17506            actor_directory_store: None,
17507            canonical_subagent_tool: None,
17508            ticket_service: None,
17509            activation_run_id: None,
17510            execution_epoch: 0,
17511            expected_source_actor_id: "child-y",
17512            initial_inflight_claims: VecDeque::new(),
17513            plain_actor: false,
17514            remote_environment_lease: false,
17515            readonly_output: None,
17516            local_history_tools: None,
17517            local_history_read_only: false,
17518            strict_history_output: None,
17519            plain_input: None,
17520            canonical_activation: None,
17521            canonical_placement_ref: None,
17522            actor_event_observer: None,
17523            plain_run: None,
17524            first_frame_timeout: Some(Duration::from_millis(50)),
17525        })
17526        .await;
17527        assert_eq!(r.ok().flatten().as_deref(), Some("done"));
17528    }
17529
17530    #[tokio::test]
17531    async fn child_approval_fails_closed_without_decider() {
17532        // No decider wired ⇒ the host denies (safe default), unchanged behavior.
17533        let body = serde_json::json!({"tool_name":"Bash","permission":"run","resource":"rm -rf /"});
17534        assert!(!decide_child_approval(None, "child-1", &body).await);
17535    }
17536
17537    #[tokio::test]
17538    async fn child_approval_honors_wired_decider() {
17539        let body =
17540            serde_json::json!({"tool_name":"Write","permission":"write","resource":"/tmp/x"});
17541        let approve: Arc<dyn ChildApprovalDecider> = Arc::new(StaticDecider(true));
17542        let deny: Arc<dyn ChildApprovalDecider> = Arc::new(StaticDecider(false));
17543        assert!(decide_child_approval(Some(&approve), "child-1", &body).await);
17544        assert!(!decide_child_approval(Some(&deny), "child-1", &body).await);
17545    }
17546
17547    // ---- #193: remote placement routing -------------------------------------
17548
17549    use crate::runtime::execution::SpawnJob;
17550    use bamboo_agent_core::Session;
17551
17552    /// A runner with a BOGUS worker_bin (`/bin/false`): a local spawn here would
17553    /// FAIL, so a passing remote test proves the remote path never spawns.
17554    fn bogus_runner(placements: HashMap<String, ResolvedRemotePlacement>) -> ActorChildRunner {
17555        ActorChildRunner::new(
17556            "test-actor".into(),
17557            PathBuf::from("/bin/false"),
17558            vec![],
17559            std::env::temp_dir().join("bamboo-test-fab-193"),
17560            ExecutorSpec::Echo,
17561            vec![],
17562            "anthropic".into(),
17563            4,
17564        )
17565        .with_remote_placements(placements)
17566    }
17567
17568    /// A child session of the given role (the role rides `subagent_type`, the
17569    /// path build_spec + the remote lookup both read).
17570    fn session_of_role(role: &str, assignment: &str) -> Session {
17571        let mut s = Session::new("child-1", "test-model");
17572        s.metadata
17573            .insert("subagent_type".to_string(), role.to_string());
17574        s.add_message(bamboo_agent_core::Message::user(assignment));
17575        s
17576    }
17577
17578    fn job_for(child: &str) -> SpawnJob {
17579        SpawnJob {
17580            parent_session_id: "parent-1".into(),
17581            child_session_id: child.into(),
17582            model: String::new(),
17583            disabled_tools: None,
17584        }
17585    }
17586
17587    #[tokio::test]
17588    async fn spawn_depth_provisioning_uses_live_policy_for_local_and_remote_workers() {
17589        let live = Arc::new(tokio::sync::RwLock::new(bamboo_llm::Config::default()));
17590        live.write().await.subagents_mut().max_spawn_depth = Some(1);
17591        let mut remote = HashMap::new();
17592        remote.insert(
17593            "remote-role".into(),
17594            ResolvedRemotePlacement {
17595                endpoint: "wss://worker.example:8443".into(),
17596                token: None,
17597                ca_cert_file: None,
17598                host_label: None,
17599                broker_peer: None,
17600                requirements: None,
17601            },
17602        );
17603        let runner = bogus_runner(remote).with_live_provider_config(live.clone());
17604        let root = Session::new("provision-root", "model");
17605        for role in ["local-role", "remote-role"] {
17606            let mut child =
17607                Session::new_child_of(format!("provision-{role}"), &root, "model", "child");
17608            child.metadata.insert("subagent_type".into(), role.into());
17609            let first = runner.build_live_spec(&child, &job_for(&child.id)).await;
17610            assert_eq!(first.capabilities.max_spawn_depth, Some(1));
17611            assert!(!first.capabilities.nested_spawn);
17612            assert_eq!(
17613                matches!(first.placement, Placement::Remote { .. }),
17614                role == "remote-role"
17615            );
17616            live.write().await.subagents_mut().max_spawn_depth = Some(7);
17617            let second = runner.build_live_spec(&child, &job_for(&child.id)).await;
17618            assert_eq!(second.capabilities.max_spawn_depth, Some(7));
17619            assert!(second.capabilities.nested_spawn);
17620            assert_ne!(
17621                ActorChildRunner::fingerprint(&first),
17622                ActorChildRunner::fingerprint(&second)
17623            );
17624            assert_eq!(second.identity.depth, 1);
17625            live.write().await.subagents_mut().max_spawn_depth = Some(1);
17626        }
17627        let fallback = bogus_runner(HashMap::new()).with_max_spawn_depth(0);
17628        let spec = fallback.build_spec(&root, &job_for(&root.id));
17629        assert_eq!(spec.capabilities.max_spawn_depth, Some(0));
17630        assert!(!spec.capabilities.nested_spawn);
17631    }
17632
17633    struct RecordingChildSessionPort {
17634        parent: Session,
17635        saved: std::sync::Mutex<Option<Session>>,
17636    }
17637
17638    impl RecordingChildSessionPort {
17639        fn saved_child(&self) -> Session {
17640            self.saved
17641                .lock()
17642                .expect("saved-child fixture lock")
17643                .clone()
17644                .expect("create_child_action must save the child")
17645        }
17646    }
17647
17648    #[async_trait]
17649    impl crate::session_app::child_session::ChildSessionPort for RecordingChildSessionPort {
17650        async fn load_root_session(
17651            &self,
17652            root_id: &str,
17653        ) -> Result<Session, crate::session_app::child_session::ChildSessionError> {
17654            assert_eq!(root_id, self.parent.id);
17655            Ok(self.parent.clone())
17656        }
17657
17658        async fn load_child_for_parent(
17659            &self,
17660            _parent_id: &str,
17661            _child_id: &str,
17662        ) -> Result<Session, crate::session_app::child_session::ChildSessionError> {
17663            unreachable!("create_child_action does not reload the child")
17664        }
17665
17666        async fn save_child_session(
17667            &self,
17668            child: &mut Session,
17669        ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17670            *self.saved.lock().expect("saved-child fixture lock") = Some(child.clone());
17671            Ok(())
17672        }
17673
17674        async fn save_child_session_authoritative_flags(
17675            &self,
17676            _child: &mut Session,
17677        ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17678            unreachable!("new-child creation uses the ordinary save")
17679        }
17680
17681        async fn is_child_running(&self, _child_id: &str) -> bool {
17682            false
17683        }
17684
17685        async fn list_children(
17686            &self,
17687            _parent_id: &str,
17688        ) -> Vec<crate::session_app::child_session::ChildSessionEntry> {
17689            Vec::new()
17690        }
17691
17692        async fn enqueue_child_run(
17693            &self,
17694            _parent: &Session,
17695            _child: &Session,
17696        ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17697            unreachable!("fixture creates the child with auto_run=false")
17698        }
17699
17700        async fn cancel_child_run_and_wait(
17701            &self,
17702            _child_id: &str,
17703        ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17704            unreachable!("create_child_action does not cancel")
17705        }
17706
17707        async fn delete_child_session(
17708            &self,
17709            _parent_id: &str,
17710            _child_id: &str,
17711        ) -> Result<
17712            crate::session_app::child_session::DeleteChildResult,
17713            crate::session_app::child_session::ChildSessionError,
17714        > {
17715            unreachable!("create_child_action does not delete")
17716        }
17717
17718        async fn get_child_runner_info(
17719            &self,
17720            _child_id: &str,
17721        ) -> Option<crate::session_app::child_session::ChildRunnerInfo> {
17722            None
17723        }
17724
17725        async fn register_parent_wait_for_child(
17726            &self,
17727            _parent_session_id: &str,
17728            _child_session_id: &str,
17729            _tool_call_id: Option<&str>,
17730        ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17731            unreachable!("create_child_action does not register a wait")
17732        }
17733
17734        async fn register_parent_wait_for_children(
17735            &self,
17736            _parent_session_id: &str,
17737            _child_session_ids: &[String],
17738            _policy: bamboo_domain::session::runtime_state::ChildWaitPolicy,
17739        ) -> Result<usize, crate::session_app::child_session::ChildSessionError> {
17740            unreachable!("create_child_action does not register a wait")
17741        }
17742
17743        async fn active_child_ids(&self, _parent_session_id: &str) -> Vec<String> {
17744            Vec::new()
17745        }
17746
17747        async fn find_resident_child(
17748            &self,
17749            _root_session_id: &str,
17750            _resident_name: &str,
17751        ) -> Option<String> {
17752            None
17753        }
17754
17755        async fn ensure_child_indexed(&self, _child_session_id: &str) {}
17756    }
17757
17758    #[test]
17759    fn build_spec_sets_remote_placement_for_matching_role() {
17760        let mut placements = HashMap::new();
17761        placements.insert(
17762            "explorer".to_string(),
17763            ResolvedRemotePlacement {
17764                endpoint: "wss://gpu-host:8443".into(),
17765                token: Some("T-secret".into()),
17766                ca_cert_file: None,
17767                host_label: None,
17768                broker_peer: None,
17769                requirements: None,
17770            },
17771        );
17772        let runner = bogus_runner(placements);
17773
17774        // The broker credential never rides the child RunSpec secrets envelope.
17775        let s = session_of_role("explorer", "do the thing");
17776        let spec = runner.build_spec(&s, &job_for("child-1"));
17777        match &spec.placement {
17778            Placement::Remote { endpoint } => assert_eq!(endpoint, "wss://gpu-host:8443"),
17779            other => panic!("expected Remote, got {other:?}"),
17780        }
17781        assert!(spec.secrets.worker_auth_token.is_none());
17782    }
17783
17784    #[test]
17785    fn build_spec_leaves_local_for_unmatched_role() {
17786        let mut placements = HashMap::new();
17787        placements.insert(
17788            "explorer".to_string(),
17789            ResolvedRemotePlacement {
17790                endpoint: "wss://gpu-host:8443".into(),
17791                token: Some("T".into()),
17792                ca_cert_file: None,
17793                host_label: None,
17794                broker_peer: None,
17795                requirements: None,
17796            },
17797        );
17798        let runner = bogus_runner(placements);
17799
17800        // A DIFFERENT role keeps the default Local placement + no bearer.
17801        let s = session_of_role("writer", "do the thing");
17802        let spec = runner.build_spec(&s, &job_for("child-1"));
17803        assert_eq!(spec.placement, Placement::Local);
17804        assert!(spec.secrets.worker_auth_token.is_none());
17805    }
17806
17807    #[test]
17808    fn build_spec_local_when_no_placements() {
17809        let runner = bogus_runner(HashMap::new());
17810        let s = session_of_role("explorer", "do the thing");
17811        let spec = runner.build_spec(&s, &job_for("child-1"));
17812        assert_eq!(spec.placement, Placement::Local);
17813        assert!(spec.secrets.worker_auth_token.is_none());
17814    }
17815
17816    #[test]
17817    fn typed_child_state_is_the_read_only_authority_under_auto_and_bypass() {
17818        for mode in [
17819            bamboo_domain::SessionPermissionMode::Auto,
17820            bamboo_domain::SessionPermissionMode::Bypass,
17821        ] {
17822            let runner = bogus_runner(HashMap::new());
17823            let mut child = session_of_role("planner", "inspect and plan");
17824            let runtime = child
17825                .agent_runtime_state
17826                .get_or_insert_with(bamboo_domain::AgentRuntimeState::default);
17827            runtime.set_permission_mode(mode);
17828            runtime.read_only = true;
17829
17830            let spec = runner.build_spec(&child, &job_for("read-only-child"));
17831
17832            assert!(spec.capabilities.read_only);
17833            assert!(spec.capabilities.read_only_enforced());
17834            assert!(
17835                spec.capabilities.guardian_read_only,
17836                "the compatibility alias must protect planners on older workers"
17837            );
17838            assert_eq!(spec.capabilities.permission_requested_mode, mode.as_str());
17839            assert_eq!(spec.capabilities.permission_effective_mode, "plan");
17840            assert!(
17841                !spec.capabilities.bypass,
17842                "read-only must win over requested {mode:?}"
17843            );
17844            assert_eq!(
17845                spec.capabilities.auto_approve_permissions,
17846                mode == bamboo_domain::SessionPermissionMode::Auto,
17847                "Auto may suppress prompts but must not widen Plan authorization"
17848            );
17849            let disabled = spec
17850                .disabled_tools
17851                .as_ref()
17852                .expect("read-only state must provision a host-owned denylist");
17853            for tool_name in [
17854                "Edit",
17855                "Write",
17856                "Plan",
17857                "SubAgent",
17858                "session_control",
17859                "request_permissions",
17860            ] {
17861                assert!(
17862                    disabled.iter().any(|name| name == tool_name),
17863                    "read-only provisioning must disable {tool_name}"
17864                );
17865            }
17866        }
17867
17868        let runner = bogus_runner(HashMap::new());
17869        let cosmetic_planner = runner.build_spec(
17870            &session_of_role("planner", "label only"),
17871            &job_for("cosmetic"),
17872        );
17873        assert!(
17874            !cosmetic_planner.capabilities.read_only_enforced(),
17875            "a cosmetic role label must not change authority"
17876        );
17877        assert!(cosmetic_planner.disabled_tools.is_none());
17878
17879        let cosmetic_guardian = runner.build_spec(
17880            &session_of_role("guardian", "label only"),
17881            &job_for("cosmetic-guardian"),
17882        );
17883        assert!(
17884            !cosmetic_guardian.capabilities.read_only_enforced(),
17885            "a Guardian label without typed state or the legacy denylist is cosmetic"
17886        );
17887
17888        let legacy_guardian = session_of_role("guardian", "pre-upgrade reviewer");
17889        let mut legacy_job = job_for("legacy-guardian");
17890        legacy_job.disabled_tools = Some(vec![
17891            "Edit".to_string(),
17892            "Write".to_string(),
17893            "SubAgent".to_string(),
17894            "WebFetch".to_string(),
17895        ]);
17896        let legacy_spec = runner.build_spec(&legacy_guardian, &legacy_job);
17897        assert!(
17898            legacy_spec.capabilities.read_only_enforced(),
17899            "an in-flight pre-upgrade Guardian keeps its read-only authority"
17900        );
17901
17902        let mut differently_named = session_of_role("explorer", "typed authority");
17903        differently_named
17904            .agent_runtime_state
17905            .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
17906            .read_only = true;
17907        let typed = runner.build_spec(&differently_named, &job_for("typed"));
17908        assert!(
17909            typed.capabilities.read_only_enforced(),
17910            "typed state must enforce read-only independently of role"
17911        );
17912    }
17913
17914    #[test]
17915    fn typed_read_only_forces_native_external_executor_profiles() {
17916        let mut planner = session_of_role("planner", "inspect and plan");
17917        planner
17918            .agent_runtime_state
17919            .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
17920            .read_only = true;
17921
17922        let claude_runner = ActorChildRunner::new(
17923            "test-actor".into(),
17924            PathBuf::from("/bin/false"),
17925            vec![],
17926            std::env::temp_dir().join("bamboo-test-plan-claude"),
17927            ExecutorSpec::ClaudeCode {
17928                binary: None,
17929                model: None,
17930                permission_mode: Some("bypassPermissions".to_string()),
17931                inherit_user_config: Some(true),
17932                forward_env: None,
17933            },
17934            vec![],
17935            "anthropic".into(),
17936            4,
17937        );
17938        let claude_spec = claude_runner.build_spec(&planner, &job_for("planner-claude"));
17939        let disabled = claude_spec
17940            .disabled_tools
17941            .as_ref()
17942            .expect("typed read-only child must receive a host-owned denylist");
17943        for tool in ["Bash", "BashInput", "BashOutput", "KillShell"] {
17944            assert!(
17945                disabled.iter().any(|name| name == tool),
17946                "typed read-only child must not advertise {tool}"
17947            );
17948        }
17949        assert!(matches!(
17950            claude_spec.executor,
17951            ExecutorSpec::ClaudeCode {
17952                permission_mode: Some(ref mode),
17953                inherit_user_config: Some(false),
17954                ..
17955            } if mode == "plan"
17956        ));
17957        assert_eq!(claude_spec.capabilities.permission_effective_mode, "plan");
17958
17959        let mut codex_exec = codex_executor(Some("inherit"), Some(true));
17960        if let ExecutorSpec::Codex {
17961            sandbox,
17962            approval_policy,
17963            network_access,
17964            allow_danger_bypass,
17965            permission_profile,
17966            ..
17967        } = &mut codex_exec
17968        {
17969            *sandbox = Some("danger-full-access".to_string());
17970            *approval_policy = Some("on-failure".to_string());
17971            *network_access = Some(true);
17972            *allow_danger_bypass = Some(true);
17973            *permission_profile = Some("full-access".to_string());
17974        }
17975        let codex_runner = ActorChildRunner::new(
17976            "test-actor".into(),
17977            PathBuf::from("/bin/false"),
17978            vec![],
17979            std::env::temp_dir().join("bamboo-test-plan-codex"),
17980            codex_exec,
17981            vec![],
17982            "openai".into(),
17983            4,
17984        );
17985        let codex_spec = codex_runner.build_spec(&planner, &job_for("planner-codex"));
17986        assert_eq!(
17987            expected_permission_executor_mapping(
17988                &codex_spec.executor,
17989                codex_spec.capabilities.permission_resolution().unwrap(),
17990                false,
17991            )
17992            .unwrap()
17993            .as_deref(),
17994            Some("codex_exec:approval_policy=never")
17995        );
17996        assert!(matches!(
17997            codex_spec.executor,
17998            ExecutorSpec::Codex {
17999                sandbox: Some(ref sandbox),
18000                inherit_user_config: Some(false),
18001                approval_policy: Some(ref approval_policy),
18002                network_access: Some(false),
18003                allow_danger_bypass: Some(false),
18004                permission_profile: Some(ref profile),
18005                ..
18006            } if sandbox == "read-only"
18007                && approval_policy == "never"
18008                && profile == "read-only"
18009        ));
18010
18011        let mut app_server = codex_executor(Some("inherit"), Some(true));
18012        if let ExecutorSpec::Codex {
18013            mode,
18014            sandbox,
18015            approval_policy,
18016            network_access,
18017            allow_danger_bypass,
18018            permission_profile,
18019            ..
18020        } = &mut app_server
18021        {
18022            *mode = Some("app_server".to_string());
18023            *sandbox = Some("danger-full-access".to_string());
18024            *approval_policy = Some("on-request".to_string());
18025            *network_access = Some(true);
18026            *allow_danger_bypass = Some(true);
18027            *permission_profile = Some("full-access".to_string());
18028        }
18029        let app_server_runner = ActorChildRunner::new(
18030            "test-actor".into(),
18031            PathBuf::from("/bin/false"),
18032            vec![],
18033            std::env::temp_dir().join("bamboo-test-plan-codex-app-server"),
18034            app_server,
18035            vec![],
18036            "openai".into(),
18037            4,
18038        );
18039        let app_server_spec =
18040            app_server_runner.build_spec(&planner, &job_for("planner-codex-app-server"));
18041        assert_eq!(
18042            expected_permission_executor_mapping(
18043                &app_server_spec.executor,
18044                app_server_spec
18045                    .capabilities
18046                    .permission_resolution()
18047                    .unwrap(),
18048                false,
18049            )
18050            .unwrap()
18051            .as_deref(),
18052            Some("codex_app_server:approvalPolicy=never")
18053        );
18054        assert!(matches!(
18055            app_server_spec.executor,
18056            ExecutorSpec::Codex {
18057                mode: Some(ref mode),
18058                sandbox: Some(ref sandbox),
18059                inherit_user_config: Some(false),
18060                approval_policy: Some(ref approval_policy),
18061                network_access: Some(false),
18062                allow_danger_bypass: Some(false),
18063                permission_profile: Some(ref profile),
18064                ..
18065            } if mode == "app_server"
18066                && sandbox == "read-only"
18067                && approval_policy == "on-request"
18068                && profile == "read-only"
18069        ));
18070    }
18071
18072    #[test]
18073    fn typed_read_only_rejects_both_codex_executor_modes() {
18074        let mut planner = session_of_role("planner", "inspect and plan");
18075        planner
18076            .agent_runtime_state
18077            .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18078            .read_only = true;
18079
18080        for mode in ["exec", "app_server"] {
18081            let mut executor = codex_executor(Some("inherit"), Some(true));
18082            if let ExecutorSpec::Codex {
18083                mode: configured_mode,
18084                ..
18085            } = &mut executor
18086            {
18087                *configured_mode = Some(mode.to_string());
18088            }
18089            let runner = ActorChildRunner::new(
18090                "test-actor".into(),
18091                PathBuf::from("/bin/false"),
18092                vec![],
18093                std::env::temp_dir().join(format!("bamboo-test-plan-codex-{mode}")),
18094                executor,
18095                vec![],
18096                "openai".into(),
18097                4,
18098            );
18099            let spec = runner.build_spec(&planner, &job_for(&format!("planner-codex-{mode}")));
18100            let error = ensure_read_only_activation_is_enforceable(&spec)
18101                .expect_err("Codex command execution must fail closed for read-only activations");
18102            let message = error.to_string();
18103            assert!(message.contains("Codex executor"), "{message}");
18104            assert!(message.contains("command execution"), "{message}");
18105        }
18106    }
18107
18108    #[tokio::test]
18109    async fn typed_read_only_codex_fails_before_worker_spawn_or_dispatch() {
18110        let mut planner = session_of_role("planner", "inspect and plan");
18111        planner
18112            .agent_runtime_state
18113            .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18114            .read_only = true;
18115
18116        let runner = ActorChildRunner::new(
18117            "test-actor".into(),
18118            PathBuf::from("/bin/false"),
18119            vec![],
18120            std::env::temp_dir().join("bamboo-test-plan-codex-no-spawn"),
18121            codex_executor(Some("inherit"), Some(true)),
18122            vec![],
18123            "openai".into(),
18124            4,
18125        );
18126        let (event_tx, _event_rx) = mpsc::channel::<AgentEvent>(4);
18127        let error = runner
18128            .execute_external_child(
18129                &mut planner,
18130                &job_for("planner-codex-no-spawn"),
18131                event_tx,
18132                CancellationToken::new(),
18133            )
18134            .await
18135            .expect_err("typed read-only Codex must fail before invoking /bin/false");
18136        let message = error.to_string();
18137        assert!(message.contains("Codex executor"), "{message}");
18138        assert!(message.contains("command execution"), "{message}");
18139    }
18140
18141    #[test]
18142    fn ticket_placement_keeps_local_and_ordinary_remote_execution_available() {
18143        let runner = bogus_runner(HashMap::new());
18144        let ordinary = session_of_role("worker", "ordinary work");
18145        for placement in [
18146            Placement::Local,
18147            Placement::Remote {
18148                endpoint: "wss://fixture.invalid/actor".into(),
18149            },
18150            Placement::Schedulable {
18151                pool: "fixture-pool".into(),
18152            },
18153        ] {
18154            let mut spec = runner.build_spec(&ordinary, &job_for("placement"));
18155            spec.placement = placement;
18156            ensure_ticket_placement_is_enforceable(&ordinary, &spec).unwrap();
18157            for marker in [
18158                crate::ticket_runtime::TICKET_DISPATCH_KEY,
18159                crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY,
18160            ] {
18161                let mut ticket = ordinary.clone();
18162                ticket.metadata.insert(marker.into(), "fixture".into());
18163                assert_eq!(
18164                    ensure_ticket_placement_is_enforceable(&ticket, &spec).is_ok(),
18165                    matches!(spec.placement, Placement::Local)
18166                );
18167            }
18168        }
18169    }
18170
18171    #[tokio::test]
18172    async fn ticket_placement_rejected_before_remote_route_claim_or_worker_spawn() {
18173        for marker in [
18174            crate::ticket_runtime::TICKET_DISPATCH_KEY,
18175            crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY,
18176        ] {
18177            for schedulable in [false, true] {
18178                let mut session = session_of_role("worker", "Ticket work");
18179                session.metadata.insert(marker.into(), "fixture".into());
18180                let before = serde_json::to_value(&session).unwrap();
18181                let remote = if schedulable {
18182                    HashMap::new()
18183                } else {
18184                    HashMap::from([(
18185                        "worker".into(),
18186                        ResolvedRemotePlacement {
18187                            endpoint: "wss://fixture.invalid/actor".into(),
18188                            ..Default::default()
18189                        },
18190                    )])
18191                };
18192                let sched = if schedulable {
18193                    HashMap::from([(
18194                        "worker".into(),
18195                        sched_placement("fixture-pool", "wss://fixture.invalid"),
18196                    )])
18197                } else {
18198                    HashMap::new()
18199                };
18200                let runner = bogus_sched_runner(remote, sched);
18201                let (events, mut observations) = mpsc::channel::<AgentEvent>(4);
18202                let error = runner
18203                    .execute_external_child(
18204                        &mut session,
18205                        &job_for("ticket-no-remote"),
18206                        events,
18207                        CancellationToken::new(),
18208                    )
18209                    .await
18210                    .unwrap_err();
18211                assert!(
18212                    error
18213                        .to_string()
18214                        .contains("Ticket execution requires local placement"),
18215                    "{error}"
18216                );
18217                assert_eq!(serde_json::to_value(&session).unwrap(), before);
18218                assert!(observations.try_recv().is_err());
18219            }
18220        }
18221    }
18222
18223    #[test]
18224    fn typed_read_only_rejects_resident_actor_placements() {
18225        let runner = bogus_runner(HashMap::new());
18226        let mut planner = session_of_role("planner", "inspect and plan");
18227        planner
18228            .agent_runtime_state
18229            .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18230            .read_only = true;
18231
18232        for placement in [
18233            Placement::Remote {
18234                endpoint: "wss://resident.example/actor".to_string(),
18235            },
18236            Placement::Schedulable {
18237                pool: "resident-pool".to_string(),
18238            },
18239        ] {
18240            let mut spec = runner.build_spec(&planner, &job_for("read-only-resident"));
18241            spec.placement = placement;
18242
18243            let error = ensure_read_only_activation_is_enforceable(&spec)
18244                .expect_err("resident placement must fail closed for read-only activation");
18245            let message = error.to_string();
18246            assert!(message.contains("requires local placement"), "{message}");
18247            assert!(message.contains("read-only tool boundary"), "{message}");
18248        }
18249
18250        let local = runner.build_spec(&planner, &job_for("read-only-local"));
18251        ensure_read_only_activation_is_enforceable(&local)
18252            .expect("a locally provisioned read-only worker is enforceable");
18253    }
18254
18255    #[tokio::test]
18256    async fn build_spec_preserves_exact_inherited_permission_mode_for_child_worker() {
18257        // Exercise the real creation path instead of pre-seeding the child by
18258        // hand: both legacy Bypass and zero-prompt Auto must survive child
18259        // creation and the actor provisioning boundary without collapsing.
18260        for (label, mode) in [
18261            ("bypass", bamboo_domain::SessionPermissionMode::Bypass),
18262            ("auto", bamboo_domain::SessionPermissionMode::Auto),
18263        ] {
18264            let runner = bogus_runner(HashMap::new());
18265            let mut parent = Session::new(format!("parent-{label}"), "test-model");
18266            parent
18267                .agent_runtime_state
18268                .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18269                .set_permission_mode(mode);
18270            let workspace = tempfile::tempdir().expect("workspace fixture");
18271            let port = RecordingChildSessionPort {
18272                parent: parent.clone(),
18273                saved: Default::default(),
18274            };
18275            let child_id = format!("child-{label}-{}", uuid::Uuid::new_v4());
18276            crate::session_app::child_session::create_child_action(
18277                &port,
18278                crate::session_app::child_session::CreateChildInput {
18279                    parent_session: parent,
18280                    child_id: child_id.clone(),
18281                    title: format!("{label} child"),
18282                    responsibility: "Run ordinary commands".to_string(),
18283                    assignment_prompt: "run an ordinary command".to_string(),
18284                    subagent_type: "explorer".to_string(),
18285                    workspace: workspace.path().to_string_lossy().into_owned(),
18286                    workspace_source: crate::project_context::WorkspaceSource::Explicit,
18287                    model_override: None,
18288                    model_ref_override: None,
18289                    runtime_metadata: HashMap::new(),
18290                    read_only: false,
18291                    auto_run: false,
18292                    reasoning_effort: None,
18293                    lifecycle: None,
18294                    resident_name: None,
18295                    resident_context: None,
18296                    disabled_tools: None,
18297                    context_fork: None,
18298                },
18299            )
18300            .await
18301            .expect("create inherited-permission child");
18302            let child = port.saved_child();
18303
18304            assert_eq!(
18305                child
18306                    .agent_runtime_state
18307                    .as_ref()
18308                    .map(bamboo_domain::AgentRuntimeState::effective_permission_mode),
18309                Some(mode),
18310                "create_child_action must inherit {label} from the parent"
18311            );
18312
18313            let spec = runner.build_spec(&child, &job_for(&child_id));
18314
18315            assert_eq!(
18316                spec.capabilities.bypass,
18317                mode == bamboo_domain::SessionPermissionMode::Bypass
18318            );
18319            assert_eq!(
18320                spec.capabilities.auto_approve_permissions,
18321                mode == bamboo_domain::SessionPermissionMode::Auto
18322            );
18323            assert!(
18324                spec.capabilities.enforce_permissions,
18325                "policy evaluation must remain active under {label}"
18326            );
18327        }
18328    }
18329
18330    #[tokio::test]
18331    async fn child_resident_and_guardian_inherit_project_through_actor_run_spec() {
18332        let project_id = bamboo_domain::ProjectId::parse("project-inherited").expect("Project id");
18333        let workspace = tempfile::tempdir().expect("workspace fixture");
18334
18335        for (role, lifecycle, resident_name) in [
18336            ("explorer", None, None),
18337            ("resident", Some("resident"), Some("stable-reviewer")),
18338            ("guardian", None, None),
18339        ] {
18340            let mut parent = Session::new(format!("parent-{role}"), "test-model");
18341            parent.set_project_id_meta(project_id.to_string());
18342            let port = RecordingChildSessionPort {
18343                parent: parent.clone(),
18344                saved: Default::default(),
18345            };
18346            let child_id = format!("child-{role}-{}", uuid::Uuid::new_v4());
18347            crate::session_app::child_session::create_child_action(
18348                &port,
18349                crate::session_app::child_session::CreateChildInput {
18350                    parent_session: parent,
18351                    child_id: child_id.clone(),
18352                    title: format!("{role} child"),
18353                    responsibility: "Review the assigned work".to_string(),
18354                    assignment_prompt: "inspect the change".to_string(),
18355                    subagent_type: role.to_string(),
18356                    workspace: workspace.path().to_string_lossy().into_owned(),
18357                    workspace_source: crate::project_context::WorkspaceSource::Explicit,
18358                    model_override: None,
18359                    model_ref_override: None,
18360                    runtime_metadata: HashMap::new(),
18361                    read_only: false,
18362                    auto_run: false,
18363                    reasoning_effort: None,
18364                    lifecycle: lifecycle.map(str::to_string),
18365                    resident_name: resident_name.map(str::to_string),
18366                    resident_context: None,
18367                    disabled_tools: None,
18368                    context_fork: None,
18369                },
18370            )
18371            .await
18372            .expect("create Project-inheriting child");
18373            let child = port.saved_child();
18374            assert_eq!(
18375                crate::project_context::ProjectContextResolver::project_id_from_session(&child),
18376                Some(project_id.clone()),
18377                "{role} child must inherit its parent's Project"
18378            );
18379
18380            assert_eq!(
18381                project_id_for_actor_run(&child).expect("valid actor Project identity"),
18382                Some(project_id.clone()),
18383                "{role} actor RunSpec must preserve inherited Project identity"
18384            );
18385        }
18386    }
18387
18388    #[test]
18389    fn placement_metadata_stamps_remote_and_schedulable_not_local() {
18390        // Local children carry no stamp — the DTO defaults them to the backend host.
18391        assert_eq!(placement_metadata(&Placement::Local, None), None);
18392
18393        // Remote, no node label → host derived from the endpoint.
18394        let r = placement_metadata(
18395            &Placement::Remote {
18396                endpoint: "wss://10.0.0.5:8443/stream".into(),
18397            },
18398            None,
18399        )
18400        .unwrap();
18401        assert!(r.contains(r#""kind":"remote""#), "{r}");
18402        assert!(r.contains(r#""host":"10.0.0.5""#), "{r}");
18403
18404        // A cluster node's label (its metadata) OVERRIDES the raw endpoint host.
18405        let labeled = placement_metadata(
18406            &Placement::Remote {
18407                endpoint: "ws://169.254.230.101:8899".into(),
18408            },
18409            Some("mini"),
18410        )
18411        .unwrap();
18412        assert!(labeled.contains(r#""host":"mini""#), "{labeled}");
18413
18414        // Schedulable → {kind:"remote", host:<node label, else pool>}.
18415        let s = placement_metadata(
18416            &Placement::Schedulable {
18417                pool: "explorers".into(),
18418            },
18419            Some("mini"),
18420        )
18421        .unwrap();
18422        assert!(s.contains(r#""kind":"remote""#), "{s}");
18423        assert!(s.contains(r#""host":"mini""#), "{s}");
18424
18425        // The stamp round-trips through the storage placement type.
18426        let p: bamboo_storage::SessionPlacement = serde_json::from_str(&labeled).unwrap();
18427        assert_eq!(p.kind, "remote");
18428        assert_eq!(p.host, "mini");
18429    }
18430
18431    /// A legacy direct worker endpoint cannot bypass the broker activation
18432    /// contract, even when the endpoint is reachable and has a bearer token.
18433    #[tokio::test]
18434    async fn execute_external_child_rejects_direct_remote_worker() {
18435        let mut placements = HashMap::new();
18436        placements.insert(
18437            "explorer".to_string(),
18438            ResolvedRemotePlacement {
18439                endpoint: "ws://127.0.0.1:1".into(),
18440                token: Some("remote-test-token".into()),
18441                ca_cert_file: None,
18442                host_label: Some("mini-e2e".into()),
18443                broker_peer: None,
18444                requirements: None,
18445            },
18446        );
18447        let runner = bogus_runner(placements);
18448        let mut session = session_of_role("explorer", "hello remote");
18449        let job = job_for("child-1");
18450        let (event_tx, _event_rx) = mpsc::channel::<AgentEvent>(64);
18451        let error = runner
18452            .execute_external_child(&mut session, &job, event_tx, CancellationToken::new())
18453            .await
18454            .err()
18455            .expect("direct remote transport must fail closed");
18456        assert!(error
18457            .to_string()
18458            .contains("remote_broker_activation_unavailable"));
18459        assert!(session
18460            .messages
18461            .iter()
18462            .all(|message| message.role != Role::Assistant));
18463    }
18464
18465    // ---- #181 (P2b): schedulable placement routing --------------------------
18466
18467    /// A bogus-worker_bin runner carrying SCHEDULABLE placements (and optionally
18468    /// remote ones, to test precedence). A local spawn here would fail on
18469    /// `/bin/false`, so a passing schedulable test proves no subprocess spawned.
18470    fn bogus_sched_runner(
18471        remote: HashMap<String, ResolvedRemotePlacement>,
18472        sched: HashMap<String, ResolvedSchedulablePlacement>,
18473    ) -> ActorChildRunner {
18474        ActorChildRunner::new(
18475            "test-actor".into(),
18476            PathBuf::from("/bin/false"),
18477            vec![],
18478            std::env::temp_dir().join("bamboo-test-fab-181"),
18479            ExecutorSpec::Echo,
18480            vec![],
18481            "anthropic".into(),
18482            4,
18483        )
18484        .with_remote_placements(remote)
18485        .with_schedulable_placements(sched)
18486    }
18487
18488    fn sched_placement(
18489        pool: &str,
18490        _registry_url: impl Into<String>,
18491    ) -> ResolvedSchedulablePlacement {
18492        ResolvedSchedulablePlacement {
18493            pool: pool.into(),
18494            host_label: None,
18495            requirements: None,
18496            ..Default::default()
18497        }
18498    }
18499
18500    #[test]
18501    fn build_spec_sets_schedulable_placement_for_matching_role() {
18502        let mut sched = HashMap::new();
18503        sched.insert(
18504            "explorer".to_string(),
18505            sched_placement("gpu-pool", "unused"),
18506        );
18507        let runner = bogus_sched_runner(HashMap::new(), sched);
18508
18509        let s = session_of_role("explorer", "do the thing");
18510        let spec = runner.build_spec(&s, &job_for("child-1"));
18511        match &spec.placement {
18512            Placement::Schedulable { pool } => assert_eq!(pool, "gpu-pool"),
18513            other => panic!("expected Schedulable, got {other:?}"),
18514        }
18515        // No per-placement bearer now — the bus connection carries the bus token.
18516        assert!(spec.secrets.worker_auth_token.is_none());
18517    }
18518
18519    #[test]
18520    fn build_spec_remote_wins_when_role_in_both_maps() {
18521        // A role present in BOTH remote_placements and schedulable_placements must
18522        // resolve to the FIXED remote placement (documented precedence).
18523        let mut remote = HashMap::new();
18524        remote.insert(
18525            "explorer".to_string(),
18526            ResolvedRemotePlacement {
18527                endpoint: "wss://fixed-host:8443".into(),
18528                token: Some("T-remote".into()),
18529                ca_cert_file: None,
18530                host_label: None,
18531                broker_peer: None,
18532                requirements: None,
18533            },
18534        );
18535        let mut sched = HashMap::new();
18536        sched.insert(
18537            "explorer".to_string(),
18538            sched_placement("gpu-pool", "https://control-plane:9562"),
18539        );
18540        let runner = bogus_sched_runner(remote, sched);
18541
18542        let s = session_of_role("explorer", "do the thing");
18543        let spec = runner.build_spec(&s, &job_for("child-1"));
18544        match &spec.placement {
18545            Placement::Remote { endpoint } => assert_eq!(endpoint, "wss://fixed-host:8443"),
18546            other => panic!("expected Remote (precedence), got {other:?}"),
18547        }
18548        assert!(spec.secrets.worker_auth_token.is_none());
18549    }
18550
18551    #[test]
18552    fn build_spec_local_for_unmatched_schedulable_role() {
18553        let mut sched = HashMap::new();
18554        sched.insert(
18555            "explorer".to_string(),
18556            sched_placement("gpu-pool", "https://control-plane:9562"),
18557        );
18558        let runner = bogus_sched_runner(HashMap::new(), sched);
18559        let s = session_of_role("writer", "do the thing");
18560        let spec = runner.build_spec(&s, &job_for("child-1"));
18561        assert_eq!(spec.placement, Placement::Local);
18562        assert!(spec.secrets.worker_auth_token.is_none());
18563    }
18564
18565    /// The full role → resolved-placement → badge-host chain: a child routed to a
18566    /// remote/schedulable placement carrying a cluster node's `host_label` stamps
18567    /// that label; without a label it falls back to the endpoint host / pool; a
18568    /// Local child gets no stamp (the DTO defaults it to the backend host).
18569    #[test]
18570    fn placement_stamp_uses_node_label_for_remote_and_schedulable() {
18571        // Remote WITH a node label → {remote, <label>}, overriding the raw IP.
18572        let mut remote = HashMap::new();
18573        remote.insert(
18574            "explorer".to_string(),
18575            ResolvedRemotePlacement {
18576                endpoint: "ws://169.254.230.101:8899".into(),
18577                token: None,
18578                ca_cert_file: None,
18579                host_label: Some("mini".into()),
18580                broker_peer: None,
18581                requirements: None,
18582            },
18583        );
18584        let runner = bogus_runner(remote);
18585        let spec = runner.build_spec(&session_of_role("explorer", "go"), &job_for("c1"));
18586        let stamp = runner
18587            .placement_stamp_for(&spec)
18588            .expect("remote child is stamped");
18589        assert!(stamp.contains(r#""kind":"remote""#), "{stamp}");
18590        assert!(stamp.contains(r#""host":"mini""#), "{stamp}");
18591
18592        // Remote WITHOUT a node label → falls back to the endpoint host.
18593        let mut remote_nolabel = HashMap::new();
18594        remote_nolabel.insert(
18595            "explorer".to_string(),
18596            ResolvedRemotePlacement {
18597                endpoint: "ws://169.254.230.101:8899".into(),
18598                token: None,
18599                ca_cert_file: None,
18600                host_label: None,
18601                broker_peer: None,
18602                requirements: None,
18603            },
18604        );
18605        let r2 = bogus_runner(remote_nolabel);
18606        let spec2 = r2.build_spec(&session_of_role("explorer", "go"), &job_for("c1"));
18607        assert!(r2
18608            .placement_stamp_for(&spec2)
18609            .unwrap()
18610            .contains(r#""host":"169.254.230.101""#));
18611
18612        // Schedulable WITH a node label → {remote, <label>} (a node, not a pool name).
18613        let mut sched = HashMap::new();
18614        sched.insert(
18615            "mac-mini-monitor".to_string(),
18616            ResolvedSchedulablePlacement {
18617                pool: "mac-mini-monitor".into(),
18618                host_label: Some("mini".into()),
18619                requirements: None,
18620                ..Default::default()
18621            },
18622        );
18623        let sr = bogus_sched_runner(HashMap::new(), sched);
18624        let spec3 = sr.build_spec(&session_of_role("mac-mini-monitor", "go"), &job_for("c1"));
18625        let stamp3 = sr
18626            .placement_stamp_for(&spec3)
18627            .expect("scheduled child is stamped");
18628        assert!(stamp3.contains(r#""kind":"remote""#), "{stamp3}");
18629        assert!(stamp3.contains(r#""host":"mini""#), "{stamp3}");
18630
18631        // A Local (unmatched) child gets NO stamp.
18632        let local = bogus_runner(HashMap::new());
18633        let spec4 = local.build_spec(&session_of_role("writer", "go"), &job_for("c1"));
18634        assert_eq!(local.placement_stamp_for(&spec4), None);
18635    }
18636
18637    #[tokio::test]
18638    async fn legacy_schedulable_route_fails_closed_without_local_fallback() {
18639        let mut sched = HashMap::new();
18640        sched.insert(
18641            "explorer".to_string(),
18642            sched_placement("gpu-pool", "unused"),
18643        );
18644        let runner = bogus_sched_runner(HashMap::new(), sched);
18645        let mut session = session_of_role("explorer", "do the thing");
18646        let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
18647        let error = runner
18648            .execute_external_child(
18649                &mut session,
18650                &job_for("child-1"),
18651                event_tx,
18652                CancellationToken::new(),
18653            )
18654            .await
18655            .expect_err("legacy bus presence cannot grant placement");
18656        assert!(error
18657            .to_string()
18658            .contains("remote_broker_activation_unavailable"));
18659    }
18660}