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, delivery)) = prepared {
3757                            messages = prefix;
3758                            initial_session_messages = vec![delivery];
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_input.is_some())
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_input: Option<SessionInboxOwnedClaim>,
4463    recovering_pre_ack: bool,
4464    completion_router: std::sync::Mutex<Option<ActorEventRouter>>,
4465}
4466type PlainInitialDelivery = (Vec<serde_json::Value>, SessionMessageDelivery);
4467#[track_caller]
4468fn plain_actor_unsupported() -> AgentError {
4469    let source = std::panic::Location::caller();
4470    tracing::warn!(
4471        process_id = std::process::id(),
4472        source_file = source.file(),
4473        source_line = source.line(),
4474        source_column = source.column(),
4475        "plain Actor continuation unsupported"
4476    );
4477    AgentError::LLM("This Actor Child supports a fresh plain activation, a completed plain Child with one new input, 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())
4478}
4479fn plain_initial_history(session: &Session) -> bool {
4480    !session.messages.iter().any(|message| {
4481        matches!(message.role, Role::Assistant | Role::Tool)
4482            || message
4483                .metadata
4484                .as_ref()
4485                .is_some_and(|metadata| metadata.get("_bamboo_owned_input_checkpoint").is_some())
4486    }) && session.provider_transcript.is_empty()
4487        && session.session_inbox_admission().is_none()
4488        && session.pending_injected_messages().is_none()
4489}
4490// Selection only. Canonical terminal status/new input are checked at start;
4491// the existing fenced input checkpoint validates the complete durable prefix.
4492fn plain_completed_history(session: &Session) -> bool {
4493    session.provider_transcript.is_empty()
4494        && session.pending_injected_messages().is_none()
4495        && session
4496            .messages
4497            .last()
4498            .is_some_and(|message| message.role == Role::Assistant && !message.content.is_empty())
4499        && !session
4500            .messages
4501            .iter()
4502            .any(|message| message.role == Role::Tool || message.tool_calls.is_some())
4503}
4504// Selection only. Storage's closed raw bookkeeper/current-prefix validator is
4505// still mandatory after the actual replacement claim; this metadata is no grant.
4506fn pre_ack_input(
4507    session: &Session,
4508) -> Result<Option<(bamboo_domain::SessionMessageId, u64)>, AgentError> {
4509    let Some(message) = session.messages.last() else {
4510        return Ok(None);
4511    };
4512    let Some(proof) = message
4513        .metadata
4514        .as_ref()
4515        .and_then(|v| v.get("_bamboo_owned_input_checkpoint"))
4516    else {
4517        return Ok(None);
4518    };
4519    let id = bamboo_domain::SessionMessageId::parse(message.id.clone())
4520        .map_err(|_| plain_actor_unsupported())?;
4521    let generation = proof
4522        .get("generation")
4523        .and_then(serde_json::Value::as_u64)
4524        .filter(|g| *g > 0)
4525        .ok_or_else(|| plain_actor_unsupported())?;
4526    if message.role != Role::User
4527        || session
4528            .messages
4529            .iter()
4530            .filter(|m| m.id == message.id)
4531            .count()
4532            != 1
4533        || !session
4534            .session_inbox_admission()
4535            .is_some_and(|cursor| cursor.contains(&id))
4536        || !session.provider_transcript.is_empty()
4537        || session.pending_injected_messages().is_some()
4538        || session
4539            .messages
4540            .iter()
4541            .any(|m| m.role == Role::Tool || m.tool_calls.is_some())
4542    {
4543        return Err(plain_actor_unsupported());
4544    }
4545    Ok(Some((id, generation)))
4546}
4547fn qualified_pre_ack_owner(
4548    entry: &bamboo_domain::ActorDirectoryEntry,
4549    now: chrono::DateTime<chrono::Utc>,
4550) -> bool {
4551    let Some(old) = entry.activation.as_ref() else {
4552        return false;
4553    };
4554    let recoverable = (entry.actor.state == ActorLogicalState::Failed
4555        && old.status == bamboo_domain::ActorActivationStatus::Failed)
4556        || (entry.actor.state == ActorLogicalState::Active
4557            && old.status.is_live()
4558            && old.lease_expires_at <= now);
4559    let trusted = old.placement_ref.as_ref().is_some_and(|placement| {
4560        placement.class == bamboo_domain::ActorPlacementClass::Local
4561            && placement
4562                .lease_id
4563                .strip_prefix("owned-initial-release-v1:")
4564                .is_some_and(|worker| {
4565                    !worker.is_empty()
4566                        && worker.len() <= 256
4567                        && !worker.contains("..")
4568                        && worker.bytes().all(|b| {
4569                            b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.'
4570                        })
4571                })
4572    });
4573    recoverable && trusted
4574}
4575async fn expired_pre_ack_input(
4576    inbox: &bamboo_storage::FileSessionInbox,
4577    session: &Session,
4578) -> Result<Option<(bamboo_domain::SessionMessageId, u64)>, AgentError> {
4579    let Some((id, generation)) = pre_ack_input(session)? else {
4580        return Ok(None);
4581    };
4582    let backlog = inbox
4583        .inspect(&session.id)
4584        .await
4585        .map_err(|_| plain_actor_unsupported())?;
4586    let leases = inbox
4587        .inspect_owned_leases(&session.id, 2, chrono::Utc::now())
4588        .await
4589        .map_err(|_| plain_actor_unsupported())?;
4590    if backlog.pending != 0
4591        || backlog.claimed != 1
4592        || backlog.generation != generation
4593        || backlog.oldest_generation != Some(generation)
4594        || !backlog.activation_pending()
4595        || leases.len() != 1
4596        || !leases[0].expired
4597        || leases[0].generation != generation
4598        || inbox
4599            .was_admitted(&session.id, &id)
4600            .await
4601            .map_err(|_| plain_actor_unsupported())?
4602    {
4603        return Err(plain_actor_unsupported());
4604    }
4605    Ok(Some((id, generation)))
4606}
4607impl PlainActorActivation {
4608    // Callee-side boxing keeps this storage chain out of the already large
4609    // external-runner future without changing its cancellation semantics.
4610    fn start<'a>(
4611        store: Arc<bamboo_storage::SessionStoreV2>,
4612        session: &'a Session,
4613        binding: &'a SessionInboxRuntimeBinding,
4614        run_id: Option<&'a str>,
4615        permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
4616        allow_failed_retry: bool,
4617        release_worker: Option<&'a str>,
4618    ) -> std::pin::Pin<Box<impl std::future::Future<Output = Result<Self, AgentError>> + Send + 'a>>
4619    {
4620        Box::pin(async move {
4621            let run_id = run_id.ok_or_else(|| plain_actor_unsupported())?;
4622            let backlog = binding
4623                .inbox
4624                .inspect(&session.id)
4625                .await
4626                .map_err(|_| plain_actor_unsupported())?;
4627            let entry = store
4628                .ensure_actor(&session.id)
4629                .await
4630                .map_err(|error| AgentError::LLM(error.to_string()))?;
4631            let config = permission_config
4632                .as_ref()
4633                .ok_or_else(|| plain_actor_unsupported())?;
4634            if entry.actor.policy_revision != Some(config.policy_revision())
4635                || entry.actor.placement_intent != Some(bamboo_domain::ActorPlacementIntent::Local)
4636            {
4637                return Err(plain_actor_unsupported());
4638            }
4639            let fresh = entry.actor.current_attempt == 0
4640                && entry.actor.state == ActorLogicalState::Cold
4641                && entry.activation.is_none()
4642                && backlog.pending == 0
4643                && backlog.claimed == 0
4644                && backlog.generation == 0;
4645            let new_input = backlog.pending == 1
4646                && backlog.claimed == 0
4647                && backlog.oldest_generation == Some(backlog.generation)
4648                && backlog.activation_pending();
4649            let retry = allow_failed_retry
4650                && entry.actor.state == ActorLogicalState::Failed
4651                && entry.actor.current_attempt > 0
4652                && entry.activation.as_ref().is_some_and(|old| {
4653                    old.status == bamboo_domain::ActorActivationStatus::Failed
4654                        && backlog.generation > old.inbox_generation
4655                })
4656                && new_input;
4657            let continuation = allow_failed_retry
4658                && release_worker.is_some()
4659                && entry.actor.state == ActorLogicalState::Cold
4660                && entry.actor.current_attempt > 0
4661                && entry.activation.as_ref().is_some_and(|old| {
4662                    old.status == bamboo_domain::ActorActivationStatus::Succeeded
4663                        && backlog.generation > old.inbox_generation
4664                })
4665                && new_input
4666                && plain_completed_history(session);
4667            let input_inbox = bamboo_storage::FileSessionInbox::new(
4668                store.clone(),
4669                bamboo_domain::SessionInboxLimits::default(),
4670            );
4671            let now = chrono::Utc::now();
4672            // The old initial Run may still be executing physically. Its final
4673            // append remains fenced; only a new owned input can release this
4674            // replacement worker. Previously admitted history is not replayed.
4675            let replacement = allow_failed_retry
4676                && release_worker.is_some()
4677                && entry.actor.state == ActorLogicalState::Active
4678                && qualified_pre_ack_owner(&entry, now)
4679                && entry.activation.as_ref().is_some_and(|old| {
4680                    old.status.is_live() && old.lease_expires_at <= now && old.inbox_generation == 0
4681                })
4682                && new_input
4683                && plain_initial_history(session);
4684            let recovery = if allow_failed_retry
4685                && release_worker.is_some()
4686                && qualified_pre_ack_owner(&entry, now)
4687            {
4688                expired_pre_ack_input(&input_inbox, session).await?
4689            } else {
4690                None
4691            };
4692            if (recovery.is_some() || continuation || replacement)
4693                && (!entry.actor.matches_session(session)
4694                    || entry.actor.project_id
4695                        != project_id_for_actor_run(session)?.map(|id| id.to_string()))
4696            {
4697                return Err(plain_actor_unsupported());
4698            }
4699            if recovery.is_none()
4700                && !continuation
4701                && !replacement
4702                && ((!fresh && !retry) || !plain_initial_history(session))
4703            {
4704                return Err(plain_actor_unsupported());
4705            }
4706            let duration = crate::runtime::execution::spawn::watchdog_policy_for_session(session)
4707                .max_total_secs
4708                .checked_add(60)
4709                .and_then(chrono::Duration::try_seconds)
4710                .ok_or_else(|| plain_actor_unsupported())?;
4711            let expires = now
4712                .checked_add_signed(duration)
4713                .ok_or_else(|| plain_actor_unsupported())?;
4714            let input_consumer = SessionInboxConsumerId::new();
4715            let input_deadline = expires.min(now + chrono::Duration::hours(1));
4716            let recovered = if let Some((id, generation)) = &recovery {
4717                if !binding.router.owns_run(&session.id, run_id).await {
4718                    return Err(plain_actor_unsupported());
4719                }
4720                let lease = SessionInboxLeaseRequest {
4721                    consumer: input_consumer.clone(),
4722                    now,
4723                    duration: input_deadline - now,
4724                };
4725                let mut claims = input_inbox
4726                    .claim_owned(&session.id, 1, Some(run_id), &lease)
4727                    .await
4728                    .map_err(|_| plain_actor_unsupported())?;
4729                let claim = claims.pop().ok_or_else(|| plain_actor_unsupported())?;
4730                if claim.claim.envelope.id != *id
4731                    || claim.claim.generation != *generation
4732                    || !matches!(&claim.claim.envelope.body, bamboo_domain::SessionMessageBody::Content(content)
4733                        if content.parts.is_empty() && !content.text.trim().is_empty() && content.text.len() <= 8192)
4734                {
4735                    return Err(plain_actor_unsupported());
4736                }
4737                Some(claim)
4738            } else {
4739                None
4740            };
4741            let claimed = store
4742                .claim_activation(&ActorActivationClaim {
4743                    actor_id: session.id.clone(),
4744                    run_id: run_id.into(),
4745                    lease_owner: input_consumer.as_str().into(),
4746                    lease_expires_at: expires,
4747                    inbox_generation: recovered
4748                        .as_ref()
4749                        .map_or(backlog.generation, |claim| claim.claim.generation),
4750                    placement_ref: release_worker.map(|worker| bamboo_domain::ActorPlacementRef {
4751                        class: bamboo_domain::ActorPlacementClass::Local,
4752                        // Written only after the real worker capability probe
4753                        // and spawn. Opaque provenance, never a live-owner grant.
4754                        lease_id: format!("owned-initial-release-v1:{worker}"),
4755                        slot_epoch: None,
4756                    }),
4757                    now,
4758                })
4759                .await
4760                .map_err(|error| AgentError::LLM(error.to_string()))?;
4761            let activation = Self {
4762                store,
4763                fence: claimed.fence(),
4764                inbox_generation: claimed.inbox_generation,
4765                created_at: session.created_at,
4766                provider_transcript: session.provider_transcript.clone(),
4767                input_consumer,
4768                input_deadline,
4769                actor_deadline: claimed.lease_expires_at,
4770                initial_release_required: release_worker.is_some(),
4771                input_inbox,
4772                permission_config,
4773                initial_input: recovered,
4774                recovering_pre_ack: recovery.is_some(),
4775                completion_router: std::sync::Mutex::new(None),
4776            };
4777            if let Err(error) = activation
4778                .store
4779                .start_activation(&activation.fence, chrono::Utc::now())
4780                .await
4781            {
4782                let _ = activation.finish(ActorActivationFinish::Failed).await;
4783                return Err(AgentError::LLM(error.to_string()));
4784            }
4785            Ok(activation)
4786        })
4787    }
4788    fn start_and_prepare<'a>(
4789        store: Arc<bamboo_storage::SessionStoreV2>,
4790        session: &'a mut Session,
4791        binding: &'a SessionInboxRuntimeBinding,
4792        run_id: Option<&'a str>,
4793        permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
4794        allow_failed_retry: bool,
4795        release_worker: Option<&'a str>,
4796    ) -> std::pin::Pin<
4797        Box<
4798            impl std::future::Future<
4799                    Output = Result<(Self, Option<PlainInitialDelivery>), AgentError>,
4800                > + Send
4801                + 'a,
4802        >,
4803    > {
4804        Box::pin(async move {
4805            let mut activation = Self::start(
4806                store,
4807                session,
4808                binding,
4809                run_id,
4810                permission_config,
4811                allow_failed_retry,
4812                release_worker,
4813            )
4814            .await?;
4815            if activation.inbox_generation == 0 {
4816                return Ok((activation, None));
4817            }
4818            let prepared = async {
4819                let run_id = run_id.ok_or_else(|| plain_actor_unsupported())?;
4820                let backlog = activation
4821                    .input_inbox
4822                    .inspect(&session.id)
4823                    .await
4824                    .map_err(|_| plain_actor_unsupported())?;
4825                let expected = if activation.recovering_pre_ack {
4826                    (0, 1)
4827                } else {
4828                    (1, 0)
4829                };
4830                if (backlog.pending, backlog.claimed) != expected
4831                    || backlog.generation != activation.inbox_generation
4832                {
4833                    return Err(plain_actor_unsupported());
4834                }
4835                let claim = match activation.initial_input.clone() {
4836                    Some(claim) => claim,
4837                    None => activation
4838                        .claim_input(binding, session, run_id)
4839                        .await?
4840                        .ok_or_else(|| plain_actor_unsupported())?,
4841                };
4842                if claim.claim.generation != activation.inbox_generation {
4843                    return Err(plain_actor_unsupported());
4844                }
4845                let delivery = activation.checkpoint_input(session, run_id, &claim).await?;
4846                activation.initial_input = Some(claim);
4847                Ok(delivery)
4848            }
4849            .await;
4850            match prepared {
4851                Ok(delivery) => Ok((activation, Some(delivery))),
4852                Err(error) => {
4853                    activation.finish(ActorActivationFinish::Failed).await?;
4854                    Err(error)
4855                }
4856            }
4857        })
4858    }
4859    fn claim_input<'a>(
4860        &'a self,
4861        binding: &'a SessionInboxRuntimeBinding,
4862        session: &'a Session,
4863        run_id: &'a str,
4864    ) -> std::pin::Pin<
4865        Box<
4866            impl std::future::Future<Output = Result<Option<SessionInboxOwnedClaim>, AgentError>>
4867                + Send
4868                + 'a,
4869        >,
4870    > {
4871        Box::pin(async move {
4872            if !self
4873                .input_inbox
4874                .inspect(&session.id)
4875                .await
4876                .map_err(|_| plain_actor_unsupported())?
4877                .activation_pending()
4878            {
4879                return Ok(None);
4880            }
4881            if run_id != self.fence.run_id || !binding.router.owns_run(&session.id, run_id).await {
4882                return Err(plain_actor_unsupported());
4883            }
4884            let now = chrono::Utc::now();
4885            if now >= self.input_deadline {
4886                return Err(plain_actor_unsupported());
4887            }
4888            let lease = SessionInboxLeaseRequest {
4889                consumer: self.input_consumer.clone(),
4890                now,
4891                duration: self.input_deadline - now,
4892            };
4893            let claim = self
4894                .input_inbox
4895                .claim_owned(&session.id, 1, Some(run_id), &lease)
4896                .await
4897                .map_err(|error| {
4898                    tracing::warn!(%error, "Actor correction claim unconfirmed");
4899                    AgentError::LLM(
4900                        "Actor correction claim unconfirmed; durable input is preserved".into(),
4901                    )
4902                })?
4903                .into_iter()
4904                .next();
4905            if let Some(claim) = &claim {
4906                if !matches!(&claim.claim.envelope.body, bamboo_domain::SessionMessageBody::Content(content)
4907                    if content.parts.is_empty() && !content.text.trim().is_empty() && content.text.len() <= 8192)
4908                {
4909                    self.input_inbox
4910                        .fail_owned(
4911                            &session.id,
4912                            claim,
4913                            &SessionInboxFailureReport {
4914                                now: chrono::Utc::now(),
4915                                error_code: "unsupported_actor_input".into(),
4916                            },
4917                        )
4918                        .await
4919                        .map_err(|error| {
4920                            tracing::warn!(%error, "Actor correction failure report unconfirmed");
4921                            AgentError::LLM(
4922                                "Actor correction failure report unconfirmed; durable input is preserved".into(),
4923                            )
4924                        })?;
4925                    return Err(plain_actor_unsupported());
4926                }
4927            }
4928            Ok(claim)
4929        })
4930    }
4931    fn checkpoint_input<'a>(
4932        &'a self,
4933        session: &'a mut Session,
4934        run_id: &'a str,
4935        claim: &'a SessionInboxOwnedClaim,
4936    ) -> std::pin::Pin<
4937        Box<
4938            impl std::future::Future<Output = Result<PlainInitialDelivery, AgentError>> + Send + 'a,
4939        >,
4940    > {
4941        Box::pin(async move {
4942            // The worker receives the committed pre-input prefix. Only typed
4943            // delivery adds the User; the Host-private bookkeeper is not a grant.
4944            let request = bamboo_storage::ActorInputCheckpoint {
4945                fence: self.fence.clone(),
4946                expected_created_at: self.created_at,
4947                claim: claim.clone(),
4948                expected_messages: session.messages.clone(),
4949                expected_provider_transcript: session.provider_transcript.clone(),
4950                expected_admission: session.session_inbox_admission().cloned(),
4951            };
4952            let mut retries = 0;
4953            let committed = loop {
4954                let result = self.input_inbox.checkpoint_actor_input(request.clone()).await;
4955                if matches!(&result, Err(bamboo_storage::ActorInputCheckpointError::PrefixConflict))
4956                    && retries < 3
4957                {
4958                    // A Parent can save ordinary tool/wait state while this
4959                    // Child checkpoints. PrefixConflict guarantees no publish;
4960                    // retry the SAME Child prefix, claim and fence, with every
4961                    // storage authority check repeated. Never adopt a changed
4962                    // Child transcript or retry an unconfirmed publication.
4963                    tokio::time::sleep(Duration::from_millis(10 << retries)).await;
4964                    retries += 1;
4965                    continue;
4966                }
4967                break result;
4968            }.map_err(|error| {
4969                tracing::warn!(%error, "Actor correction checkpoint rejected or unconfirmed; no worker dispatch or ACK");
4970                AgentError::LLM("Actor correction checkpoint unconfirmed; durable Inbox and history are preserved".into())
4971            })?;
4972            if self.recovering_pre_ack
4973                && committed.status
4974                    != bamboo_storage::ActorInputCheckpointStatus::AlreadyCheckpointed
4975            {
4976                return Err(plain_actor_unsupported());
4977            }
4978            let messages = session
4979                .messages
4980                .iter()
4981                .map(|message| {
4982                    let mut worker = message.clone();
4983                    if committed.status
4984                        == bamboo_storage::ActorInputCheckpointStatus::AlreadyCheckpointed
4985                        && worker.id == committed.envelope.id.as_str()
4986                    {
4987                        worker
4988                            .metadata
4989                            .as_mut()
4990                            .and_then(serde_json::Value::as_object_mut)
4991                            .ok_or_else(|| plain_actor_unsupported())?
4992                            .remove("_bamboo_owned_input_checkpoint");
4993                        if !bamboo_domain::is_matching_session_message(&worker, &committed.envelope)
4994                        {
4995                            return Err(plain_actor_unsupported());
4996                        }
4997                    }
4998                    serde_json::to_value(worker).map_err(|_| plain_actor_unsupported())
4999                })
5000                .collect::<Result<Vec<_>, _>>()?;
5001            *session = committed.session;
5002            Ok((
5003                messages,
5004                SessionMessageDelivery {
5005                    target_session_id: session.id.clone(),
5006                    envelope: committed.envelope,
5007                    canonical_claim_generation: committed.generation,
5008                    activation_run_id: run_id.into(),
5009                    activation_policy: claim.claim.activation_policy,
5010                },
5011            ))
5012        })
5013    }
5014    fn append_reply<'a>(
5015        &'a self,
5016        session: &'a mut Session,
5017        text: String,
5018        event_tx: &'a mpsc::Sender<AgentEvent>,
5019    ) -> std::pin::Pin<Box<impl std::future::Future<Output = Result<(), AgentError>> + Send + 'a>>
5020    {
5021        Box::pin(async move {
5022            let message = bamboo_agent_core::Message::assistant(text, None);
5023            let request = bamboo_storage::ActorTranscriptAppend {
5024                fence: self.fence.clone(),
5025                expected_created_at: self.created_at,
5026                expected_messages: session.messages.clone(),
5027                expected_provider_transcript: self.provider_transcript.clone(),
5028                messages: vec![message.clone()],
5029                native_groups: Vec::new(),
5030            };
5031            let mut retries = 0;
5032            *session = loop {
5033                let result = self.store.append_actor_transcript(request.clone()).await;
5034                if matches!(
5035                    &result,
5036                    Err(bamboo_storage::ActorTranscriptAppendError::PrefixConflict)
5037                ) && retries < 3
5038                {
5039                    // Parent writes may change the ancestor witness before
5040                    // publication. Retry this exact reply and Child prefix;
5041                    // never reload/adopt a different transcript or replay an
5042                    // unconfirmed write. Storage rechecks the same live fence.
5043                    tokio::time::sleep(Duration::from_millis(10 << retries)).await;
5044                    retries += 1;
5045                    continue;
5046                }
5047                break result;
5048            }
5049            .map_err(|error| {
5050                tracing::warn!(
5051                    %error,
5052                    "Actor reply checkpoint rejected or unconfirmed; durable history is preserved"
5053                );
5054                AgentError::LLM(format!("actor reply commit failed: {error}"))
5055            })?;
5056            let _ = event_tx
5057                .send(AgentEvent::message_appended(&session.id, &message))
5058                .await;
5059            Ok(())
5060        })
5061    }
5062    // First Terminal is a real completed plain reply, not a cache event. Commit
5063    // it before appending the typed correction, then use the existing startup
5064    // admission barrier on a second correlated Run under the SAME Host fence.
5065    fn continue_input<'a>(
5066        &'a self,
5067        client: &'a mut dyn bamboo_subagent::ChildLink,
5068        binding: &'a SessionInboxRuntimeBinding,
5069        session: &'a mut Session,
5070        run_id: &'a str,
5071        run: Option<(&'a RunSpec, &'a AtomicU64)>,
5072        expected_posture: &'a mut Option<ExpectedPermissionPosture>,
5073        text: String,
5074        event_tx: &'a mpsc::Sender<AgentEvent>,
5075    ) -> std::pin::Pin<
5076        Box<
5077            impl std::future::Future<
5078                    Output = Result<Option<(SessionInboxOwnedClaim, u64)>, AgentError>,
5079                > + Send
5080                + 'a,
5081        >,
5082    > {
5083        Box::pin(async move {
5084            let Some(claim) = self.claim_input(binding, session, run_id).await? else {
5085                return Ok(None);
5086            };
5087            let (run, epochs) = run.ok_or_else(|| plain_actor_unsupported())?;
5088            self.append_reply(session, text, event_tx).await?;
5089            let (messages, delivery) = self.checkpoint_input(session, run_id, &claim).await?;
5090            // Fresh actual Host audit, unchanged permission/ceiling checks.
5091            self.refresh_continuation_posture(session, run, expected_posture)?;
5092            let epoch = epochs
5093                .fetch_add(1, Ordering::Relaxed)
5094                .checked_add(1)
5095                .ok_or_else(|| plain_actor_unsupported())?;
5096            let mut next = run.clone();
5097            next.messages = messages;
5098            next.initial_session_messages = vec![delivery];
5099            next.execution_epoch = epoch;
5100            client.send(ParentFrame::Run(next)).await.map_err(|_| {
5101                AgentError::LLM(
5102                    "Actor correction dispatch unconfirmed; durable input is preserved".into(),
5103                )
5104            })?;
5105            Ok(Some((claim, epoch)))
5106        })
5107    }
5108    fn refresh_continuation_posture(
5109        &self,
5110        session: &Session,
5111        run: &RunSpec,
5112        expected: &mut Option<ExpectedPermissionPosture>,
5113    ) -> Result<(), AgentError> {
5114        let Some(expected) = expected.as_mut() else {
5115            return Err(plain_actor_unsupported());
5116        };
5117        let audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
5118            .ok_or_else(|| plain_actor_unsupported())?;
5119        if audit.policy_revision != expected.policy_revision
5120            || audit.resolution != expected.resolution
5121            || audit.executor_mapping != expected.executor_mapping
5122        {
5123            return Err(plain_actor_unsupported());
5124        }
5125        match (&self.permission_config, &run.permission_policy) {
5126            (Some(config), Some(policy)) => {
5127                ensure_no_active_scoped_session_denies(config, &session.id)?;
5128                if config.policy_revision() != policy.revision
5129                    || policy.revision != audit.policy_revision
5130                    || policy.session_id != session.id
5131                    || policy.workspace_path != session.workspace
5132                    || policy.inherit_session_grants
5133                    || serde_json::to_value(config.to_serializable())
5134                        .map_err(|_| plain_actor_unsupported())?
5135                        != policy.policy
5136                {
5137                    return Err(plain_actor_unsupported());
5138                }
5139            }
5140            (None, None) => {}
5141            _ => return Err(plain_actor_unsupported()),
5142        }
5143        expected.expected_audit_revision = Some(audit.audit_revision);
5144        Ok(())
5145    }
5146    fn release_initial_input<'a>(
5147        &'a self,
5148        binding: &'a SessionInboxRuntimeBinding,
5149        session: &'a Session,
5150        run: &'a RunSpec,
5151        epoch: u64,
5152        claim: &'a SessionInboxOwnedClaim,
5153        request: &'a bamboo_subagent::proto::InitialInputReleaseRequest,
5154        expected: &'a mut Option<ExpectedPermissionPosture>,
5155        previous: Option<&'a bamboo_subagent::proto::InitialInputRelease>,
5156    ) -> std::pin::Pin<
5157        Box<
5158            impl std::future::Future<
5159                    Output = Result<bamboo_subagent::proto::InitialInputRelease, AgentError>,
5160                > + Send
5161                + 'a,
5162        >,
5163    > {
5164        Box::pin(async move {
5165            if !self.initial_release_required {
5166                return Err(plain_actor_unsupported());
5167            }
5168            let delivery = SessionMessageDelivery {
5169                target_session_id: session.id.clone(),
5170                envelope: claim.claim.envelope.clone(),
5171                canonical_claim_generation: claim.claim.generation,
5172                activation_run_id: self.fence.run_id.clone(),
5173                activation_policy: claim.claim.activation_policy,
5174            };
5175            let mut current_run = run.clone();
5176            current_run.execution_epoch = epoch;
5177            let exact = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
5178                &current_run,
5179                &delivery,
5180                request.nonce.clone(),
5181            )
5182            .map_err(|_| plain_actor_unsupported())?;
5183            if &exact != request
5184                || request.child_id != session.id
5185                || request.created_at != session.created_at
5186                || request.spawn_depth != session.spawn_depth
5187                || request.parent_id != session.parent_session_id.as_deref().unwrap_or_default()
5188                || request.root_id != session.root_session_id
5189                || request.project_id != project_id_for_actor_run(session)?
5190                || !binding
5191                    .router
5192                    .owns_run(&session.id, &self.fence.run_id)
5193                    .await
5194            {
5195                return Err(plain_actor_unsupported());
5196            }
5197            let deadline = self
5198                .actor_deadline
5199                .min(self.input_deadline)
5200                .min(claim.lease.expires_at);
5201            if chrono::Utc::now() >= deadline {
5202                return Err(plain_actor_unsupported());
5203            }
5204            self.store
5205                .validate_fence(&self.fence, chrono::Utc::now())
5206                .await
5207                .map_err(|_| plain_actor_unsupported())?;
5208            let actual =
5209                bamboo_agent_core::storage::Storage::load_session(self.store.as_ref(), &session.id)
5210                    .await
5211                    .map_err(|_| plain_actor_unsupported())?
5212                    .ok_or_else(|| plain_actor_unsupported())?;
5213            if actual.created_at != session.created_at
5214                || actual.root_session_id != session.root_session_id
5215                || actual.parent_session_id != session.parent_session_id
5216                || actual.spawn_depth != session.spawn_depth
5217                || project_id_for_actor_run(&actual)? != request.project_id
5218                || serde_json::to_value(&actual.messages).map_err(|_| plain_actor_unsupported())?
5219                    != serde_json::to_value(&session.messages)
5220                        .map_err(|_| plain_actor_unsupported())?
5221                || serde_json::to_value(&actual.provider_transcript)
5222                    .map_err(|_| plain_actor_unsupported())?
5223                    != serde_json::to_value(&session.provider_transcript)
5224                        .map_err(|_| plain_actor_unsupported())?
5225                || serde_json::to_value(actual.session_inbox_admission())
5226                    .map_err(|_| plain_actor_unsupported())?
5227                    != serde_json::to_value(session.session_inbox_admission())
5228                        .map_err(|_| plain_actor_unsupported())?
5229            {
5230                return Err(plain_actor_unsupported());
5231            }
5232            self.refresh_continuation_posture(&actual, &current_run, expected)?;
5233            if let Some(previous) = previous {
5234                if !previous.permits(request, chrono::Utc::now())
5235                    || previous.expires_at != deadline
5236                    || !self
5237                        .input_inbox
5238                        .was_admitted(&session.id, &claim.claim.envelope.id)
5239                        .await
5240                        .map_err(|_| plain_actor_unsupported())?
5241                {
5242                    return Err(plain_actor_unsupported());
5243                }
5244            } else {
5245                // Full current-prefix AlreadyCheckpointed readback still checks
5246                // the actual physical owner. A local worker receipt is insufficient.
5247                let checkpoint = bamboo_storage::ActorInputCheckpoint {
5248                    fence: self.fence.clone(),
5249                    expected_created_at: self.created_at,
5250                    claim: claim.clone(),
5251                    expected_messages: actual.messages.clone(),
5252                    expected_provider_transcript: actual.provider_transcript.clone(),
5253                    expected_admission: actual.session_inbox_admission().cloned(),
5254                };
5255                let mut retries = 0;
5256                loop {
5257                    let result = self
5258                        .input_inbox
5259                        .checkpoint_actor_input(checkpoint.clone())
5260                        .await;
5261                    if matches!(
5262                        &result,
5263                        Err(bamboo_storage::ActorInputCheckpointError::PrefixConflict)
5264                    ) && retries < 3
5265                    {
5266                        // As at initial checkpoint, this error guarantees no
5267                        // publication. Repeat the identical prefix, claim and
5268                        // fence; a Parent's ordinary save grants no authority.
5269                        tracing::warn!(retries, "Actor initial release prefix changed before publication; retrying same claim");
5270                        tokio::time::sleep(Duration::from_millis(10 << retries)).await;
5271                        retries += 1;
5272                        continue;
5273                    }
5274                    result.map_err(|error| {
5275                        tracing::warn!(%error, "Actor initial release checkpoint rejected or unconfirmed");
5276                        plain_actor_unsupported()
5277                    })?;
5278                    break;
5279                }
5280                self.confirm_input(
5281                    binding,
5282                    &actual,
5283                    &self.fence.run_id,
5284                    claim,
5285                    &bamboo_subagent::proto::SessionMessageAdmissionConfirmation {
5286                        target_session_id: request.child_id.clone(),
5287                        envelope_id: request.envelope_id.clone(),
5288                        canonical_claim_generation: request.generation,
5289                        activation_run_id: request.activation_run_id.clone(),
5290                    },
5291                )
5292                .await?;
5293            }
5294            // ACK's terminal receipt is idempotent even after expiry. It cannot
5295            // replace these final current-owner/deadline/policy checks.
5296            if chrono::Utc::now() >= deadline
5297                || !binding
5298                    .router
5299                    .owns_run(&session.id, &self.fence.run_id)
5300                    .await
5301            {
5302                return Err(plain_actor_unsupported());
5303            }
5304            self.store
5305                .validate_fence(&self.fence, chrono::Utc::now())
5306                .await
5307                .map_err(|_| plain_actor_unsupported())?;
5308            let final_session =
5309                bamboo_agent_core::storage::Storage::load_session(self.store.as_ref(), &session.id)
5310                    .await
5311                    .map_err(|_| plain_actor_unsupported())?
5312                    .ok_or_else(|| plain_actor_unsupported())?;
5313            self.store
5314                .validate_fence(&self.fence, chrono::Utc::now())
5315                .await
5316                .map_err(|_| plain_actor_unsupported())?;
5317            self.refresh_continuation_posture(&final_session, &current_run, expected)?;
5318            if chrono::Utc::now() >= deadline {
5319                return Err(plain_actor_unsupported());
5320            }
5321            Ok(bamboo_subagent::proto::InitialInputRelease {
5322                request: request.clone(),
5323                expires_at: deadline,
5324            })
5325        })
5326    }
5327    async fn confirm_input(
5328        &self,
5329        binding: &SessionInboxRuntimeBinding,
5330        session: &Session,
5331        run_id: &str,
5332        claim: &SessionInboxOwnedClaim,
5333        confirmation: &bamboo_subagent::proto::SessionMessageAdmissionConfirmation,
5334    ) -> Result<(), AgentError> {
5335        if confirmation.target_session_id != session.id
5336            || confirmation.envelope_id != claim.claim.envelope.id.as_str()
5337            || confirmation.canonical_claim_generation != claim.claim.generation
5338            || confirmation.activation_run_id != run_id
5339            || run_id != self.fence.run_id
5340            || !binding.router.owns_run(&session.id, run_id).await
5341        {
5342            return Err(plain_actor_unsupported());
5343        }
5344        self.input_inbox
5345            .ack_owned(&session.id, claim, chrono::Utc::now())
5346            .await
5347            .map_err(|error| {
5348                tracing::warn!(%error, "Actor correction ACK unresolved");
5349                AgentError::LLM(
5350                    "Actor correction ACK unresolved; durable input is preserved".into(),
5351                )
5352            })
5353    }
5354    async fn finish(&self, outcome: ActorActivationFinish) -> Result<(), AgentError> {
5355        self.finish_with_observer(outcome, None).await
5356    }
5357
5358    async fn finish_with_observer(
5359        &self,
5360        outcome: ActorActivationFinish,
5361        observer: Option<&dyn ActorEventObserver>,
5362    ) -> Result<(), AgentError> {
5363        let committed = self
5364            .store
5365            .finish_activation(&self.fence, chrono::Utc::now(), outcome)
5366            .await
5367            .map_err(|error| AgentError::LLM(format!("Actor completion unconfirmed: {error}")))?;
5368        // A validated final Terminal hands off this one router. Failed writes
5369        // never publish, and neither a retry nor another finish can reuse it.
5370        let router = self.completion_router.lock().recover_poison().take();
5371        if let (Some(observer), Some(router)) = (observer, router) {
5372            // The Directory may already hold a successor by this point. Never
5373            // relabel the old completion, and never turn an already committed
5374            // success into an execution failure if best-effort delivery is lost.
5375            match self.store.inspect_actor(&self.fence.actor_id).await {
5376                Ok(entry) => match router.into_host_completion(&entry, &committed) {
5377                    Ok(event) => observer.publish(event),
5378                    Err(_) => tracing::debug!(actor_id = %self.fence.actor_id,
5379                        "Actor completion notification superseded"),
5380                },
5381                Err(error) => tracing::warn!(actor_id = %self.fence.actor_id, %error,
5382                    "Actor completion committed; notification snapshot unavailable"),
5383            }
5384        }
5385        Ok(())
5386    }
5387}
5388fn local_tool_history_unsupported() -> AgentError {
5389    AgentError::LLM("local_tool_history_unsupported".into())
5390}
5391
5392#[derive(Clone, Copy)]
5393enum LocalToolTerminal<'a> {
5394    Completed(Option<&'a str>),
5395    // Only the selected Cancelled terminal may finish a validated Read/Glob
5396    // prefix. This is Host control flow, never a Worker DATA authority flag.
5397    Cancelled,
5398}
5399
5400impl LocalToolTerminal<'_> {
5401    fn permits_checkpoint(self, cancel: &CancellationToken) -> bool {
5402        match self {
5403            Self::Completed(_) => !cancel.is_cancelled(),
5404            Self::Cancelled => cancel.is_cancelled(),
5405        }
5406    }
5407}
5408
5409#[derive(Default)]
5410struct LocalToolCollector {
5411    messages: Option<Vec<bamboo_agent_core::Message>>,
5412    starts: HashMap<String, (String, serde_json::Value)>,
5413    outcomes: HashMap<String, (bool, String)>,
5414    // Installed only by the Host's exact Task callback receipt. Worker event
5415    // JSON and transcript metadata cannot grant this terminal exception.
5416    ticket_yield: Option<(String, bamboo_tickets::PendingRequest)>,
5417}
5418
5419impl LocalToolCollector {
5420    fn ticket_event(
5421        &self,
5422        value: &serde_json::Value,
5423        committed: &HashMap<String, serde_json::Value>,
5424    ) -> Result<bool, AgentError> {
5425        if value["type"] == bamboo_subagent::proto::LocalToolMessages::TYPE {
5426            return Ok(true);
5427        }
5428        match serde_json::from_value::<AgentEvent>(value.clone())
5429            .map_err(|_| local_tool_history_unsupported())?
5430        {
5431            AgentEvent::ToolStart { tool_name, .. }
5432                if !matches!(tool_name.as_str(), "Task" | "Read" | "Write") =>
5433            {
5434                Err(local_tool_history_unsupported())
5435            }
5436            AgentEvent::ToolComplete {
5437                tool_call_id,
5438                result,
5439            } if result.success => {
5440                let Some((name, args)) = self.starts.get(&tool_call_id) else {
5441                    return Err(local_tool_history_unsupported());
5442                };
5443                let valid = if name == "Task" {
5444                    committed.get(&tool_call_id) == Some(args)
5445                } else {
5446                    committed.get(&tool_call_id).is_some_and(|proof| {
5447                        proof["file_tool"] == *name
5448                            && proof["arguments"] == *args
5449                            && proof["result"] == result.result
5450                    })
5451                };
5452                if !valid {
5453                    return Err(AgentError::LLM(
5454                        "LocalPlan success has no exact Host receipt".into(),
5455                    ));
5456                }
5457                Ok(true)
5458            }
5459            // Only the committed Host projection may publish a plan update.
5460            AgentEvent::TaskListUpdated { .. } => Ok(false),
5461            _ => Ok(true),
5462        }
5463    }
5464
5465    fn event(&mut self, value: &serde_json::Value) -> Result<bool, AgentError> {
5466        use bamboo_subagent::proto::LocalToolMessages;
5467        if self.messages.is_some() {
5468            return Err(local_tool_history_unsupported());
5469        }
5470        if value["type"] == LocalToolMessages::TYPE {
5471            let observation: LocalToolMessages = serde_json::from_value(value.clone())
5472                .map_err(|_| local_tool_history_unsupported())?;
5473            self.messages = Some(
5474                observation
5475                    .validate()
5476                    .map_err(|_| local_tool_history_unsupported())?,
5477            );
5478            return Ok(false);
5479        }
5480        let event: AgentEvent =
5481            serde_json::from_value(value.clone()).map_err(|_| local_tool_history_unsupported())?;
5482        match event {
5483            AgentEvent::ToolStart {
5484                tool_call_id,
5485                tool_name,
5486                arguments,
5487            } => {
5488                if !arguments.is_object()
5489                    || !matches!(tool_name.as_str(), "Read" | "Glob" | "Write" | "Task")
5490                    || tool_call_id.is_empty()
5491                    || tool_call_id.len() > 128
5492                    || self.starts.len() >= LocalToolMessages::MAX_PAIRS
5493                    || self.outcomes.contains_key(&tool_call_id)
5494                    || self
5495                        .starts
5496                        .insert(tool_call_id, (tool_name, arguments))
5497                        .is_some()
5498                {
5499                    return Err(local_tool_history_unsupported());
5500                }
5501            }
5502            AgentEvent::ToolComplete {
5503                tool_call_id,
5504                result,
5505            } => {
5506                if !result.images.is_empty() {
5507                    return Err(local_tool_history_unsupported());
5508                }
5509                self.outcome(tool_call_id, result.success, result.result)?;
5510            }
5511            AgentEvent::ToolError {
5512                tool_call_id,
5513                error,
5514            } => {
5515                self.outcome(tool_call_id, false, format!("Error: {error}"))?;
5516            }
5517            AgentEvent::ToolToken { tool_call_id, .. } => {
5518                if !self.starts.contains_key(&tool_call_id)
5519                    || self.outcomes.contains_key(&tool_call_id)
5520                {
5521                    return Err(local_tool_history_unsupported());
5522                }
5523            }
5524            AgentEvent::ToolLifecycle {
5525                tool_call_id,
5526                tool_name,
5527                is_mutating,
5528                auto_approved,
5529                phase,
5530                ..
5531            } => {
5532                if self.starts.get(&tool_call_id).map(|(name, _)| name) != Some(&tool_name)
5533                    || is_mutating != matches!(tool_name.as_str(), "Write" | "Task")
5534                    || auto_approved != !matches!(tool_name.as_str(), "Write" | "Task")
5535                    || !matches!(phase.as_str(), "begin" | "finished" | "error" | "cancelled")
5536                {
5537                    return Err(local_tool_history_unsupported());
5538                }
5539            }
5540            AgentEvent::ReasoningToken { .. } => {
5541                // Text reasoning is a stream observation. Its complete value
5542                // is preserved by the bounded typed completion, which still
5543                // rejects signatures and unsupported provider state.
5544            }
5545            AgentEvent::ContextSummarized { .. }
5546            | AgentEvent::ContextArchived { .. }
5547            | AgentEvent::ContextCompressionStatus { .. }
5548            | AgentEvent::NeedClarification { .. }
5549            | AgentEvent::SubAgentStarted { .. }
5550            | AgentEvent::SubAgentEvent { .. } => return Err(local_tool_history_unsupported()),
5551            // Worker commit events are observations, not canonical Host commits.
5552            AgentEvent::SessionHistoryCommitted { .. } | AgentEvent::MessageAppended { .. } => {
5553                return Ok(false)
5554            }
5555            _ => {}
5556        }
5557        Ok(true)
5558    }
5559
5560    fn outcome(&mut self, id: String, success: bool, text: String) -> Result<(), AgentError> {
5561        if id.is_empty()
5562            || id.len() > 128
5563            || text.len() > bamboo_subagent::proto::LocalToolMessages::MAX_BYTES
5564            || self.outcomes.len() >= bamboo_subagent::proto::LocalToolMessages::MAX_PAIRS
5565            || self.outcomes.insert(id, (success, text)).is_some()
5566        {
5567            return Err(local_tool_history_unsupported());
5568        }
5569        Ok(())
5570    }
5571
5572    fn suffix(
5573        self,
5574        host: &Session,
5575        tools: &[String],
5576        read_only: bool,
5577        terminal: Option<&str>,
5578    ) -> Result<Vec<bamboo_agent_core::Message>, AgentError> {
5579        self.suffix_for_terminal(
5580            host,
5581            tools,
5582            read_only,
5583            LocalToolTerminal::Completed(terminal),
5584        )
5585    }
5586
5587    fn suffix_for_terminal(
5588        self,
5589        host: &Session,
5590        tools: &[String],
5591        read_only: bool,
5592        terminal: LocalToolTerminal<'_>,
5593    ) -> Result<Vec<bamboo_agent_core::Message>, AgentError> {
5594        use bamboo_subagent::proto::LocalToolMessages;
5595        let cancelled = matches!(terminal, LocalToolTerminal::Cancelled);
5596        if cancelled
5597            && (!read_only
5598                || tools.is_empty()
5599                || tools
5600                    .iter()
5601                    .any(|name| !matches!(name.as_str(), "Read" | "Glob"))
5602                || self.ticket_yield.is_some())
5603        {
5604            return Err(local_tool_history_unsupported());
5605        }
5606        let data = self.messages.ok_or_else(local_tool_history_unsupported)?;
5607        let candidate: Vec<_> = data.iter().skip_while(|m| m.role == Role::System).collect();
5608        let prefix: Vec<_> = host
5609            .messages
5610            .iter()
5611            .filter(|m| m.role != Role::System)
5612            .collect();
5613        if !host.provider_transcript.groups().is_empty()
5614            || candidate.len() < prefix.len()
5615            || (!cancelled && candidate.len() == prefix.len())
5616            || serde_json::to_value(&candidate[..prefix.len()])
5617                .map_err(|_| local_tool_history_unsupported())?
5618                != serde_json::to_value(&prefix).map_err(|_| local_tool_history_unsupported())?
5619        {
5620            return Err(local_tool_history_unsupported());
5621        }
5622        let mut ids = std::collections::HashSet::new();
5623        let mut calls = std::collections::HashSet::new();
5624        for message in &host.messages {
5625            if !ids.insert(message.id.clone()) {
5626                return Err(local_tool_history_unsupported());
5627            }
5628            for call in message.tool_calls.iter().flatten() {
5629                calls.insert(call.id.clone());
5630            }
5631            if let Some(id) = &message.tool_call_id {
5632                calls.insert(id.clone());
5633            }
5634        }
5635        let suffix: Vec<_> = candidate[prefix.len()..]
5636            .iter()
5637            .map(|message| (**message).clone())
5638            .collect();
5639        let mut pending = HashMap::new();
5640        let mut used = std::collections::HashSet::new();
5641        for message in &suffix {
5642            if !ids.insert(message.id.clone()) || message.never_compress {
5643                return Err(local_tool_history_unsupported());
5644            }
5645            match message.role {
5646                Role::Assistant => {
5647                    if !pending.is_empty()
5648                        || message.metadata.is_some()
5649                        || message.tool_call_id.is_some()
5650                        || message.tool_success.is_some()
5651                    {
5652                        return Err(local_tool_history_unsupported());
5653                    }
5654                    if let Some(batch) = &message.tool_calls {
5655                        if batch.is_empty() {
5656                            return Err(local_tool_history_unsupported());
5657                        }
5658                        for call in batch {
5659                            let arguments =
5660                                serde_json::from_str::<serde_json::Value>(&call.function.arguments)
5661                                    .ok()
5662                                    .filter(|arguments| arguments.is_object())
5663                                    .ok_or_else(local_tool_history_unsupported)?;
5664                            if call.id.is_empty()
5665                                || call.id.len() > 128
5666                                || call.tool_type != "function"
5667                                || !calls.insert(call.id.clone())
5668                                || calls.len() > LocalToolMessages::MAX_MESSAGES
5669                                || !matches!(
5670                                    call.function.name.as_str(),
5671                                    "Read" | "Glob" | "Write" | "Task"
5672                                )
5673                                || (cancelled
5674                                    && !matches!(call.function.name.as_str(), "Read" | "Glob"))
5675                                || self.starts.get(&call.id).is_some_and(|(name, actual)| {
5676                                    name != &call.function.name || actual != &arguments
5677                                })
5678                            {
5679                                return Err(local_tool_history_unsupported());
5680                            }
5681                            pending.insert(call.id.clone(), call.function.name.clone());
5682                        }
5683                    } else if message.content.trim().is_empty() {
5684                        return Err(local_tool_history_unsupported());
5685                    }
5686                }
5687                Role::Tool => {
5688                    let id = message
5689                        .tool_call_id
5690                        .as_ref()
5691                        .ok_or_else(local_tool_history_unsupported)?;
5692                    let name = pending
5693                        .remove(id)
5694                        .ok_or_else(local_tool_history_unsupported)?;
5695                    let success = message
5696                        .tool_success
5697                        .ok_or_else(local_tool_history_unsupported)?;
5698                    if message.tool_calls.is_some()
5699                        || message.phase.is_some()
5700                        || self.outcomes.get(id) != Some(&(success, message.content.clone()))
5701                        || !used.insert(id.clone())
5702                        || used.len() > LocalToolMessages::MAX_PAIRS
5703                    {
5704                        return Err(local_tool_history_unsupported());
5705                    }
5706                    let denied_write = name == "Write" && (read_only || !tools.contains(&name));
5707                    if denied_write {
5708                        if success
5709                            || !(message.content.contains("native_tool_ceiling_denied")
5710                                || message
5711                                    .content
5712                                    .contains("not callable at the current conversation position"))
5713                        {
5714                            return Err(local_tool_history_unsupported());
5715                        }
5716                    } else if !tools.contains(&name)
5717                        || self.starts.get(id).map(|(actual, _)| actual) != Some(&name)
5718                    {
5719                        return Err(local_tool_history_unsupported());
5720                    }
5721                    if let Some(metadata) = &message.metadata {
5722                        let object = metadata
5723                            .as_object()
5724                            .ok_or_else(local_tool_history_unsupported)?;
5725                        if object.len() != 5
5726                            || metadata["elapsed_ms"].as_u64().is_none()
5727                            || metadata["tool_name"].as_str() != Some(name.as_str())
5728                            || metadata["is_mutating"].as_bool()
5729                                != Some(matches!(name.as_str(), "Write" | "Task"))
5730                            || metadata["auto_approved"].as_bool()
5731                                != Some(!matches!(name.as_str(), "Write" | "Task"))
5732                            || metadata["success"].as_bool() != Some(success)
5733                        {
5734                            return Err(local_tool_history_unsupported());
5735                        }
5736                    }
5737                }
5738                _ => return Err(local_tool_history_unsupported()),
5739            }
5740        }
5741        let last = suffix.last();
5742        let completed_text = match terminal {
5743            LocalToolTerminal::Completed(text) => text,
5744            LocalToolTerminal::Cancelled => None,
5745        };
5746        let question_terminal =
5747            self.ticket_yield.as_ref().is_some_and(|(id, question)| {
5748                last.is_some_and(|last| last.role == Role::Tool
5749                && last.tool_call_id.as_ref() == Some(id)
5750                && last.tool_success == Some(true)
5751                && self.starts.get(id).is_some_and(|(name, _)| name == "Task")
5752                && completed_text.is_none_or(str::is_empty)
5753                && serde_json::from_str::<serde_json::Value>(&last.content).ok()
5754                    == Some(serde_json::json!({"status":"waiting_for_answer", "request":question})))
5755            });
5756        let report_terminal = last.is_some_and(|last| {
5757            last.role == Role::Assistant
5758                && last.tool_calls.is_none()
5759                && last.phase != Some(bamboo_domain::MessagePhase::Commentary)
5760                && completed_text == Some(last.content.as_str())
5761        });
5762        if !pending.is_empty()
5763            || used.len() != self.outcomes.len()
5764            || self.starts.keys().any(|id| !used.contains(id))
5765            || !(cancelled || report_terminal || question_terminal)
5766        {
5767            return Err(local_tool_history_unsupported());
5768        }
5769        Ok(suffix)
5770    }
5771}
5772
5773async fn validate_local_tool_checkpoint(
5774    session: &Session,
5775    binding: &SessionInboxRuntimeBinding,
5776    run_id: &str,
5777    terminal: LocalToolTerminal<'_>,
5778    cancel: &CancellationToken,
5779) -> Result<(), AgentError> {
5780    if !terminal.permits_checkpoint(cancel) || !binding.router.owns_run(&session.id, run_id).await {
5781        return Err(local_tool_history_unsupported());
5782    }
5783    if matches!(terminal, LocalToolTerminal::Cancelled) {
5784        let backlog = binding
5785            .inbox
5786            .inspect(&session.id)
5787            .await
5788            .map_err(|_| local_tool_history_unsupported())?;
5789        if backlog.pending != 0 || backlog.claimed != 0 || backlog.activation_pending() {
5790            // An unclaimed correction can race its delivery notification.
5791            // Inspect only: neither admission nor ACK belongs to cancellation.
5792            return Err(local_tool_history_unsupported());
5793        }
5794    }
5795    Ok(())
5796}
5797
5798async fn commit_local_tool_history(
5799    collector: LocalToolCollector,
5800    session: &mut Session,
5801    binding: &SessionInboxRuntimeBinding,
5802    run_id: &str,
5803    selection: (&[String], bool),
5804    terminal: LocalToolTerminal<'_>,
5805    cancel: &CancellationToken,
5806) -> Result<(), AgentError> {
5807    validate_local_tool_checkpoint(session, binding, run_id, terminal, cancel).await?;
5808    let latest = binding
5809        .storage
5810        .load_session(&session.id)
5811        .await
5812        .map_err(|_| local_tool_history_unsupported())?
5813        .ok_or_else(local_tool_history_unsupported)?;
5814    if latest.id != session.id
5815        || latest.kind != session.kind
5816        || latest.created_at != session.created_at
5817        || latest.root_session_id != session.root_session_id
5818        || latest.parent_session_id != session.parent_session_id
5819        || latest.spawn_depth != session.spawn_depth
5820        || latest.project_id_meta() != session.project_id_meta()
5821        || serde_json::to_value(&latest.provider_transcript)
5822            .map_err(|_| local_tool_history_unsupported())?
5823            != serde_json::to_value(&session.provider_transcript)
5824                .map_err(|_| local_tool_history_unsupported())?
5825    {
5826        return Err(local_tool_history_unsupported());
5827    }
5828    let suffix = match terminal {
5829        LocalToolTerminal::Completed(text) => {
5830            collector.suffix(&latest, selection.0, selection.1, text)?
5831        }
5832        LocalToolTerminal::Cancelled => {
5833            collector.suffix_for_terminal(&latest, selection.0, selection.1, terminal)?
5834        }
5835    };
5836    let before = session.clone();
5837    let mut staged = session.clone();
5838    staged.messages = latest.messages.clone();
5839    staged.messages.extend(suffix.iter().cloned());
5840    staged.updated_at = chrono::Utc::now();
5841    let cancelled_messages = if matches!(terminal, LocalToolTerminal::Cancelled) {
5842        Some(serde_json::to_value(&staged.messages).map_err(|_| local_tool_history_unsupported())?)
5843    } else {
5844        None
5845    };
5846    validate_local_tool_checkpoint(session, binding, run_id, terminal, cancel).await?;
5847    binding
5848        .persistence
5849        .checkpoint_runtime_session(&mut staged)
5850        .await
5851        .map_err(|_| local_tool_history_unsupported())?;
5852    let saved = binding
5853        .storage
5854        .load_session(&session.id)
5855        .await
5856        .map_err(|_| local_tool_history_unsupported())?
5857        .ok_or_else(local_tool_history_unsupported)?;
5858    if serde_json::to_value(&saved.provider_transcript)
5859        .map_err(|_| local_tool_history_unsupported())?
5860        != serde_json::to_value(&latest.provider_transcript)
5861            .map_err(|_| local_tool_history_unsupported())?
5862    {
5863        return Err(local_tool_history_unsupported());
5864    }
5865    let start = match suffix.first() {
5866        Some(first) => saved
5867            .messages
5868            .iter()
5869            .position(|message| message.id == first.id)
5870            .ok_or_else(local_tool_history_unsupported)?,
5871        None => latest.messages.len(),
5872    };
5873    if cancelled_messages.as_ref().is_some_and(|expected| {
5874        serde_json::to_value(&saved.messages).ok().as_ref() != Some(expected)
5875    }) {
5876        // A new canonical input during cancellation has not been applied by
5877        // this Worker. Preserve it, but do not certify it with the old terminal.
5878        return Err(local_tool_history_unsupported());
5879    }
5880    if saved.messages.len() < latest.messages.len()
5881        || serde_json::to_value(&saved.messages[..latest.messages.len()])
5882            .map_err(|_| local_tool_history_unsupported())?
5883            != serde_json::to_value(&latest.messages)
5884                .map_err(|_| local_tool_history_unsupported())?
5885        || serde_json::to_value(saved.messages.get(start..start + suffix.len()))
5886            .map_err(|_| local_tool_history_unsupported())?
5887            != serde_json::to_value(Some(&suffix[..]))
5888                .map_err(|_| local_tool_history_unsupported())?
5889    {
5890        return Err(local_tool_history_unsupported());
5891    }
5892    // Append-safe reconciliation may add a concurrent Host User; it may never
5893    // rewrite the validated Host prefix or any worker row being committed.
5894    let expected = latest.messages.iter().chain(&suffix);
5895    for message in expected {
5896        let matches: Vec<_> = saved
5897            .messages
5898            .iter()
5899            .filter(|current| current.id == message.id)
5900            .collect();
5901        if matches.len() != 1
5902            || serde_json::to_value(matches[0]).map_err(|_| local_tool_history_unsupported())?
5903                != serde_json::to_value(message).map_err(|_| local_tool_history_unsupported())?
5904        {
5905            *session = before;
5906            return Err(local_tool_history_unsupported());
5907        }
5908    }
5909    validate_local_tool_checkpoint(session, binding, run_id, terminal, cancel).await?;
5910    staged.messages = saved.messages;
5911    *session = staged;
5912    Ok(())
5913}
5914
5915#[derive(Default)]
5916struct ReadOnlyActorCollector {
5917    start: Option<(String, serde_json::Value)>,
5918    result: Option<String>,
5919    messages: Option<Vec<bamboo_agent_core::Message>>,
5920}
5921impl ReadOnlyActorCollector {
5922    fn event(&mut self, value: &serde_json::Value) -> Result<bool, AgentError> {
5923        if self.messages.is_some() {
5924            return Err(plain_actor_unsupported());
5925        }
5926        if value["type"] == "owned_readonly_transcript" {
5927            let tail: bamboo_subagent::proto::ReadOnlyActorTranscript =
5928                serde_json::from_value(value.clone()).map_err(|_| plain_actor_unsupported())?;
5929            tail.validate().map_err(|_| plain_actor_unsupported())?;
5930            let bamboo_subagent::proto::ReadOnlyActorTranscript::Complete { messages } = tail;
5931            let typed: Vec<bamboo_agent_core::Message> = messages
5932                .iter()
5933                .map(|raw| {
5934                    let message: bamboo_agent_core::Message =
5935                        serde_json::from_value(raw.clone())
5936                            .map_err(|_| plain_actor_unsupported())?;
5937                    if serde_json::to_value(&message).map_err(|_| plain_actor_unsupported())?
5938                        != *raw
5939                    {
5940                        return Err(plain_actor_unsupported());
5941                    }
5942                    Ok(message)
5943                })
5944                .collect::<Result<_, _>>()?;
5945            let (id, args) = self
5946                .start
5947                .as_ref()
5948                .ok_or_else(|| plain_actor_unsupported())?;
5949            let call = typed[0]
5950                .tool_calls
5951                .as_ref()
5952                .and_then(|calls| {
5953                    if calls.len() == 1 {
5954                        calls.first()
5955                    } else {
5956                        None
5957                    }
5958                })
5959                .ok_or_else(|| plain_actor_unsupported())?;
5960            if call.id != *id
5961                || call.function.name != "Glob"
5962                || serde_json::from_str::<serde_json::Value>(&call.function.arguments)
5963                    .map_err(|_| plain_actor_unsupported())?
5964                    != *args
5965                || typed[1].tool_call_id.as_ref() != Some(id)
5966                || typed[1].tool_success != Some(true)
5967                || self.result.as_ref() != Some(&typed[1].content)
5968            {
5969                return Err(plain_actor_unsupported());
5970            }
5971            self.messages = Some(typed);
5972            return Ok(false);
5973        }
5974        let event: AgentEvent =
5975            serde_json::from_value(value.clone()).map_err(|_| plain_actor_unsupported())?;
5976        match event {
5977            AgentEvent::ToolStart {
5978                tool_call_id,
5979                tool_name,
5980                arguments,
5981            } => {
5982                if tool_name != "Glob"
5983                    || tool_call_id.trim().is_empty()
5984                    || self.start.is_some()
5985                    || !arguments.is_object()
5986                {
5987                    return Err(plain_actor_unsupported());
5988                }
5989                self.start = Some((tool_call_id, arguments));
5990                Ok(true)
5991            }
5992            AgentEvent::ToolComplete {
5993                tool_call_id,
5994                result,
5995            } => {
5996                if self.start.as_ref().map(|s| &s.0) != Some(&tool_call_id)
5997                    || self.result.is_some()
5998                    || !result.success
5999                    || !result.images.is_empty()
6000                {
6001                    return Err(plain_actor_unsupported());
6002                }
6003                self.result = Some(result.result);
6004                Ok(true)
6005            }
6006            AgentEvent::ToolToken { tool_call_id, .. } => {
6007                if self.start.as_ref().map(|s| &s.0) != Some(&tool_call_id) || self.result.is_some()
6008                {
6009                    return Err(plain_actor_unsupported());
6010                }
6011                Ok(true)
6012            }
6013            AgentEvent::ToolLifecycle {
6014                tool_call_id,
6015                tool_name,
6016                is_mutating,
6017                phase,
6018                error,
6019                ..
6020            } => {
6021                if tool_name != "Glob"
6022                    || is_mutating
6023                    || !matches!(phase.as_str(), "begin" | "finished")
6024                    || error.is_some()
6025                    || self.start.as_ref().map(|s| &s.0) != Some(&tool_call_id)
6026                {
6027                    return Err(plain_actor_unsupported());
6028                }
6029                Ok(true)
6030            }
6031            _ => plain_actor_event(value),
6032        }
6033    }
6034    fn finish(self, terminal: Option<&str>) -> Result<Vec<bamboo_agent_core::Message>, AgentError> {
6035        let messages = self.messages.ok_or_else(|| plain_actor_unsupported())?;
6036        if terminal != Some(messages[2].content.as_str()) || messages[2].content.trim().is_empty() {
6037            return Err(plain_actor_unsupported());
6038        }
6039        Ok(messages)
6040    }
6041}
6042
6043fn plain_actor_event(value: &serde_json::Value) -> Result<bool, AgentError> {
6044    let event: AgentEvent =
6045        serde_json::from_value(value.clone()).map_err(|_| plain_actor_unsupported())?;
6046    match event {
6047        AgentEvent::ReasoningToken { .. }
6048        | AgentEvent::ToolToken { .. }
6049        | AgentEvent::ToolStart { .. }
6050        | AgentEvent::ToolComplete { .. }
6051        | AgentEvent::ToolError { .. }
6052        | AgentEvent::ToolLifecycle { .. }
6053        | AgentEvent::ContextSummarized { .. }
6054        | AgentEvent::ContextArchived { .. }
6055        | AgentEvent::ContextCompressionStatus { .. }
6056        | AgentEvent::NeedClarification { .. }
6057        | AgentEvent::SubAgentStarted { .. }
6058        | AgentEvent::SubAgentEvent { .. }
6059        | AgentEvent::ChildApprovalRequested { .. } => Err(plain_actor_unsupported()),
6060        // Worker cache commits are not host Store commits. Publish only the
6061        // actual guarded host append after it returns a committed snapshot.
6062        AgentEvent::MessageAppended { .. }
6063        | AgentEvent::SessionHistoryCommitted { .. }
6064        | AgentEvent::VisibleMessageStart { .. }
6065        | AgentEvent::VisibleMessageDiscard { .. } => Ok(false),
6066        event if event.is_durable_change() => Ok(false),
6067        _ => Ok(true),
6068    }
6069}
6070
6071/// The `{kind,host}` placement descriptor stamped onto a child session's metadata
6072/// under `"placement"` — read back by the storage index → `SessionSummary.placement`
6073/// → the UI's machine badge. `None` for `Local` (those fall through to the DTO's
6074/// default of this backend's own host). The value is a JSON string matching
6075/// `bamboo_storage::SessionPlacement { kind, host }`.
6076fn placement_metadata(placement: &Placement, host_label: Option<&str>) -> Option<String> {
6077    // Prefer the cluster node's own label/host (its metadata) when the placement
6078    // maps to a node; else fall back to the raw endpoint host / pool name.
6079    let value = match placement {
6080        Placement::Local => return None,
6081        Placement::Remote { endpoint } => serde_json::json!({
6082            "kind": "remote",
6083            "host": host_label.map(str::to_string).unwrap_or_else(|| host_of_endpoint(endpoint)),
6084        }),
6085        Placement::Schedulable { pool } => serde_json::json!({
6086            "kind": "remote",
6087            "host": host_label.unwrap_or(pool),
6088        }),
6089    };
6090    serde_json::to_string(&value).ok()
6091}
6092
6093/// Extract the host from a `ws[s]://host:port[/path]` bus endpoint, for display.
6094fn host_of_endpoint(endpoint: &str) -> String {
6095    endpoint
6096        .trim()
6097        .trim_start_matches("wss://")
6098        .trim_start_matches("ws://")
6099        .split(['/', ':'])
6100        .next()
6101        .unwrap_or(endpoint)
6102        .to_string()
6103}
6104
6105async fn reconcile_already_admitted_claim(
6106    binding: &SessionInboxRuntimeBinding,
6107    session: &mut Session,
6108    claim: &SessionInboxClaim,
6109) -> crate::runtime::runner::Result<()> {
6110    let latest = binding
6111        .storage
6112        .load_session(&session.id)
6113        .await
6114        .map_err(|error| {
6115            AgentError::LLM(format!(
6116                "load canonical SessionInbox checkpoint for {}: {error}",
6117                session.id
6118            ))
6119        })?
6120        .ok_or_else(|| {
6121            AgentError::LLM(format!(
6122                "canonical SessionInbox target disappeared: {}",
6123                session.id
6124            ))
6125        })?;
6126    let Some(message) = latest
6127        .messages
6128        .iter()
6129        .find(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
6130        .cloned()
6131    else {
6132        return Err(AgentError::LLM(format!(
6133            "canonical admitted receipt for {} exists without transcript message {}",
6134            session.id, claim.envelope.id
6135        )));
6136    };
6137    if let Some(existing) = session
6138        .messages
6139        .iter_mut()
6140        .find(|existing| existing.id == message.id)
6141    {
6142        *existing = message;
6143    } else {
6144        session.add_message(message);
6145    }
6146    bamboo_domain::merge_session_inbox_admission(session, &latest);
6147    binding
6148        .inbox
6149        .ack(&session.id, claim)
6150        .await
6151        .map_err(|error| {
6152            AgentError::LLM(format!(
6153                "ack recovered canonical SessionInbox claim {}: {error}",
6154                claim.envelope.id
6155            ))
6156        })
6157}
6158
6159/// Checkpoint a worker-confirmed envelope into the canonical logical Session,
6160/// then create the permanent host receipt/remove its exact claim. This order is
6161/// the actor-side equivalent of the local state_bridge crash boundary.
6162async fn checkpoint_and_ack_canonical_claim(
6163    binding: &SessionInboxRuntimeBinding,
6164    session: &mut Session,
6165    claim: &SessionInboxClaim,
6166    fenced: Option<(&bamboo_storage::SessionStoreV2, &ActorActivationFence)>,
6167) -> crate::runtime::runner::Result<()> {
6168    if claim.envelope.target_session_id != session.id {
6169        return Err(AgentError::LLM(format!(
6170            "canonical SessionInbox claim target {} does not match active logical session {}",
6171            claim.envelope.target_session_id, session.id
6172        )));
6173    }
6174    if binding
6175        .inbox
6176        .was_admitted(&session.id, &claim.envelope.id)
6177        .await
6178        .map_err(|error| {
6179            AgentError::LLM(format!(
6180                "inspect canonical admitted receipt {}: {error}",
6181                claim.envelope.id
6182            ))
6183        })?
6184    {
6185        return reconcile_already_admitted_claim(binding, session, claim).await;
6186    }
6187
6188    if let Some((store, fence)) = fenced {
6189        let committed = store
6190            .confirm_actor_claim_context(
6191                concrete_file_inbox(binding)?,
6192                bamboo_storage::ActorClaimContextSeed {
6193                    fence: fence.clone(),
6194                    expected_created_at: session.created_at,
6195                    expected_messages: session.messages.clone(),
6196                    expected_provider_transcript: session.provider_transcript.clone(),
6197                    expected_admission: session.session_inbox_admission().cloned(),
6198                    claims: vec![claim.clone()],
6199                },
6200            )
6201            .await;
6202        let committed = match committed {
6203            Ok(committed) => committed,
6204            Err(error) => {
6205                // An error after atomic replacement may still have advanced
6206                // Main. Keep the claim unacked and use that canonical context
6207                // for the SDK's terminal status save.
6208                adopt_latest_actor_context(store, session).await;
6209                return Err(AgentError::LLM(format!(
6210                    "confirm fenced Actor claim context unconfirmed: {error}"
6211                )));
6212            }
6213        };
6214        bamboo_domain::merge_session_inbox_admission(session, &committed);
6215        session.messages = committed.messages;
6216        session.provider_transcript = committed.provider_transcript;
6217        session.updated_at = committed.updated_at;
6218        if !session
6219            .messages
6220            .iter()
6221            .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
6222            || !session
6223                .session_inbox_admission()
6224                .is_some_and(|cursor| cursor.contains(&claim.envelope.id))
6225        {
6226            return Err(AgentError::LLM(
6227                "fenced Actor claim confirmation lost durable proof".into(),
6228            ));
6229        }
6230        return binding
6231            .inbox
6232            .ack(&session.id, claim)
6233            .await
6234            .map_err(|error| {
6235                AgentError::LLM(format!(
6236                    "ack fenced Actor claim {} after confirmation: {error}",
6237                    claim.envelope.id
6238                ))
6239            });
6240    }
6241
6242    let transcript_has_id = session
6243        .messages
6244        .iter()
6245        .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope));
6246    if session
6247        .messages
6248        .iter()
6249        .any(|message| message.id == claim.envelope.id.as_str())
6250        && !transcript_has_id
6251    {
6252        return Err(AgentError::LLM(format!(
6253            "canonical SessionInbox id {} collides with a non-matching transcript message",
6254            claim.envelope.id
6255        )));
6256    }
6257    let cursor_has_id = session
6258        .session_inbox_admission()
6259        .is_some_and(|state| state.contains(&claim.envelope.id));
6260    if cursor_has_id && !transcript_has_id {
6261        return Err(AgentError::LLM(format!(
6262            "canonical SessionInbox cursor exists without transcript message {}",
6263            claim.envelope.id
6264        )));
6265    }
6266    let before = session.clone();
6267    if !transcript_has_id {
6268        let message = claim.envelope.to_provider_message().map_err(|error| {
6269            AgentError::LLM(format!(
6270                "translate canonical SessionInbox envelope {}: {error}",
6271                claim.envelope.id
6272            ))
6273        })?;
6274        session.add_message(message);
6275    }
6276    session
6277        .session_inbox_admission_mut()
6278        .record(claim.envelope.id.clone(), claim.generation);
6279    session.updated_at = chrono::Utc::now();
6280
6281    if let Err(error) = binding
6282        .persistence
6283        .checkpoint_runtime_session(session)
6284        .await
6285    {
6286        *session = before;
6287        return Err(AgentError::LLM(format!(
6288            "checkpoint canonical SessionInbox claim {}: {error}",
6289            claim.envelope.id
6290        )));
6291    }
6292    if !session
6293        .messages
6294        .iter()
6295        .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
6296    {
6297        *session = before;
6298        return Err(AgentError::LLM(format!(
6299            "canonical SessionInbox checkpoint lost typed transcript proof for {}",
6300            claim.envelope.id
6301        )));
6302    }
6303    binding
6304        .inbox
6305        .ack(&session.id, claim)
6306        .await
6307        .map_err(|error| {
6308            AgentError::LLM(format!(
6309                "ack canonical SessionInbox claim {} after checkpoint: {error}",
6310                claim.envelope.id
6311            ))
6312        })
6313}
6314
6315fn concrete_file_inbox(
6316    binding: &SessionInboxRuntimeBinding,
6317) -> Result<bamboo_storage::FileSessionInbox, AgentError> {
6318    binding
6319        .inbox
6320        .as_any()
6321        .and_then(|inbox| inbox.downcast_ref::<bamboo_storage::FileSessionInbox>())
6322        .cloned()
6323        .ok_or_else(|| AgentError::LLM("remote Actor requires canonical FileSessionInbox".into()))
6324}
6325
6326async fn adopt_latest_actor_context(store: &bamboo_storage::SessionStoreV2, session: &mut Session) {
6327    if let Ok(Some(latest)) = store.load_session(&session.id).await {
6328        if latest.created_at == session.created_at {
6329            bamboo_domain::merge_session_inbox_admission(session, &latest);
6330            session.messages = latest.messages;
6331            session.provider_transcript = latest.provider_transcript;
6332            session.updated_at = latest.updated_at;
6333        }
6334    }
6335}
6336
6337/// Durably seed claimed typed messages into the canonical host transcript
6338/// before dispatching them to any actor worker, while deliberately leaving the
6339/// admission cursor and `cur/` claims untouched.
6340///
6341/// This is the cross-placement lost-confirmation invariant: if worker A admits
6342/// and reasons over the batch but its confirmations are lost, a retry on worker
6343/// B receives a host snapshot already containing each stable typed message
6344/// exactly once. Worker B's local safe boundary then records/acks the same ids
6345/// without appending duplicates. Only an exact worker confirmation advances the
6346/// host cursor; only a durable cursor checkpoint precedes host ack.
6347async fn checkpoint_claim_context_before_dispatch(
6348    binding: &SessionInboxRuntimeBinding,
6349    session: &mut Session,
6350    claims: &[SessionInboxClaim],
6351    fenced: Option<(&bamboo_storage::SessionStoreV2, &ActorActivationFence)>,
6352) -> crate::runtime::runner::Result<()> {
6353    if claims.is_empty() {
6354        return Ok(());
6355    }
6356    let before = session.clone();
6357    let mut additions = Vec::new();
6358    for claim in claims {
6359        if claim.envelope.target_session_id != session.id {
6360            return Err(AgentError::LLM(format!(
6361                "canonical SessionInbox claim target {} does not match actor session {}",
6362                claim.envelope.target_session_id, session.id
6363            )));
6364        }
6365        let matching = session
6366            .messages
6367            .iter()
6368            .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope));
6369        if session
6370            .messages
6371            .iter()
6372            .any(|message| message.id == claim.envelope.id.as_str())
6373            && !matching
6374        {
6375            return Err(AgentError::LLM(format!(
6376                "canonical SessionInbox id {} collides before actor dispatch",
6377                claim.envelope.id
6378            )));
6379        }
6380        if session
6381            .session_inbox_admission()
6382            .is_some_and(|state| state.contains(&claim.envelope.id))
6383            && !matching
6384        {
6385            return Err(AgentError::LLM(format!(
6386                "canonical SessionInbox cursor exists without transcript proof for {}",
6387                claim.envelope.id
6388            )));
6389        }
6390        if !matching {
6391            let message = claim.envelope.to_provider_message().map_err(|error| {
6392                AgentError::LLM(format!(
6393                    "translate canonical SessionInbox envelope {} before actor dispatch: {error}",
6394                    claim.envelope.id
6395                ))
6396            })?;
6397            additions.push(message);
6398        }
6399    }
6400    let fenced_seed = fenced.is_some();
6401    if let Some((store, fence)) = fenced {
6402        let committed = store
6403            .seed_actor_claim_context(
6404                concrete_file_inbox(binding)?,
6405                bamboo_storage::ActorClaimContextSeed {
6406                    fence: fence.clone(),
6407                    expected_created_at: session.created_at,
6408                    expected_messages: session.messages.clone(),
6409                    expected_provider_transcript: session.provider_transcript.clone(),
6410                    expected_admission: session.session_inbox_admission().cloned(),
6411                    claims: claims.to_vec(),
6412                },
6413            )
6414            .await;
6415        let committed = match committed {
6416            Ok(committed) => committed,
6417            Err(error) => {
6418                // Publication may have occurred before an unconfirmed
6419                // readback. Do not dispatch or ACK; adopt only the current
6420                // canonical context so SDK can still save an error status.
6421                adopt_latest_actor_context(store, session).await;
6422                return Err(AgentError::LLM(format!(
6423                    "seed fenced Actor claim context unconfirmed: {error}"
6424                )));
6425            }
6426        };
6427        bamboo_domain::merge_session_inbox_admission(session, &committed);
6428        session.messages = committed.messages;
6429        session.provider_transcript = committed.provider_transcript;
6430        session.updated_at = committed.updated_at;
6431    } else {
6432        for message in additions {
6433            session.add_message(message);
6434        }
6435        session.updated_at = chrono::Utc::now();
6436        if let Err(error) = binding
6437            .persistence
6438            .checkpoint_runtime_session(session)
6439            .await
6440        {
6441            *session = before;
6442            return Err(AgentError::LLM(format!(
6443                "checkpoint canonical SessionInbox actor context: {error}"
6444            )));
6445        }
6446    }
6447    for claim in claims {
6448        if !session
6449            .messages
6450            .iter()
6451            .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
6452        {
6453            if !fenced_seed {
6454                *session = before;
6455            }
6456            return Err(AgentError::LLM(format!(
6457                "actor context checkpoint lost typed transcript proof for {}",
6458                claim.envelope.id
6459            )));
6460        }
6461    }
6462    Ok(())
6463}
6464
6465async fn claim_canonical_deliveries(
6466    binding: &SessionInboxRuntimeBinding,
6467    session: &mut Session,
6468    activation_run_id: &str,
6469    limit: usize,
6470    fenced: Option<(&bamboo_storage::SessionStoreV2, &ActorActivationFence)>,
6471) -> crate::runtime::runner::Result<Vec<(SessionInboxClaim, SessionMessageDelivery)>> {
6472    let claims = binding
6473        .inbox
6474        .claim_for_turn(&session.id, limit, Some(activation_run_id))
6475        .await
6476        .map_err(|error| {
6477            AgentError::LLM(format!(
6478                "claim canonical SessionInbox for active actor {}: {error}",
6479                session.id
6480            ))
6481        })?;
6482    if claims.is_empty() {
6483        return Ok(Vec::new());
6484    }
6485    let mut unconfirmed = Vec::with_capacity(claims.len());
6486    for claim in claims {
6487        if binding
6488            .inbox
6489            .was_admitted(&session.id, &claim.envelope.id)
6490            .await
6491            .map_err(|error| {
6492                AgentError::LLM(format!(
6493                    "inspect canonical SessionInbox claim {}: {error}",
6494                    claim.envelope.id
6495                ))
6496            })?
6497        {
6498            reconcile_already_admitted_claim(binding, session, &claim).await?;
6499            continue;
6500        }
6501        unconfirmed.push(claim);
6502    }
6503    checkpoint_claim_context_before_dispatch(binding, session, &unconfirmed, fenced).await?;
6504
6505    let mut deliveries = Vec::with_capacity(unconfirmed.len());
6506    for claim in unconfirmed {
6507        // Cursor+transcript without the permanent tombstone is the recoverable
6508        // crash window after an exact worker confirmation was checkpointed but
6509        // before host ack removed `cur/`. Finish that ack without exposing the
6510        // message to another provider run.
6511        if session
6512            .session_inbox_admission()
6513            .is_some_and(|state| state.contains(&claim.envelope.id))
6514        {
6515            binding
6516                .inbox
6517                .ack(&session.id, &claim)
6518                .await
6519                .map_err(|error| {
6520                    AgentError::LLM(format!(
6521                        "finish confirmed canonical SessionInbox ack {}: {error}",
6522                        claim.envelope.id
6523                    ))
6524                })?;
6525            continue;
6526        }
6527        let delivery = SessionMessageDelivery {
6528            target_session_id: session.id.clone(),
6529            envelope: claim.envelope.clone(),
6530            canonical_claim_generation: claim.generation,
6531            activation_run_id: activation_run_id.to_string(),
6532            activation_policy: claim.activation_policy,
6533        };
6534        deliveries.push((claim, delivery));
6535    }
6536    Ok(deliveries)
6537}
6538
6539async fn forward_next_canonical_claim(
6540    client: &mut dyn bamboo_subagent::ChildLink,
6541    binding: &SessionInboxRuntimeBinding,
6542    session: &mut Session,
6543    activation_run_id: &str,
6544    inflight: &mut VecDeque<SessionInboxClaim>,
6545    fenced: Option<(&bamboo_storage::SessionStoreV2, &ActorActivationFence)>,
6546) -> crate::runtime::runner::Result<()> {
6547    if !inflight.is_empty() {
6548        return Ok(());
6549    }
6550    let Some((claim, delivery)) =
6551        claim_canonical_deliveries(binding, session, activation_run_id, 1, fenced)
6552            .await?
6553            .pop()
6554    else {
6555        return Ok(());
6556    };
6557    client
6558        .send(ParentFrame::SessionMessage { delivery })
6559        .await
6560        .map_err(|error| {
6561            AgentError::LLM(format!(
6562                "forward canonical SessionInbox claim {} to active actor: {error}",
6563                claim.envelope.id
6564            ))
6565        })?;
6566    inflight.push_back(claim);
6567    Ok(())
6568}
6569
6570/// Borrowed and per-run-owned inputs for one actor frame pump.
6571struct ActorDriveContext<'a> {
6572    client: &'a mut dyn bamboo_subagent::ChildLink,
6573    parent_session_id: &'a str,
6574    child_session_id: &'a str,
6575    child_attempt: u32,
6576    approval_registry: Option<&'a super::approval_registry::SharedApprovalRegistry>,
6577    approval_decider: Option<&'a Arc<dyn ChildApprovalDecider>>,
6578    approval_reviewer: Option<&'a Arc<dyn ChildApprovalReviewer>>,
6579    escalation_bridge: Option<bamboo_subagent::executor::HostBridge>,
6580    event_tx: &'a mpsc::Sender<AgentEvent>,
6581    cancel_token: &'a CancellationToken,
6582    live_rx: &'a mut mpsc::UnboundedReceiver<ParentFrame>,
6583    delivery_rx: &'a mut mpsc::UnboundedReceiver<u64>,
6584    logical_session: &'a mut Session,
6585    expected_permission_posture: Option<ExpectedPermissionPosture>,
6586    expected_creation: Option<&'a bamboo_subagent::proto::ChildCreationIdentity>,
6587    session_inbox_runtime: Option<&'a SessionInboxRuntimeBinding>,
6588    actor_directory_store: Option<&'a bamboo_storage::SessionStoreV2>,
6589    canonical_subagent_tool: Option<Arc<dyn bamboo_agent_core::tools::Tool>>,
6590    ticket_service: Option<Arc<bamboo_tickets::TicketService>>,
6591    activation_run_id: Option<&'a str>,
6592    execution_epoch: u64,
6593    expected_source_actor_id: &'a str,
6594    initial_inflight_claims: VecDeque<SessionInboxClaim>,
6595    plain_actor: bool,
6596    remote_environment_lease: bool,
6597    readonly_output: Option<&'a mut Vec<bamboo_agent_core::Message>>,
6598    local_history_tools: Option<&'a [String]>,
6599    local_history_read_only: bool,
6600    strict_history_output: Option<&'a mut Option<StrictTerminalHistory>>,
6601    plain_input: Option<&'a PlainActorActivation>,
6602    canonical_activation: Option<&'a ActorActivationFence>,
6603    canonical_placement_ref: Option<&'a bamboo_domain::ActorPlacementRef>,
6604    actor_event_observer: Option<&'a dyn ActorEventObserver>,
6605    plain_run: Option<(&'a RunSpec, &'a AtomicU64)>,
6606    first_frame_timeout: Option<Duration>,
6607}
6608
6609#[derive(Debug, Clone, PartialEq, Eq)]
6610struct ExpectedPermissionPosture {
6611    policy_revision: u64,
6612    resolution: bamboo_domain::PermissionModeResolution,
6613    expected_audit_revision: Option<u64>,
6614    executor_mapping: String,
6615}
6616
6617#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6618enum PermissionPostureHandshake {
6619    /// Legacy/custom actors were dispatched without a typed posture contract.
6620    NotRequired,
6621    /// The host dispatched an exact posture and no matching, durably recorded
6622    /// worker activation has arrived yet.
6623    Awaiting,
6624    /// One worker posture matched and its host-owned audit write succeeded.
6625    Confirmed,
6626}
6627
6628impl PermissionPostureHandshake {
6629    fn new(expected: Option<&ExpectedPermissionPosture>) -> Self {
6630        if expected.is_some() {
6631            Self::Awaiting
6632        } else {
6633            Self::NotRequired
6634        }
6635    }
6636
6637    fn is_awaiting(self) -> bool {
6638        self == Self::Awaiting
6639    }
6640
6641    fn posture_was_confirmed(self) -> bool {
6642        self == Self::Confirmed
6643    }
6644}
6645
6646fn permission_posture_seed_from_event(
6647    session: &Session,
6648    event: &AgentEvent,
6649) -> Result<Option<bamboo_domain::PermissionAuditSeed>, String> {
6650    let AgentEvent::PermissionPostureActivated {
6651        session_id,
6652        policy_revision,
6653        requested_mode,
6654        effective_mode,
6655        executor_mapping,
6656    } = event
6657    else {
6658        return Ok(None);
6659    };
6660    if session_id != &session.id {
6661        return Err("permission posture event targets a different logical session".to_string());
6662    }
6663    let requested = bamboo_domain::SessionPermissionMode::from_audit_str(requested_mode)
6664        .ok_or_else(|| "permission posture event has an invalid requested mode".to_string())?;
6665    let effective = bamboo_domain::PermissionMode::from_audit_str(effective_mode)
6666        .ok_or_else(|| "permission posture event has an invalid effective mode".to_string())?;
6667    let resolution = bamboo_domain::PermissionModeResolution {
6668        requested,
6669        effective,
6670    };
6671    if !resolution.is_consistent() {
6672        return Err("permission posture event has an inconsistent mode pair".to_string());
6673    }
6674    let current_requested = session
6675        .agent_runtime_state
6676        .as_ref()
6677        .map(|state| state.effective_permission_mode())
6678        .unwrap_or_default();
6679    if current_requested != requested {
6680        return Err("permission posture event is stale for the host typed mode".to_string());
6681    }
6682    let mapping_chars = executor_mapping.chars().count();
6683    if mapping_chars == 0 || mapping_chars > bamboo_domain::MAX_PERMISSION_EXECUTOR_MAPPING_CHARS {
6684        return Err("permission posture event has an invalid executor mapping".to_string());
6685    }
6686    Ok(Some(bamboo_domain::PermissionAuditSeed::new(
6687        *policy_revision,
6688        resolution,
6689        executor_mapping,
6690    )))
6691}
6692
6693fn actor_event_logical_identity(
6694    session: &Session,
6695    parent_session_id: &str,
6696    creation: Option<&bamboo_subagent::proto::ChildCreationIdentity>,
6697) -> LogicalSessionIdentity {
6698    LogicalSessionIdentity {
6699        creation: creation.cloned(),
6700        session_id: session.id.clone(),
6701        parent_session_id: session
6702            .parent_session_id
6703            .clone()
6704            .or_else(|| Some(parent_session_id.to_string())),
6705        root_session_id: if session.root_session_id.trim().is_empty() {
6706            parent_session_id.to_string()
6707        } else {
6708            session.root_session_id.clone()
6709        },
6710    }
6711}
6712
6713fn validate_strict_history_terminal(
6714    watermark: Option<&bamboo_subagent::ActorEventWatermark>,
6715    session: &Session,
6716    parent_session_id: &str,
6717    activation_run_id: Option<&str>,
6718    execution_epoch: u64,
6719    creation: Option<&bamboo_subagent::proto::ChildCreationIdentity>,
6720    consumed_seq: u64,
6721) -> Result<(), AgentError> {
6722    let identity = actor_event_logical_identity(session, parent_session_id, creation);
6723    if watermark
6724        .zip(activation_run_id)
6725        .is_some_and(|(watermark, run_id)| {
6726            watermark.matches_consumed_run(&identity, run_id, execution_epoch, consumed_seq)
6727        })
6728    {
6729        Ok(())
6730    } else {
6731        Err(AgentError::LLM("actor_history_tail_incomplete".into()))
6732    }
6733}
6734
6735fn validate_actor_event_batch(
6736    batch: &ActorEventBatch,
6737    logical_session: &Session,
6738    parent_session_id: &str,
6739    activation_run_id: Option<&str>,
6740    execution_epoch: u64,
6741    expected_source_actor_id: &str,
6742    expected_creation: Option<&bamboo_subagent::proto::ChildCreationIdentity>,
6743) -> Result<(), AgentError> {
6744    batch
6745        .validate()
6746        .map_err(|error| AgentError::LLM(format!("actor emitted invalid event batch: {error}")))?;
6747    let identity = batch.logical_session.as_ref().ok_or_else(|| {
6748        AgentError::LLM("actor event batch omitted its logical session identity".to_string())
6749    })?;
6750    // Fence against the exact canonical identity used to build RunSpec. Some
6751    // migrated/test sessions have not yet materialized parent ancestry on the
6752    // in-memory Session, so comparing the batch directly with those raw fields
6753    // can reject the identity the host itself just dispatched.
6754    let expected_identity =
6755        actor_event_logical_identity(logical_session, parent_session_id, expected_creation);
6756    if identity != &expected_identity {
6757        return Err(AgentError::LLM(
6758            "actor event batch targets a different logical session".to_string(),
6759        ));
6760    }
6761    if batch.activation_id.as_deref() != activation_run_id {
6762        return Err(AgentError::LLM(
6763            "actor event batch belongs to a stale activation".to_string(),
6764        ));
6765    }
6766    if batch.execution_epoch != execution_epoch {
6767        return Err(AgentError::LLM(
6768            "actor event batch belongs to a stale execution epoch".to_string(),
6769        ));
6770    }
6771    if batch
6772        .source_actor_id
6773        .as_deref()
6774        .is_some_and(|source| source != expected_source_actor_id)
6775    {
6776        return Err(AgentError::LLM(
6777            "actor event batch declares a different physical actor".to_string(),
6778        ));
6779    }
6780    Ok(())
6781}
6782
6783/// The plain Actor path already owns a durable Directory activation. Check that
6784/// exact owner before admitting either batched or rolling-upgrade live frames;
6785/// worker-provided batch identity alone is never an activation authority.
6786async fn validate_directory_actor_event(
6787    store: &bamboo_storage::SessionStoreV2,
6788    fence: &ActorActivationFence,
6789    session: &Session,
6790    run_id: Option<&str>,
6791) -> Result<bamboo_domain::ActorDirectoryEntry, AgentError> {
6792    store
6793        .validate_fence(fence, chrono::Utc::now())
6794        .await
6795        .map_err(|_| AgentError::LLM("actor event owner is stale".into()))?;
6796    let entry = store
6797        .inspect_actor(&fence.actor_id)
6798        .await
6799        .map_err(|_| AgentError::LLM("actor event authority is unavailable".into()))?;
6800    if !entry.actor.matches_session(session)
6801        || entry.actor.project_id != project_id_for_actor_run(session)?.map(|id| id.to_string())
6802        || !entry.activation.as_ref().is_some_and(|current| {
6803            current.matches_fence(fence)
6804                && current.status == bamboo_domain::ActorActivationStatus::Running
6805        })
6806        || run_id.is_some_and(|id| id != fence.run_id)
6807    {
6808        return Err(AgentError::LLM("actor event authority changed".into()));
6809    }
6810    Ok(entry)
6811}
6812
6813/// The Worker supplies only a cursor. All identity and scope inputs come from
6814/// this Host's active drive and its canonical durable Session store.
6815async fn inspect_active_owned_tree(
6816    store: &bamboo_storage::SessionStoreV2,
6817    binding: &SessionInboxRuntimeBinding,
6818    run_id: &str,
6819    child_id: &str,
6820    parent_id: &str,
6821    logical_session: &Session,
6822    creation: &bamboo_subagent::proto::ChildCreationIdentity,
6823    plain_input: Option<&PlainActorActivation>,
6824    cursor: Option<&str>,
6825) -> Option<serde_json::Value> {
6826    use bamboo_agent_core::storage::Storage;
6827    let current = match store.load_session(child_id).await {
6828        Ok(Some(current)) => current,
6829        _ => {
6830            tracing::warn!("owned tree denied: canonical caller unavailable");
6831            return None;
6832        }
6833    };
6834    let current_project = match project_id_for_actor_run(&current) {
6835        Ok(project) => project,
6836        Err(_) => {
6837            tracing::warn!("owned tree denied: invalid canonical Project");
6838            return None;
6839        }
6840    };
6841    let logical_project = match project_id_for_actor_run(logical_session) {
6842        Ok(project) => project,
6843        Err(_) => {
6844            tracing::warn!("owned tree denied: invalid logical Project");
6845            return None;
6846        }
6847    };
6848    if current.id != child_id
6849        || current.kind != bamboo_domain::SessionKind::Child
6850        || current.parent_session_id.as_deref() != Some(parent_id)
6851        || current.parent_session_id != logical_session.parent_session_id
6852        || current.root_session_id != logical_session.root_session_id
6853        || current.created_at != creation.created_at
6854        || current.created_at != logical_session.created_at
6855        || current.spawn_depth != creation.spawn_depth
6856        || current.spawn_depth != logical_session.spawn_depth
6857        || current_project != logical_project
6858    {
6859        tracing::warn!("owned tree denied: caller lifetime or Project changed");
6860        return None;
6861    }
6862    if let Some(activation) = plain_input {
6863        validate_directory_actor_event(
6864            activation.store.as_ref(),
6865            &activation.fence,
6866            &current,
6867            Some(run_id),
6868        )
6869        .await
6870        .ok()?;
6871    } else if !binding.router.owns_run(child_id, run_id).await {
6872        tracing::warn!("owned tree denied: activation owner changed");
6873        return None;
6874    }
6875    let page = match crate::session_app::child_session::owned_tree::inspect_owned_tree(
6876        store, child_id, cursor,
6877    )
6878    .await
6879    {
6880        Ok(page) => page,
6881        Err(error) => {
6882            tracing::warn!(?error, "owned tree denied: canonical tree read failed");
6883            return None;
6884        }
6885    };
6886    let again = match store.load_session(child_id).await {
6887        Ok(Some(again)) => again,
6888        _ => {
6889            tracing::warn!("owned tree denied: canonical caller disappeared");
6890            return None;
6891        }
6892    };
6893    if again.created_at != current.created_at
6894        || again.parent_session_id != current.parent_session_id
6895        || again.root_session_id != current.root_session_id
6896        || again.spawn_depth != current.spawn_depth
6897        || project_id_for_actor_run(&again).ok()? != current_project
6898    {
6899        tracing::warn!("owned tree denied: caller changed during inspection");
6900        return None;
6901    }
6902    if let Some(activation) = plain_input {
6903        validate_directory_actor_event(
6904            activation.store.as_ref(),
6905            &activation.fence,
6906            &again,
6907            Some(run_id),
6908        )
6909        .await
6910        .ok()?;
6911    } else if !binding.router.owns_run(child_id, run_id).await {
6912        tracing::warn!("owned tree denied: activation owner changed after inspection");
6913        return None;
6914    }
6915    serde_json::to_vec(&page)
6916        .ok()
6917        .filter(|bytes| bytes.len() <= 8192)?;
6918    Some(page)
6919}
6920
6921/// Resolve the caller of a mutating nested SubAgent RPC entirely from the
6922/// Host's active drive and canonical store. The frame never supplies identity,
6923/// Project, ancestry, or a run fence.
6924async fn load_active_subagent_caller(
6925    store: &bamboo_storage::SessionStoreV2,
6926    binding: &SessionInboxRuntimeBinding,
6927    run_id: &str,
6928    child_id: &str,
6929    parent_id: &str,
6930    logical_session: &Session,
6931    creation: &bamboo_subagent::proto::ChildCreationIdentity,
6932    plain_input: Option<&PlainActorActivation>,
6933) -> Option<Session> {
6934    use bamboo_agent_core::storage::Storage;
6935    let current = store.load_session(child_id).await.ok().flatten()?;
6936    let current_project = project_id_for_actor_run(&current).ok()?;
6937    let logical_project = project_id_for_actor_run(logical_session).ok()?;
6938    if current.id != child_id
6939        || current.kind != bamboo_domain::SessionKind::Child
6940        || current.parent_session_id.as_deref() != Some(parent_id)
6941        || current.parent_session_id != logical_session.parent_session_id
6942        || current.root_session_id != logical_session.root_session_id
6943        || current.created_at != creation.created_at
6944        || current.created_at != logical_session.created_at
6945        || current.spawn_depth != creation.spawn_depth
6946        || current.spawn_depth != logical_session.spawn_depth
6947        || current_project != logical_project
6948        || bamboo_domain::ActorSession::from_session(&current).is_err()
6949    {
6950        return None;
6951    }
6952    if let Some(activation) = plain_input {
6953        validate_directory_actor_event(
6954            activation.store.as_ref(),
6955            &activation.fence,
6956            &current,
6957            Some(run_id),
6958        )
6959        .await
6960        .ok()?;
6961    } else if !binding.router.owns_run(child_id, run_id).await {
6962        return None;
6963    }
6964    Some(current)
6965}
6966
6967async fn invoke_active_subagent(
6968    tool: &dyn bamboo_agent_core::tools::Tool,
6969    caller: &Session,
6970    args: serde_json::Value,
6971    tool_call_id: &str,
6972) -> serde_json::Value {
6973    use bamboo_agent_core::tools::{ToolCtx, ToolOutcome};
6974    let mut ctx = ToolCtx::none(tool_call_id.to_owned());
6975    ctx.session_id = Some(Arc::from(caller.id.as_str()));
6976    match tool.invoke(args, ctx).await {
6977        Ok(ToolOutcome::Completed(result))
6978            if result.images.is_empty()
6979                && serde_json::to_vec(&result).is_ok_and(|bytes| bytes.len() <= 16 * 1024) =>
6980        {
6981            serde_json::json!({"result":result})
6982        }
6983        _ => serde_json::json!({
6984            "error":"SubAgent operation failed or its result exceeded the limit; inspect logical child state before retrying"
6985        }),
6986    }
6987}
6988
6989/// Commit one already-persisted Worker clarification under the Host's current
6990/// Child birth and Run fence. Only the Host supplies caller/parent identity.
6991#[allow(clippy::too_many_arguments)]
6992async fn checkpoint_active_parent_question(
6993    store: &bamboo_storage::SessionStoreV2,
6994    binding: &SessionInboxRuntimeBinding,
6995    run_id: &str,
6996    child_id: &str,
6997    parent_id: &str,
6998    logical_session: &mut Session,
6999    creation: &bamboo_subagent::proto::ChildCreationIdentity,
7000    plain_input: Option<&PlainActorActivation>,
7001    observation: ParentQuestionCheckpointV1,
7002) -> Option<String> {
7003    use bamboo_agent_core::storage::Storage;
7004    if plain_input.is_some() || observation.validate_shape().is_err() {
7005        return None;
7006    }
7007    // SessionRepository's final save and the direct-parent answer resolver
7008    // use this same Host-owned lock. Hold it across the V2 read/CAS/save so
7009    // neither can read an older Child and overwrite the checkpoint afterward.
7010    let _session_guard = binding
7011        .parent_question_lock
7012        .as_ref()?
7013        .acquire_lock(child_id)
7014        .await;
7015    let caller = load_active_subagent_caller(
7016        store,
7017        binding,
7018        run_id,
7019        child_id,
7020        parent_id,
7021        logical_session,
7022        creation,
7023        plain_input,
7024    )
7025    .await?;
7026    let parent = store.load_session(parent_id).await.ok().flatten()?;
7027    if parent.created_at > caller.created_at || !binding.router.owns_run(child_id, run_id).await {
7028        return None;
7029    }
7030    let (saved, question) = store
7031        .checkpoint_parent_question(&caller, &parent, &observation)
7032        .await
7033        .ok()
7034        .flatten()?;
7035    // The current Run's local snapshot must carry the committed canonical
7036    // prefix into finalization; sdk/spawn rechecks disk before its last save.
7037    logical_session.messages = saved.messages;
7038    logical_session.pending_question = saved.pending_question;
7039    if let Some(reason) = saved.metadata.get("runtime.suspend_reason") {
7040        logical_session
7041            .metadata
7042            .insert("runtime.suspend_reason".into(), reason.clone());
7043    }
7044    if let Some(request) = saved
7045        .metadata
7046        .get(bamboo_domain::PARENT_QUESTION_REQUEST_KEY)
7047    {
7048        logical_session.metadata.insert(
7049            bamboo_domain::PARENT_QUESTION_REQUEST_KEY.into(),
7050            request.clone(),
7051        );
7052    }
7053    logical_session.metadata.insert(
7054        "runtime.actor_parent_question_handoff".into(),
7055        "true".into(),
7056    );
7057    Some(question.id.as_str().to_owned())
7058}
7059
7060/// An actor Worker cannot see the Host's canonical descendants. At the end of
7061/// an otherwise completed Child turn, apply the same orphan-child safety net
7062/// used by the in-process root loop before publishing this Child as terminal.
7063/// The existing Host wait, including an explicit `any` policy, always wins.
7064async fn ensure_active_nested_wait(
7065    tool: &dyn bamboo_agent_core::tools::Tool,
7066    store: &bamboo_storage::SessionStoreV2,
7067    binding: &SessionInboxRuntimeBinding,
7068    run_id: &str,
7069    child_id: &str,
7070    parent_id: &str,
7071    logical_session: &Session,
7072    creation: &bamboo_subagent::proto::ChildCreationIdentity,
7073    plain_input: Option<&PlainActorActivation>,
7074) -> Result<bool, AgentError> {
7075    use bamboo_agent_core::storage::Storage;
7076    use bamboo_agent_core::tools::{ToolCtx, ToolOutcome};
7077
7078    let caller = load_active_subagent_caller(
7079        store,
7080        binding,
7081        run_id,
7082        child_id,
7083        parent_id,
7084        logical_session,
7085        creation,
7086        plain_input,
7087    )
7088    .await
7089    .ok_or_else(|| AgentError::LLM("nested wait caller is no longer active".into()))?;
7090    if caller
7091        .agent_runtime_state
7092        .as_ref()
7093        .and_then(|state| state.waiting_for_children.as_ref())
7094        .is_some()
7095    {
7096        return Ok(true);
7097    }
7098
7099    let active = store
7100        .list_child_run_statuses(child_id)
7101        .await
7102        .map_err(|error| AgentError::LLM(format!("nested child status scan failed: {error}")))?
7103        .into_iter()
7104        .filter(|(_, status)| {
7105            !status.as_deref().is_some_and(|status| {
7106                matches!(
7107                    status,
7108                    "completed" | "error" | "timeout" | "cancelled" | "skipped"
7109                )
7110            })
7111        })
7112        .map(|(id, _)| id)
7113        .collect::<Vec<_>>();
7114    if active.is_empty() {
7115        return Ok(false);
7116    }
7117
7118    let mut ctx = ToolCtx::none(format!("host-nested-orphan-wait:{run_id}"));
7119    ctx.session_id = Some(Arc::from(caller.id.as_str()));
7120    let outcome = tokio::time::timeout(
7121        Duration::from_secs(30),
7122        tool.invoke(
7123            serde_json::json!({"action":"wait", "child_session_ids":active}),
7124            ctx,
7125        ),
7126    )
7127    .await
7128    .map_err(|_| AgentError::LLM("nested wait registration timed out".into()))?
7129    .map_err(|error| AgentError::LLM(format!("nested wait registration failed: {error}")))?;
7130    let wait_registered = match outcome {
7131        ToolOutcome::Completed(result) if result.success => {
7132            let payload: serde_json::Value = serde_json::from_str(&result.result)
7133                .map_err(|_| AgentError::LLM("nested wait result is invalid".into()))?;
7134            payload["status"] == "waiting"
7135        }
7136        _ => {
7137            return Err(AgentError::LLM(
7138                "nested wait registration was not completed".into(),
7139            ))
7140        }
7141    };
7142
7143    // A grandchild may finish while the wait is being registered and clear it.
7144    // A clear wait is safe only when no canonical descendants remain active.
7145    let latest = load_active_subagent_caller(
7146        store,
7147        binding,
7148        run_id,
7149        child_id,
7150        parent_id,
7151        logical_session,
7152        creation,
7153        plain_input,
7154    )
7155    .await
7156    .ok_or_else(|| AgentError::LLM("nested wait caller changed during registration".into()))?;
7157    if latest
7158        .agent_runtime_state
7159        .as_ref()
7160        .and_then(|state| state.waiting_for_children.as_ref())
7161        .is_some()
7162    {
7163        return Ok(true);
7164    }
7165    let still_active = store
7166        .list_child_run_statuses(child_id)
7167        .await
7168        .map_err(|error| {
7169            AgentError::LLM(format!("nested child wait verification failed: {error}"))
7170        })?
7171        .into_iter()
7172        .any(|(_, status)| {
7173            !status.as_deref().is_some_and(|status| {
7174                matches!(
7175                    status,
7176                    "completed" | "error" | "timeout" | "cancelled" | "skipped"
7177                )
7178            })
7179        });
7180    if still_active {
7181        return Err(AgentError::LLM(
7182            "nested child remains active without a durable parent wait".into(),
7183        ));
7184    }
7185    // Keep the current Run's suspend intent even if the last grandchild
7186    // finished and cleared the Host wait before SDK finalization. Its outcome
7187    // has already admitted a successor; publishing this Run as completed
7188    // would release the grandparent on the Child's interim response.
7189    Ok(wait_registered)
7190}
7191
7192fn actor_event_route_error(error: ActorEventRouteError) -> AgentError {
7193    let message = match error {
7194        ActorEventRouteError::SequenceGap { .. } => {
7195            "actor event sequence gap; authoritative session snapshot is required"
7196        }
7197        ActorEventRouteError::LifecycleMissing => {
7198            "actor emitted live content before its activation lifecycle event"
7199        }
7200        ActorEventRouteError::ForgedSession => {
7201            "actor event payload targets a different canonical session"
7202        }
7203        ActorEventRouteError::ConflictingReplay => {
7204            "actor replay reused an event sequence with different content"
7205        }
7206        ActorEventRouteError::StaleAuthority => "actor event owner is stale",
7207        ActorEventRouteError::SequenceExhausted => "actor event sequence is exhausted",
7208    };
7209    AgentError::LLM(message.into())
7210}
7211
7212const MAX_DISPLAY_CALLS: usize = 4_096;
7213const MAX_NESTED_DISPLAY_DEPTH: usize = 8;
7214const MAX_DISPLAY_ID_BYTES: usize = 256;
7215const MAX_DISPLAY_HOOK_RESULT_DEPTH: usize = 16;
7216
7217fn hook_result_for_display(result: HookResult, depth: usize) -> Option<HookResult> {
7218    if depth >= MAX_DISPLAY_HOOK_RESULT_DEPTH {
7219        return None;
7220    }
7221    Some(match result {
7222        HookResult::Continue => HookResult::Continue,
7223        HookResult::Mutated => HookResult::Mutated,
7224        HookResult::Allow => HookResult::Allow,
7225        HookResult::Ask => HookResult::Ask,
7226        HookResult::Deny { .. } => HookResult::Deny {
7227            reason: "Hook denied".into(),
7228        },
7229        HookResult::InjectContext { .. } => HookResult::InjectContext {
7230            text: "Hook context hidden".into(),
7231        },
7232        HookResult::WithContext { result, .. } => HookResult::WithContext {
7233            result: Box::new(hook_result_for_display(*result, depth + 1)?),
7234            text: "Hook context hidden".into(),
7235        },
7236        HookResult::Suspend { .. } => HookResult::Suspend {
7237            reason: "Hook suspended".into(),
7238        },
7239        HookResult::Abort { .. } => HookResult::Abort {
7240            reason: "Hook aborted".into(),
7241        },
7242    })
7243}
7244
7245#[derive(Clone, Copy, PartialEq, Eq)]
7246enum DisplayCallKind {
7247    Ordinary,
7248    PrivateBrowser,
7249    Reused,
7250}
7251
7252#[derive(Clone, Copy)]
7253struct DisplayCall {
7254    kind: DisplayCallKind,
7255    started: bool,
7256}
7257
7258#[derive(Default)]
7259struct ActorEventDisplay {
7260    calls: HashMap<String, DisplayCall>,
7261    hook_names: HashMap<String, usize>,
7262    children: HashMap<(String, String), Box<ActorEventDisplay>>,
7263    // Shared by every nested child: a per-level cap alone permits an
7264    // exponential number of identities across an actor event tree.
7265    identities: Arc<AtomicUsize>,
7266    overflowed: bool,
7267    nested_overflowed: bool,
7268}
7269
7270fn is_browser_tool_name(tool_name: &str) -> bool {
7271    tool_name
7272        .trim()
7273        .rsplit("::")
7274        .next()
7275        .is_some_and(|name| name.eq_ignore_ascii_case("browser"))
7276}
7277
7278fn known_browser_action(args: &serde_json::Value) -> bool {
7279    matches!(
7280        args.get("action").and_then(serde_json::Value::as_str),
7281        Some(
7282            "tabs"
7283                | "new_tab"
7284                | "activate_tab"
7285                | "close_tab"
7286                | "navigate"
7287                | "history"
7288                | "viewport"
7289                | "snapshot"
7290                | "click"
7291                | "click_at"
7292                | "hover"
7293                | "drag"
7294                | "fill"
7295                | "select_option"
7296                | "set_file_input"
7297                | "type"
7298                | "press"
7299                | "key"
7300                | "scroll"
7301                | "download"
7302                | "screenshot"
7303                | "dialog_respond"
7304        )
7305    )
7306}
7307
7308fn is_private_browser_call(tool_name: &str, args: &serde_json::Value) -> bool {
7309    is_browser_tool_name(tool_name)
7310        && (!known_browser_action(args)
7311            || args
7312                .get("action")
7313                .and_then(serde_json::Value::as_str)
7314                .is_some_and(|action| {
7315                    action.eq_ignore_ascii_case("download") || action == "set_file_input"
7316                })
7317            || args.get("data_base64").is_some())
7318}
7319
7320fn actor_arguments_for_display(tool_name: &str, args: &serde_json::Value) -> serde_json::Value {
7321    if is_browser_tool_name(tool_name) {
7322        // A malformed or unknown action has no reliable privacy class. A
7323        // mixed-version actor may emit it before its schema rejects it.
7324        if !known_browser_action(args) {
7325            return serde_json::json!({"details":"[redacted]"});
7326        }
7327    }
7328    tool_start_arguments_for_display(tool_name, args)
7329}
7330
7331impl ActorEventDisplay {
7332    fn fail_closed(&mut self) {
7333        self.identities
7334            .store(MAX_DISPLAY_CALLS + 1, Ordering::Relaxed);
7335        self.overflowed = true;
7336        self.calls.clear();
7337        self.hook_names.clear();
7338        self.nested_overflowed = true;
7339        self.children.clear();
7340    }
7341
7342    fn reserve_identity(&mut self) -> bool {
7343        if self.identities.fetch_add(1, Ordering::Relaxed) < MAX_DISPLAY_CALLS {
7344            true
7345        } else {
7346            self.fail_closed();
7347            false
7348        }
7349    }
7350
7351    fn remember_start(&mut self, id: &str, private: bool) -> DisplayCallKind {
7352        if self.overflowed || id.is_empty() || id.len() > MAX_DISPLAY_ID_BYTES {
7353            return DisplayCallKind::Reused;
7354        }
7355        let kind = if private {
7356            DisplayCallKind::PrivateBrowser
7357        } else {
7358            DisplayCallKind::Ordinary
7359        };
7360        if let Some(call) = self.calls.get_mut(id) {
7361            if call.started || call.kind != kind {
7362                call.kind = DisplayCallKind::Reused;
7363            }
7364            call.started = true;
7365            return call.kind;
7366        }
7367        if !self.reserve_identity() {
7368            return DisplayCallKind::Reused;
7369        }
7370        self.calls.insert(
7371            id.to_string(),
7372            DisplayCall {
7373                kind,
7374                started: true,
7375            },
7376        );
7377        kind
7378    }
7379
7380    fn remember_approval(&mut self, id: &str, private: bool) -> DisplayCallKind {
7381        if self.overflowed || id.is_empty() || id.len() > MAX_DISPLAY_ID_BYTES {
7382            return DisplayCallKind::Reused;
7383        }
7384        let kind = if private {
7385            DisplayCallKind::PrivateBrowser
7386        } else {
7387            DisplayCallKind::Ordinary
7388        };
7389        if let Some(call) = self.calls.get_mut(id) {
7390            if call.kind != kind {
7391                call.kind = DisplayCallKind::Reused;
7392            }
7393            return call.kind;
7394        }
7395        if !self.reserve_identity() {
7396            return DisplayCallKind::Reused;
7397        }
7398        self.calls.insert(
7399            id.to_string(),
7400            DisplayCall {
7401                kind,
7402                started: false,
7403            },
7404        );
7405        kind
7406    }
7407
7408    fn known_kind(&self, id: &str) -> Option<DisplayCallKind> {
7409        if self.overflowed || self.identities.load(Ordering::Relaxed) > MAX_DISPLAY_CALLS {
7410            return None;
7411        }
7412        self.calls
7413            .get(id)
7414            .filter(|call| call.started)
7415            .map(|call| call.kind)
7416    }
7417
7418    fn display_hook_name(&mut self, hook_name: &str) -> Option<String> {
7419        if self.overflowed || hook_name.is_empty() || hook_name.len() > MAX_DISPLAY_ID_BYTES {
7420            self.fail_closed();
7421            return None;
7422        }
7423        if let Some(index) = self.hook_names.get(hook_name) {
7424            return Some(format!("actor-hook-{index}"));
7425        }
7426        if !self.reserve_identity() {
7427            return None;
7428        }
7429        let index = self.hook_names.len() + 1;
7430        self.hook_names.insert(hook_name.to_string(), index);
7431        Some(format!("actor-hook-{index}"))
7432    }
7433
7434    fn project(&mut self, event: AgentEvent, depth: usize) -> Option<AgentEvent> {
7435        let call_id = match &event {
7436            AgentEvent::ToolStart { tool_call_id, .. }
7437            | AgentEvent::ToolApprovalRequested { tool_call_id, .. }
7438            | AgentEvent::ToolToken { tool_call_id, .. }
7439            | AgentEvent::ToolComplete { tool_call_id, .. }
7440            | AgentEvent::ToolError { tool_call_id, .. }
7441            | AgentEvent::ToolLifecycle { tool_call_id, .. } => Some(tool_call_id),
7442            _ => None,
7443        };
7444        if call_id.is_some_and(|id| id.is_empty() || id.len() > MAX_DISPLAY_ID_BYTES) {
7445            self.fail_closed();
7446            return None;
7447        }
7448        if self.identities.load(Ordering::Relaxed) > MAX_DISPLAY_CALLS {
7449            self.fail_closed();
7450        }
7451        Some(match event {
7452            AgentEvent::ToolStart {
7453                tool_call_id,
7454                tool_name,
7455                arguments,
7456            } => {
7457                let private = is_private_browser_call(&tool_name, &arguments);
7458                let kind = self.remember_start(&tool_call_id, private);
7459                let arguments = if kind == DisplayCallKind::Reused {
7460                    serde_json::json!({"details":"[redacted]"})
7461                } else {
7462                    actor_arguments_for_display(&tool_name, &arguments)
7463                };
7464                AgentEvent::ToolStart {
7465                    tool_call_id,
7466                    tool_name: match kind {
7467                        DisplayCallKind::Reused => "unknown".into(),
7468                        _ if is_browser_tool_name(&tool_name) => "browser".into(),
7469                        _ => tool_name,
7470                    },
7471                    arguments,
7472                }
7473            }
7474            AgentEvent::ToolApprovalRequested {
7475                tool_call_id,
7476                tool_name,
7477                parameters,
7478            } => {
7479                let private = is_private_browser_call(&tool_name, &parameters);
7480                let kind = self.remember_approval(&tool_call_id, private);
7481                let parameters = if kind == DisplayCallKind::Reused {
7482                    serde_json::json!({"details":"[redacted]"})
7483                } else {
7484                    actor_arguments_for_display(&tool_name, &parameters)
7485                };
7486                AgentEvent::ToolApprovalRequested {
7487                    tool_call_id,
7488                    tool_name: match kind {
7489                        DisplayCallKind::Reused => "unknown".into(),
7490                        _ if is_browser_tool_name(&tool_name) => "browser".into(),
7491                        _ => tool_name,
7492                    },
7493                    parameters,
7494                }
7495            }
7496            AgentEvent::ToolToken {
7497                tool_call_id,
7498                content,
7499            } => {
7500                let content = match self.known_kind(&tool_call_id) {
7501                    Some(DisplayCallKind::Ordinary) => content,
7502                    Some(DisplayCallKind::PrivateBrowser) => "Browser tool output hidden".into(),
7503                    _ => "Tool output hidden".into(),
7504                };
7505                AgentEvent::ToolToken {
7506                    tool_call_id,
7507                    content,
7508                }
7509            }
7510            AgentEvent::ToolComplete {
7511                tool_call_id,
7512                result,
7513            } => {
7514                let result = match self.known_kind(&tool_call_id) {
7515                    Some(DisplayCallKind::Ordinary) => result,
7516                    Some(DisplayCallKind::PrivateBrowser) => {
7517                        bamboo_agent_core::tools::ToolResult::text(
7518                            result.success,
7519                            "Browser tool result hidden",
7520                        )
7521                    }
7522                    _ => bamboo_agent_core::tools::ToolResult::text(
7523                        result.success,
7524                        "Tool result hidden",
7525                    ),
7526                };
7527                AgentEvent::ToolComplete {
7528                    tool_call_id,
7529                    result,
7530                }
7531            }
7532            AgentEvent::ToolError {
7533                tool_call_id,
7534                error,
7535            } => {
7536                let error = match self.known_kind(&tool_call_id) {
7537                    Some(DisplayCallKind::Ordinary) => error,
7538                    Some(DisplayCallKind::PrivateBrowser) => "Browser tool failed".into(),
7539                    _ => "Tool failed".into(),
7540                };
7541                AgentEvent::ToolError {
7542                    tool_call_id,
7543                    error,
7544                }
7545            }
7546            AgentEvent::ToolLifecycle {
7547                tool_call_id,
7548                tool_name,
7549                phase,
7550                elapsed_ms,
7551                is_mutating,
7552                auto_approved,
7553                summary,
7554                error,
7555            } => {
7556                let kind = self.known_kind(&tool_call_id);
7557                let (summary, error) = match kind {
7558                    Some(DisplayCallKind::Ordinary) => (summary, error),
7559                    Some(DisplayCallKind::PrivateBrowser) => (
7560                        summary.map(|_| "Browser tool activity hidden".into()),
7561                        error.map(|_| "Browser tool failed".into()),
7562                    ),
7563                    _ => (
7564                        summary.map(|_| "Tool activity hidden".into()),
7565                        error.map(|_| "Tool failed".into()),
7566                    ),
7567                };
7568                AgentEvent::ToolLifecycle {
7569                    tool_call_id,
7570                    tool_name: match kind {
7571                        Some(DisplayCallKind::Ordinary) => tool_name,
7572                        Some(DisplayCallKind::PrivateBrowser) => "browser".into(),
7573                        _ => "unknown".into(),
7574                    },
7575                    phase: if kind == Some(DisplayCallKind::Ordinary)
7576                        || matches!(phase.as_str(), "begin" | "finished" | "error" | "cancelled")
7577                    {
7578                        phase
7579                    } else {
7580                        "event".into()
7581                    },
7582                    elapsed_ms,
7583                    is_mutating,
7584                    auto_approved,
7585                    summary,
7586                    error,
7587                }
7588            }
7589            AgentEvent::HookLifecycle {
7590                hook_name,
7591                point,
7592                phase,
7593                duration_ms,
7594                decision,
7595            } => {
7596                let Some(decision) = hook_result_for_display(decision, 0) else {
7597                    self.fail_closed();
7598                    return None;
7599                };
7600                let hook_name = self.display_hook_name(&hook_name)?;
7601                let phase = match phase.as_str() {
7602                    "completed" | "started" | "error" => phase,
7603                    _ => "event".into(),
7604                };
7605                AgentEvent::HookLifecycle {
7606                    hook_name,
7607                    point,
7608                    phase,
7609                    duration_ms,
7610                    decision,
7611                }
7612            }
7613            AgentEvent::ChildApprovalRequested {
7614                child_session_id,
7615                request_id,
7616                tool_name,
7617                permission,
7618                resource,
7619            } => {
7620                let (tool_name, permission, resource) = if is_browser_tool_name(&tool_name) {
7621                    (
7622                        "browser".into(),
7623                        "Browser interaction approval".into(),
7624                        "[redacted]".into(),
7625                    )
7626                } else {
7627                    (tool_name, permission, resource)
7628                };
7629                AgentEvent::ChildApprovalRequested {
7630                    child_session_id,
7631                    request_id,
7632                    tool_name,
7633                    permission,
7634                    resource,
7635                }
7636            }
7637            AgentEvent::ChildApprovalChanged { tool_name, .. }
7638                if is_browser_tool_name(&tool_name) =>
7639            {
7640                // This durable approval delta is host-owned. An actor-supplied
7641                // copy is neither authoritative nor safe to mirror to the
7642                // account feed; the host publishes its own record separately.
7643                return None;
7644            }
7645            AgentEvent::SubAgentEvent {
7646                parent_session_id,
7647                child_session_id,
7648                event,
7649            } => {
7650                if depth >= MAX_NESTED_DISPLAY_DEPTH || self.nested_overflowed {
7651                    return None;
7652                }
7653                if parent_session_id.is_empty()
7654                    || child_session_id.is_empty()
7655                    || parent_session_id.len() > MAX_DISPLAY_ID_BYTES
7656                    || child_session_id.len() > MAX_DISPLAY_ID_BYTES
7657                {
7658                    self.fail_closed();
7659                    return None;
7660                }
7661                let key = (parent_session_id.clone(), child_session_id.clone());
7662                if !self.children.contains_key(&key) && !self.reserve_identity() {
7663                    return None;
7664                }
7665                let identities = self.identities.clone();
7666                let child = self.children.entry(key).or_insert_with(|| {
7667                    Box::new(ActorEventDisplay {
7668                        identities,
7669                        ..Default::default()
7670                    })
7671                });
7672                let event = child.project(*event, depth + 1)?;
7673                if self.identities.load(Ordering::Relaxed) > MAX_DISPLAY_CALLS {
7674                    self.fail_closed();
7675                    return None;
7676                }
7677                AgentEvent::SubAgentEvent {
7678                    parent_session_id,
7679                    child_session_id,
7680                    event: Box::new(event),
7681                }
7682            }
7683            other => other,
7684        })
7685    }
7686}
7687
7688#[allow(clippy::too_many_arguments)]
7689async fn process_actor_event(
7690    event: serde_json::Value,
7691    strict_permission_events: bool,
7692    permission_handshake: &mut PermissionPostureHandshake,
7693    expected_permission_posture: Option<&ExpectedPermissionPosture>,
7694    session_inbox_runtime: Option<&SessionInboxRuntimeBinding>,
7695    logical_session: &mut Session,
7696    event_tx: &mpsc::Sender<AgentEvent>,
7697    display: &mut ActorEventDisplay,
7698) -> crate::runtime::runner::Result<()> {
7699    let event = match serde_json::from_value::<AgentEvent>(event) {
7700        Ok(event) => event,
7701        Err(_) if strict_permission_events => {
7702            // Serde errors can quote an unrecognized variant or field value.
7703            // The actor event is untrusted display input, including browser
7704            // download metadata, so never echo its decode details to SSE.
7705            return Err(AgentError::LLM(
7706                "actor emitted malformed AgentEvent under a typed permission posture contract"
7707                    .to_string(),
7708            ));
7709        }
7710        Err(_) => {
7711            // A legacy actor can omit a malformed ToolStart that reuses an
7712            // earlier public call ID. Its later result must not inherit the
7713            // stale public display classification.
7714            display.fail_closed();
7715            return Ok(());
7716        }
7717    };
7718    process_typed_actor_event(
7719        event,
7720        permission_handshake,
7721        expected_permission_posture,
7722        session_inbox_runtime,
7723        logical_session,
7724        event_tx,
7725        display,
7726    )
7727    .await
7728}
7729
7730#[allow(clippy::too_many_arguments)]
7731async fn process_typed_actor_event(
7732    event: AgentEvent,
7733    permission_handshake: &mut PermissionPostureHandshake,
7734    expected_permission_posture: Option<&ExpectedPermissionPosture>,
7735    session_inbox_runtime: Option<&SessionInboxRuntimeBinding>,
7736    logical_session: &mut Session,
7737    event_tx: &mpsc::Sender<AgentEvent>,
7738    display: &mut ActorEventDisplay,
7739) -> crate::runtime::runner::Result<()> {
7740    if matches!(&event, AgentEvent::PermissionPostureActivated { .. }) {
7741        if permission_handshake.posture_was_confirmed() {
7742            return Err(AgentError::LLM(
7743                "actor emitted a duplicate permission posture activation".to_string(),
7744            ));
7745        }
7746        let seed = permission_posture_seed_from_event(logical_session, &event)
7747            .map_err(AgentError::LLM)?
7748            .ok_or_else(|| {
7749                AgentError::LLM(
7750                    "actor permission posture event did not decode as a posture".to_string(),
7751                )
7752            })?;
7753        if let Some(expected) = expected_permission_posture {
7754            if seed.policy_revision != expected.policy_revision
7755                || seed.resolution != expected.resolution
7756            {
7757                return Err(AgentError::LLM(
7758                    "permission posture event does not match the host-dispatched policy"
7759                        .to_string(),
7760                ));
7761            }
7762            if seed.executor_mapping() != expected.executor_mapping {
7763                return Err(AgentError::LLM(
7764                    "permission posture event does not match the host-dispatched executor mapping"
7765                        .to_string(),
7766                ));
7767            }
7768        }
7769        if let Some(binding) = session_inbox_runtime {
7770            let saved = binding
7771                .persistence
7772                .record_permission_posture_activation(
7773                    &logical_session.id,
7774                    expected_permission_posture
7775                        .and_then(|expected| expected.expected_audit_revision),
7776                    &seed,
7777                )
7778                .await
7779                .map_err(|error| {
7780                    AgentError::LLM(format!(
7781                        "persist child permission posture bootstrap: {error}"
7782                    ))
7783                })?
7784                .ok_or_else(|| {
7785                    AgentError::LLM(
7786                        "persist child permission posture bootstrap: session not found".to_string(),
7787                    )
7788                })?;
7789            let snapshot = bamboo_domain::PermissionAuditSnapshot::from_metadata(&saved.metadata)
7790                .ok_or_else(|| {
7791                AgentError::LLM(
7792                    "persisted child permission posture audit is incomplete".to_string(),
7793                )
7794            })?;
7795            snapshot.write_to(&mut logical_session.metadata);
7796        } else {
7797            // In-memory/custom actor embeddings still use a host-owned clock.
7798            // Durable server paths always take the atomic branch.
7799            bamboo_domain::record_permission_audit(&mut logical_session.metadata, &seed, None)
7800                .map_err(|error| {
7801                    AgentError::LLM(format!(
7802                        "record in-memory child permission posture: {error}"
7803                    ))
7804                })?;
7805        }
7806        // Confirmation is deliberately last: matching alone is not enough.
7807        // The host-owned audit write must succeed first.
7808        *permission_handshake = PermissionPostureHandshake::Confirmed;
7809    } else if permission_handshake.is_awaiting() {
7810        return Err(AgentError::LLM(
7811            "actor emitted an execution event before permission posture confirmation".to_string(),
7812        ));
7813    }
7814    if let Some(event) = display.project(event, 0) {
7815        let _ = event_tx.send(event).await;
7816    }
7817    Ok(())
7818}
7819
7820/// Pump child frames -> parent events until a terminal frame (or cancellation).
7821/// On success, yields the actor's final result text (for session write-back).
7822/// `live_rx` carries in-band frames (steering messages) from the live registry.
7823///
7824/// `escalation_bridge` (#68) is the per-run escalation host bridge CAPTURED BY
7825/// VALUE at spawn time in `execute_external_child` (NOT read live here): when a
7826/// non-bypass child re-proxies an approval request, this owned bridge routes it
7827/// UP to the parent run. Owning it for the call's lifetime is what lets a
7828/// fire-and-forget grandchild that outlives its spawning run still escalate to
7829/// the correct (then-current) parent bridge rather than a stale/overwritten one.
7830async fn drive(context: ActorDriveContext<'_>) -> crate::runtime::runner::Result<Option<String>> {
7831    let ActorDriveContext {
7832        client,
7833        parent_session_id,
7834        child_session_id,
7835        child_attempt,
7836        approval_registry,
7837        approval_decider,
7838        approval_reviewer,
7839        escalation_bridge,
7840        event_tx,
7841        cancel_token,
7842        live_rx,
7843        delivery_rx,
7844        logical_session,
7845        mut expected_permission_posture,
7846        expected_creation,
7847        session_inbox_runtime,
7848        actor_directory_store,
7849        canonical_subagent_tool,
7850        ticket_service,
7851        activation_run_id,
7852        execution_epoch,
7853        expected_source_actor_id,
7854        initial_inflight_claims,
7855        first_frame_timeout,
7856        plain_actor,
7857        remote_environment_lease,
7858        mut readonly_output,
7859        local_history_tools,
7860        local_history_read_only,
7861        mut strict_history_output,
7862        plain_input,
7863        canonical_activation,
7864        canonical_placement_ref,
7865        actor_event_observer,
7866        plain_run,
7867    } = context;
7868
7869    // A projection belongs only to this Host-validated Run. A loaded Session
7870    // or a prior attempt may carry a stale marker; only the canonical wait
7871    // checks in the terminal branch below may set it again.
7872    logical_session
7873        .metadata
7874        .remove("runtime.actor_nested_wait_handoff");
7875
7876    // First-frame watchdog: a live worker emits its first frame (run-started /
7877    // first token) within seconds; total silence past the deadline means the
7878    // worker is dead (e.g. a pooled worker that exited right after checkout), so
7879    // its Run sits queued forever. We trip ONLY before the first frame — once any
7880    // frame arrives the worker is proven live and a legitimately long run (a slow
7881    // tool between tokens) never trips it.
7882    let mut got_first_frame = false;
7883    let mut first_frame_watch = first_frame_timeout.map(|d| Box::pin(tokio::time::sleep(d)));
7884    let mut inflight_claims = initial_inflight_claims;
7885    let mut owned_input = plain_input.and_then(|activation| activation.initial_input.clone());
7886    // Retry/recovery already consumes its one input; only a fresh activation
7887    // can continue twice. Neither budget restarts the Actor lease or watchdog.
7888    let continuation_limit = if owned_input.is_some() { 1 } else { 2 };
7889    let mut continuations = usize::from(owned_input.is_some());
7890    let mut released_input: Option<(
7891        SessionInboxOwnedClaim,
7892        bamboo_subagent::proto::InitialInputRelease,
7893    )> = None;
7894    let mut current_epoch = execution_epoch;
7895    let approval_epoch = Arc::new(AtomicU64::new(execution_epoch));
7896    let approval_cancel = cancel_token.child_token();
7897    let _approval_lifetime = approval_cancel.clone().drop_guard();
7898    let mut approval_scopes: HashMap<String, (serde_json::Value, ChildApprovalScope)> =
7899        HashMap::new();
7900    let mut owned_tree_requests = 0usize;
7901    let mut canonical_subagent_requests = 0usize;
7902    let mut committed_plan_calls = HashMap::new();
7903    // Once this Run registers a canonical Child wait, a fast completion may
7904    // clear it before the Worker sends Terminal. Keep the current Run
7905    // nonterminal so its already admitted outcome belongs to the successor.
7906    let mut nested_wait_registered_this_run = false;
7907    let mut parent_question_checkpoint_id: Option<String> = None;
7908    let strict_permission_events = expected_permission_posture.is_some();
7909    let mut permission_handshake =
7910        PermissionPostureHandshake::new(expected_permission_posture.as_ref());
7911    let mut next_actor_event_seq = 1u64;
7912    let mut canonical_router: Option<ActorEventRouter> = None;
7913    let canonical_fence = plain_input
7914        .map(|activation| &activation.fence)
7915        .or(canonical_activation);
7916    let canonical_store = plain_input
7917        .map(|activation| activation.store.as_ref())
7918        .or(actor_directory_store);
7919    let remote_claim_fence = actor_directory_store.zip(canonical_activation);
7920    let workflow_usage_requested = logical_session
7921        .metadata
7922        .get(bamboo_subagent::proto::WORKFLOW_USAGE_REQUESTED_KEY)
7923        .is_some_and(|v| v == "true");
7924    if workflow_usage_requested {
7925        logical_session
7926            .metadata
7927            .remove(bamboo_subagent::proto::WORKFLOW_USAGE_OBSERVATION_KEY);
7928        logical_session
7929            .metadata
7930            .remove(bamboo_subagent::proto::WORKFLOW_TERMINAL_OBSERVATION_KEY);
7931    }
7932    let mut remote_cancel_deadline: Option<tokio::time::Instant> = None;
7933    let mut display = ActorEventDisplay::default();
7934    let mut readonly = readonly_output
7935        .as_ref()
7936        .map(|_| ReadOnlyActorCollector::default());
7937    let mut local_history = local_history_tools.map(|_| LocalToolCollector::default());
7938    loop {
7939        tokio::select! {
7940            _ = cancel_token.cancelled(), if remote_cancel_deadline.is_none() => {
7941                if remote_claim_fence.is_some() || workflow_usage_requested {
7942                    // A remote Worker may finish after its subscriber cancels.
7943                    // Keep the same correlated frame pump alive briefly so an
7944                    // exact Cancelled Outcome can terminalize the Actor fence.
7945                    // Without that proof the activation stays live until its
7946                    // lease expires and a retry must remain blocked.
7947                    let _ = client.send(ParentFrame::Cancel).await;
7948                    remote_cancel_deadline = Some(
7949                        tokio::time::Instant::now() + Duration::from_secs(10)
7950                    );
7951                    continue;
7952                }
7953                // Direct/local links retain their existing cancel behavior.
7954                break;
7955            }
7956            _ = async {
7957                if let Some(deadline) = remote_cancel_deadline {
7958                    tokio::time::sleep_until(deadline).await;
7959                } else {
7960                    std::future::pending::<()>().await;
7961                }
7962            } => {
7963                return Err(AgentError::Cancelled);
7964            }
7965            _ = async {
7966                match first_frame_watch.as_mut() {
7967                    Some(s) => s.as_mut().await,
7968                    None => std::future::pending::<()>().await,
7969                }
7970            }, if !got_first_frame => {
7971                return Err(AgentError::WorkerUnresponsive(format!(
7972                    "child {child_session_id} produced no frame within {:?}",
7973                    first_frame_timeout.unwrap_or_default()
7974                )));
7975            }
7976            Some(_generation) = delivery_rx.recv(),
7977                if session_inbox_runtime.is_some() && activation_run_id.is_some() =>
7978            {
7979                if plain_input.is_some() {
7980                    // Keep the real Inbox pending until this Run's completed
7981                    // reply can be committed BEFORE its correction User.
7982                    continue;
7983                }
7984                forward_next_canonical_claim(
7985                    client,
7986                    session_inbox_runtime.expect("guarded"),
7987                    logical_session,
7988                    activation_run_id.expect("guarded"),
7989                    &mut inflight_claims,
7990                    remote_claim_fence,
7991                )
7992                .await?;
7993            }
7994            Some(frame) = live_rx.recv() => {
7995                if plain_actor { return Err(plain_actor_unsupported()); }
7996                // Forward in-band steering to the worker over the existing WS.
7997                if client.send(frame).await.is_err() {
7998                    tracing::warn!("live steering frame could not be sent; connection failing");
7999                }
8000            }
8001            frame = client.next_frame() => {
8002                // Any frame (event / approval / terminal / close / error) proves
8003                // the worker responded — disarm the first-frame watchdog.
8004                got_first_frame = true;
8005                first_frame_watch = None;
8006                match frame {
8007                    Ok(Some(ChildFrame::Event { event })) => {
8008                        if canonical_activation.is_some() {
8009                            return Err(AgentError::LLM("remote_actor_event_batch_required".into()));
8010                        }
8011                        if local_history.is_some() {
8012                            return Err(local_tool_history_unsupported());
8013                        }
8014                        if event.get("initial_input_control").is_some() {
8015                            let bamboo_subagent::proto::InitialInputControl::Request { request } =
8016                                bamboo_subagent::proto::InitialInputControl::decode(event).map_err(|_| plain_actor_unsupported())?
8017                            else { return Err(plain_actor_unsupported()); };
8018                            if !permission_handshake.posture_was_confirmed() || cancel_token.is_cancelled() { return Err(plain_actor_unsupported()); }
8019                            let activation = plain_input.ok_or_else(|| plain_actor_unsupported())?;
8020                            let binding = session_inbox_runtime.ok_or_else(|| plain_actor_unsupported())?;
8021                            let (run, _) = plain_run.ok_or_else(|| plain_actor_unsupported())?;
8022                            let claim = owned_input.as_ref().or_else(|| released_input.as_ref().map(|(claim, _)| claim))
8023                                .ok_or_else(|| plain_actor_unsupported())?;
8024                            let release = activation.release_initial_input(binding, logical_session, run, current_epoch, claim,
8025                                &request, &mut expected_permission_posture, released_input.as_ref().map(|(_, release)| release)).await?;
8026                            if cancel_token.is_cancelled() { return Err(AgentError::Cancelled); }
8027                            client.send(ParentFrame::InitialInputRelease { release: release.clone() }).await
8028                                .map_err(|_| AgentError::LLM("Actor initial release unconfirmed; durable input is preserved".into()))?;
8029                            released_input = Some((claim.clone(), release));
8030                            owned_input = None;
8031                            continue;
8032                        }
8033                        if expected_creation.is_some() {
8034                            return Err(AgentError::LLM("worker omitted required Child creation event identity".into()));
8035                        }
8036                        if event["type"] == bamboo_subagent::proto::LocalToolMessages::TYPE { continue; }
8037                        if event["type"] == "owned_readonly_transcript" {
8038                            if plain_actor { return Err(plain_actor_unsupported()); }
8039                            continue;
8040                        }
8041                        if plain_actor && !plain_actor_event(&event)? { continue; }
8042                        if let Some(activation) = plain_input {
8043                            validate_directory_actor_event(
8044                                activation.store.as_ref(),
8045                                &activation.fence,
8046                                logical_session,
8047                                None,
8048                            ).await?;
8049                        }
8050                        // Rolling-upgrade compatibility: old actors have no
8051                        // route/sequence metadata, but retain the same typed
8052                        // permission handshake and event validation.
8053                        process_actor_event(
8054                            event,
8055                            strict_permission_events,
8056                            &mut permission_handshake,
8057                            expected_permission_posture.as_ref(),
8058                            session_inbox_runtime,
8059                            logical_session,
8060                            event_tx,
8061                            &mut display,
8062                        )
8063                        .await?;
8064                    }
8065                    Ok(Some(ChildFrame::EventBatch { batch })) => {
8066                        validate_actor_event_batch(
8067                            &batch,
8068                            logical_session,
8069                            parent_session_id,
8070                            activation_run_id,
8071                            current_epoch,
8072                            expected_source_actor_id,
8073                            expected_creation,
8074                        )?;
8075                        let directory_entry = if let (Some(store), Some(fence)) = (canonical_store, canonical_fence) {
8076                            let entry = validate_directory_actor_event(
8077                                store, fence, logical_session, batch.activation_id.as_deref(),
8078                            ).await?;
8079                            if canonical_placement_ref.is_some_and(|expected| {
8080                                entry.activation.as_ref().and_then(|activation| activation.placement_ref.as_ref()) != Some(expected)
8081                            }) {
8082                                return Err(AgentError::LLM("remote_actor_placement_ref_changed".into()));
8083                            }
8084                            Some(entry)
8085                        } else { None };
8086                        if let (Some(entry), Some(fence)) = (&directory_entry, canonical_fence) {
8087                            if canonical_router.is_none() {
8088                                canonical_router = Some(
8089                                    ActorEventRouter::new(
8090                                        entry,
8091                                        fence,
8092                                        current_epoch,
8093                                        strict_permission_events && permission_handshake.is_awaiting(),
8094                                    )
8095                                    .map_err(actor_event_route_error)?,
8096                                );
8097                            }
8098                        }
8099                        if local_history_tools.is_some()
8100                            && (batch.first_seq != next_actor_event_seq
8101                                || batch.qos != bamboo_subagent::ActorEventQos::Durable
8102                                || batch.last_seq == u64::MAX) {
8103                            return Err(local_tool_history_unsupported());
8104                        }
8105                        if batch.last_seq < next_actor_event_seq {
8106                            continue;
8107                        }
8108                        if batch.first_seq > next_actor_event_seq {
8109                            if plain_actor {
8110                                return Err(AgentError::LLM(
8111                                    "actor event sequence gap; authoritative session snapshot is required".into(),
8112                                ));
8113                            }
8114                            tracing::warn!(
8115                                child_session_id,
8116                                expected_seq = next_actor_event_seq,
8117                                received_seq = batch.first_seq,
8118                                "actor event sequence gap; authoritative session snapshot is required"
8119                            );
8120                            // A missing ToolStart may have reused an earlier
8121                            // call ID for a private browser download. No prior
8122                            // identity remains safe for display after a gap.
8123                            display.fail_closed();
8124                        }
8125                        let skip = next_actor_event_seq
8126                            .saturating_sub(batch.first_seq)
8127                            .min(batch.events.len() as u64) as usize;
8128                        for (offset, event) in batch.events.into_iter().enumerate().skip(skip) {
8129                            let seq = batch.first_seq + offset as u64;
8130                            let mut publish = true;
8131                            if event["type"] == bamboo_subagent::proto::WorkflowAgentUsage::TYPE {
8132                                if workflow_usage_requested {
8133                                    if permission_handshake.is_awaiting() { return Err(plain_actor_unsupported()); }
8134                                    let usage: bamboo_subagent::proto::WorkflowAgentUsage = serde_json::from_value(event.clone()).map_err(|_| plain_actor_unsupported())?;
8135                                    let activation = activation_run_id.ok_or_else(plain_actor_unsupported)?;
8136                                    if !usage.matches(logical_session, activation) { return Err(plain_actor_unsupported()); }
8137                                    if logical_session.metadata.contains_key(bamboo_subagent::proto::WORKFLOW_USAGE_OBSERVATION_KEY) { return Err(plain_actor_unsupported()); }
8138                                    logical_session.metadata.insert(bamboo_subagent::proto::WORKFLOW_USAGE_OBSERVATION_KEY.into(), serde_json::to_string(&usage).map_err(|_| plain_actor_unsupported())?);
8139                                }
8140                                publish = false;
8141                            }
8142                            if publish { if let Some(collector) = local_history.as_mut() {
8143                                if permission_handshake.is_awaiting() && event["type"] != "permission_posture_activated" {
8144                                    return Err(local_tool_history_unsupported());
8145                                }
8146                                if logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY) {
8147                                    publish = collector.ticket_event(&event, &committed_plan_calls)?;
8148                                }
8149                                publish &= collector.event(&event)?;
8150                            } else if event["type"] == bamboo_subagent::proto::LocalToolMessages::TYPE {
8151                                // Completion DATA is private and grants nothing on an unselected route.
8152                                publish = false;
8153                            }}
8154                            if publish { publish = if let Some(collector) = readonly.as_mut() {
8155                                if permission_handshake.is_awaiting()
8156                                    && event["type"] != "permission_posture_activated" {
8157                                    return Err(plain_actor_unsupported());
8158                                }
8159                                collector.event(&event)?
8160                            } else if event["type"] == "owned_readonly_transcript" {
8161                                if plain_actor { return Err(plain_actor_unsupported()); }
8162                                false // A worker cache observation is not a legacy Host commit.
8163                            } else if plain_actor { plain_actor_event(&event)? } else { true };
8164                            }
8165                            if let (Some(router), Some(entry), Some(fence)) =
8166                                (canonical_router.as_mut(), directory_entry.as_ref(), canonical_fence)
8167                            {
8168                                let typed = if publish {
8169                                    Some(serde_json::from_value::<AgentEvent>(event.clone())
8170                                        .map_err(|_| plain_actor_unsupported())?)
8171                                } else {
8172                                    None
8173                                };
8174                                match router.route(entry, fence, current_epoch, seq, typed)
8175                                    .map_err(actor_event_route_error)?
8176                                {
8177                                    ActorEventRoute::Publish(envelope) => {
8178                                        let public_event = actor_event_observer
8179                                            .filter(|_| envelope.class != ActorEventClass::Ephemeral)
8180                                            .map(|_| PublicActorEvent::from(envelope.as_ref()));
8181                                        tracing::trace!(
8182                                            actor_id = %envelope.actor_id,
8183                                            event_id = %envelope.event_id,
8184                                            class = ?envelope.class,
8185                                            "publishing canonical Actor event"
8186                                        );
8187                                        process_typed_actor_event(
8188                                            envelope.payload,
8189                                            &mut permission_handshake,
8190                                            expected_permission_posture.as_ref(),
8191                                            session_inbox_runtime,
8192                                            logical_session,
8193                                            event_tx,
8194                                            &mut display,
8195                                        ).await?;
8196                                        if let (Some(observer), Some(public_event)) =
8197                                            (actor_event_observer, public_event)
8198                                        {
8199                                            observer.publish(public_event);
8200                                        }
8201                                    }
8202                                    ActorEventRoute::Duplicate | ActorEventRoute::Suppressed => {}
8203                                }
8204                                next_actor_event_seq = seq.saturating_add(1);
8205                                continue;
8206                            }
8207                            if !publish {
8208                                next_actor_event_seq = seq.saturating_add(1);
8209                                continue;
8210                            }
8211                            process_actor_event(
8212                                event,
8213                                strict_permission_events,
8214                                &mut permission_handshake,
8215                                expected_permission_posture.as_ref(),
8216                                session_inbox_runtime,
8217                                logical_session,
8218                                event_tx,
8219                                &mut display,
8220                            )
8221                            .await?;
8222                            next_actor_event_seq = seq.saturating_add(1);
8223                        }
8224                    }
8225                    Ok(Some(ChildFrame::OwnedTreeRequest { id, cursor })) => {
8226                        owned_tree_requests += 1;
8227                        if owned_tree_requests > 16 {
8228                            return Err(AgentError::LLM("owned tree request limit exceeded".into()));
8229                        }
8230                        let page = if id.len() <= 128
8231                            && !logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
8232                            && cursor.as_ref().is_none_or(|value| !value.is_empty() && value.len() <= 128)
8233                            && !cancel_token.is_cancelled()
8234                        {
8235                            match (actor_directory_store, session_inbox_runtime,
8236                                activation_run_id, expected_creation) {
8237                                (Some(store), Some(binding), Some(run_id), Some(creation)) => {
8238                                    tokio::time::timeout(
8239                                        Duration::from_secs(8),
8240                                        inspect_active_owned_tree(
8241                                            store, binding, run_id, child_session_id,
8242                                            parent_session_id, logical_session, creation,
8243                                            plain_input, cursor.as_deref(),
8244                                        ),
8245                                    ).await.ok().flatten()
8246                                }
8247                                _ => {
8248                                    tracing::warn!("owned tree denied: canonical Host scope unavailable");
8249                                    None
8250                                },
8251                            }
8252                        } else {
8253                            tracing::warn!("owned tree denied: request bounds or cancellation");
8254                            None
8255                        };
8256                        if cancel_token.is_cancelled() {
8257                            return Err(AgentError::Cancelled);
8258                        }
8259                        client.send(ParentFrame::OwnedTreeReply { id, page }).await
8260                            .map_err(|_| AgentError::LLM("owned tree reply transport closed".into()))?;
8261                    }
8262                    Ok(Some(ChildFrame::SubAgentRequest { id, tool_call_id, args })) => {
8263                        canonical_subagent_requests += 1;
8264                        if canonical_subagent_requests > 64 {
8265                            return Err(AgentError::LLM("canonical SubAgent request limit exceeded".into()));
8266                        }
8267                        let valid_shape = !id.is_empty() && id.len() <= 128
8268                            && !tool_call_id.is_empty() && tool_call_id.len() <= 128
8269                            && args.is_object()
8270                            && serde_json::to_vec(&args)
8271                                .is_ok_and(|bytes| bytes.len() <= 64 * 1024);
8272                        let registers_wait = matches!(args.get("action").and_then(|value| value.as_str()),
8273                            Some("wait"))
8274                            || (args.get("action").and_then(|value| value.as_str()) == Some("create")
8275                                && args.get("wait").and_then(|value| value.as_bool()) == Some(true));
8276                        let checkpoint_payload = args.get(PARENT_QUESTION_CHECKPOINT_ACTION).cloned();
8277                        let ticket_bootstrap_pending = valid_shape
8278                            && logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
8279                            && args == serde_json::json!({(crate::ticket_worker_plan::TICKET_PLAN_ACTION): {"read":true}})
8280                            && permission_handshake.is_awaiting()
8281                            && !cancel_token.is_cancelled();
8282                        let result = if ticket_bootstrap_pending {
8283                            // A read control can overtake the queued posture
8284                            // event on the separate lanes. Keep the handshake
8285                            // fence; the exact bootstrap read may retry briefly.
8286                            serde_json::json!({"error":crate::ticket_worker_plan::remote::TICKET_BOOTSTRAP_POSTURE_PENDING})
8287                        } else if valid_shape
8288                            && (!logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
8289                                || args.get(crate::ticket_worker_plan::TICKET_PLAN_ACTION).is_some()
8290                                || args.get(crate::ticket_worker_plan::files::TICKET_FILE_ACTION).is_some())
8291                            && !cancel_token.is_cancelled()
8292                            && !permission_handshake.is_awaiting()
8293                        {
8294                            if args.get(crate::ticket_worker_plan::TICKET_PLAN_ACTION).is_some() {
8295                                match (ticket_service.as_deref(), actor_directory_store,
8296                                    session_inbox_runtime, activation_run_id, expected_creation) {
8297                                    (Some(service), Some(store), Some(binding), Some(run_id), Some(creation)) => {
8298                                        if let Some(caller) = load_active_subagent_caller(
8299                                            store, binding, run_id, child_session_id,
8300                                            parent_session_id, logical_session, creation, plain_input,
8301                                        ).await {
8302                                            match crate::ticket_worker_plan::remote::apply_host_plan_request(service, &caller, run_id, &args, &tool_call_id) {
8303                                                Ok(projection) => {
8304                                                    logical_session.task_list = projection.task_list.clone();
8305                                                    logical_session.set_task_list_version_meta(projection.plan_revision.to_string());
8306                                                    if let Some(question) = &projection.question {
8307                                                        let raw = serde_json::to_string(question).map_err(|_| local_tool_history_unsupported())?;
8308                                                        logical_session.metadata.insert(crate::ticket_worker_plan::TICKET_QUESTION_YIELD_KEY.into(), raw);
8309                                                        if let Some(collector) = local_history.as_mut() {
8310                                                            collector.ticket_yield = Some((tool_call_id.clone(), question.clone()));
8311                                                        }
8312                                                    }
8313                                                    if let Some(task) = args[crate::ticket_worker_plan::TICKET_PLAN_ACTION].get("task") {
8314                                                        committed_plan_calls.insert(tool_call_id.clone(), task.clone());
8315                                                        if let Some(task_list) = projection.task_list.clone() {
8316                                                            let _ = event_tx.send(AgentEvent::TaskListUpdated {
8317                                                                task_list,
8318                                                                version: Some(projection.plan_revision),
8319                                                            }).await;
8320                                                        }
8321                                                    }
8322                                                    serde_json::json!({"result":projection})
8323                                                }
8324                                                Err(error) => serde_json::json!({"error":error.to_string()}),
8325                                            }
8326                                        } else { serde_json::json!({"error":"LocalPlan Child Run is no longer current"}) }
8327                                    }
8328                                    _ => serde_json::json!({"error":"LocalPlan Host authority unavailable"}),
8329                                }
8330                            } else if args.get(crate::ticket_worker_plan::files::TICKET_FILE_ACTION).is_some() {
8331                                match (ticket_service.as_deref(), actor_directory_store, session_inbox_runtime,
8332                                    activation_run_id, expected_creation, local_history_tools) {
8333                                    (Some(service),Some(store),Some(binding),Some(run_id),Some(creation),Some(ceiling)) => {
8334                                        if let Some(caller)=load_active_subagent_caller(store,binding,run_id,child_session_id,parent_session_id,logical_session,creation,plain_input).await {
8335                                            match crate::ticket_worker_plan::files::apply_host_file_request(service,&caller,run_id,&args,&tool_call_id,ceiling) {
8336                                                Ok(reply)=>{
8337                                                    let body=&args[crate::ticket_worker_plan::files::TICKET_FILE_ACTION];
8338                                                    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())?}));
8339                                                    serde_json::json!({"result":reply})
8340                                                },
8341                                                Err(error)=>serde_json::json!({"error":error.to_string()}),
8342                                            }
8343                                        } else { serde_json::json!({"error":"Ticket file Child Run is no longer current"}) }
8344                                    },
8345                                    _=>serde_json::json!({"error":"Ticket file Host authority unavailable"}),
8346                                }
8347                            } else if let Some(payload) = checkpoint_payload {
8348                                let parsed = (args.as_object().is_some_and(|object| object.len() == 1))
8349                                    .then(|| serde_json::from_value::<ParentQuestionCheckpointV1>(payload).ok())
8350                                    .flatten()
8351                                    .filter(|observation| observation.tool_call_id == tool_call_id);
8352                                match (parsed, actor_directory_store, session_inbox_runtime,
8353                                    activation_run_id, expected_creation) {
8354                                    (Some(observation), Some(store), Some(binding), Some(run_id), Some(creation)) => {
8355                                        let receipt = tokio::time::timeout(Duration::from_secs(25),
8356                                            checkpoint_active_parent_question(
8357                                                store, binding, run_id, child_session_id,
8358                                                parent_session_id, logical_session, creation,
8359                                                plain_input, observation,
8360                                            )).await.ok().flatten();
8361                                        if let Some(request_id) = receipt {
8362                                            parent_question_checkpoint_id = Some(request_id.clone());
8363                                            serde_json::json!({"result":{"request_id":request_id}})
8364                                        } else {
8365                                            serde_json::json!({"error":"canonical Child question checkpoint rejected; persisted worker question is preserved"})
8366                                        }
8367                                    }
8368                                    _ => serde_json::json!({"error":"canonical Child question checkpoint unavailable"}),
8369                                }
8370                            } else {
8371                            match (canonical_subagent_tool.as_deref(), actor_directory_store,
8372                                session_inbox_runtime, activation_run_id, expected_creation) {
8373                                (Some(tool), Some(store), Some(binding), Some(run_id), Some(creation)) => {
8374                                    if let Some(caller) = load_active_subagent_caller(
8375                                        store, binding, run_id, child_session_id,
8376                                        parent_session_id, logical_session, creation,
8377                                        plain_input,
8378                                    ).await {
8379                                        tokio::time::timeout(
8380                                            Duration::from_secs(30),
8381                                            invoke_active_subagent(tool, &caller, args, &tool_call_id),
8382                                        ).await.unwrap_or_else(|_| serde_json::json!({
8383                                            "error":"canonical SubAgent operation timed out; inspect logical child state before retrying"
8384                                        }))
8385                                    } else {
8386                                        serde_json::json!({"error":"canonical SubAgent caller is no longer active"})
8387                                    }
8388                                }
8389                                _ => serde_json::json!({"error":"canonical SubAgent Host authority unavailable"}),
8390                            }
8391                            }
8392                        } else {
8393                            serde_json::json!({"error":"canonical SubAgent request rejected"})
8394                        };
8395                        if registers_wait
8396                            && result["result"]["success"] == true
8397                            && result["result"]["result"]
8398                                .as_str()
8399                                .and_then(|value| serde_json::from_str::<serde_json::Value>(value).ok())
8400                                .is_some_and(|value| value["status"] == "waiting")
8401                        {
8402                            nested_wait_registered_this_run = true;
8403                        }
8404                        if cancel_token.is_cancelled() {
8405                            return Err(AgentError::Cancelled);
8406                        }
8407                        client.send(ParentFrame::SubAgentReply { id, result }).await
8408                            .map_err(|_| AgentError::LLM("canonical SubAgent reply transport closed".into()))?;
8409                    }
8410                    Ok(Some(ChildFrame::ApprovalRequest { id, body })) => {
8411                        if logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY) {
8412                            return Err(AgentError::LLM("Ticket Task-only Worker cannot request unrelated action authority".into()));
8413                        }
8414                        if plain_actor { return Err(plain_actor_unsupported()); }
8415                        if permission_handshake.is_awaiting() {
8416                            return Err(AgentError::LLM(
8417                                "actor requested approval before permission posture confirmation"
8418                                    .to_string(),
8419                            ));
8420                        }
8421                        // Phase 2: a worker proxied a gated-tool approval back to
8422                        // the host. The WORKER side is live — its executor installs
8423                        // a per-run task-local `ApprovalProxy` (subagent_worker.rs)
8424                        // that calls `host.approval_call`, so this frame arrives
8425                        // when a child hits `ConfirmationRequired`.
8426                        if let Some(reviewer) = approval_reviewer
8427                            .cloned()
8428                            .or_else(child_approval_reviewer)
8429                        {
8430                            // Phase 6, Part B: a BYPASSED parent worker
8431                            // model-reviews its children's forced-ask (dangerous)
8432                            // actions. The review is an LLM call, so run it OFF
8433                            // the frame pump in a spawned task and deliver the
8434                            // verdict async via the live channel — the pump keeps
8435                            // forwarding events and the agent loop never blocks. A
8436                            // timeout denies a hung review so the child can't hang.
8437                            let child = child_session_id.to_string();
8438                            let parent = parent_session_id.to_string();
8439                            let req_id = id.clone();
8440                            let body = body.clone();
8441                            let registry = approval_registry.cloned();
8442                            let Some((binding, run)) = session_inbox_runtime.zip(activation_run_id) else {
8443                                // Legacy/custom reviewers retain their original boolean seam;
8444                                // the canonical server reviewer denies without a live scope.
8445                                tokio::spawn(async move {
8446                                    let approved = tokio::time::timeout(CHILD_APPROVAL_TIMEOUT,
8447                                        reviewer.review(&parent, &child, &body)).await.unwrap_or(false);
8448                                    super::live::deliver_approval_scoped(registry.as_ref(), &child,
8449                                        child_attempt, &req_id, approved);
8450                                });
8451                                continue;
8452                            };
8453                            let generation = body.pointer("/permission_request/request_generation")
8454                                .and_then(serde_json::Value::as_str).unwrap_or("").to_string();
8455                            let scope = if let Some((original, scope)) = approval_scopes.get(&generation) {
8456                                if original != &body || scope.stamp()["reply"] != req_id {
8457                                    return Err(AgentError::LLM("approval generation changed in live scope".into()));
8458                                }
8459                                scope.clone()
8460                            } else {
8461                                if approval_scopes.len() >= 64 {
8462                                    return Err(AgentError::LLM("approval live scope limit exceeded".into()));
8463                                }
8464                                let scope = ChildApprovalScope::new(&parent, &child,
8465                                    (child_attempt, run, current_epoch, &req_id, chrono::Utc::now() + chrono::Duration::seconds(240)), binding.router.clone(),
8466                                    approval_cancel.clone(), approval_epoch.clone());
8467                                approval_scopes.insert(generation, (body.clone(), scope.clone()));
8468                                scope
8469                            };
8470                            tokio::spawn(async move {
8471                                let result = tokio::time::timeout(
8472                                    CHILD_APPROVAL_TIMEOUT,
8473                                    reviewer.review_scoped(&parent, &child, &body, &scope),
8474                                ).await.unwrap_or(ChildApprovalReview::Reply(false));
8475                                if let ChildApprovalReview::Reply(approved) = result {
8476                                    if scope.is_current(&parent, &child).await {
8477                                        super::live::deliver_approval_scoped(registry.as_ref(), &child,
8478                                            child_attempt, &req_id, approved && chrono::Utc::now() < scope.deadline());
8479                                    }
8480                                }
8481                            });
8482                        } else if approval_decider.is_some() {
8483                            // A decider is wired (policy / auto): decide promptly
8484                            // and reply inline. (Must not block the pump — see the
8485                            // `ChildApprovalDecider` doc.)
8486                            let approved =
8487                                decide_child_approval(approval_decider, child_session_id, &body)
8488                                    .await;
8489                            if client
8490                                .send(ParentFrame::ApprovalReply { id, approved })
8491                                .await
8492                                .is_err()
8493                            {
8494                                tracing::warn!(
8495                                    "failed to answer approval_request; connection failing"
8496                                );
8497                            }
8498                        } else if let Some(host) = escalation_bridge.clone() {
8499                            // Non-bypass WORKER: ESCALATE up our own actor link
8500                            // (re-proxy) so the request chains to our parent — and
8501                            // up every level until a bypass level or the top
8502                            // orchestrator's model reviewer decides. With no such
8503                            // reviewer the top level fails closed. Off-loop so the
8504                            // pump never blocks; relay the reply down to the child.
8505                            let child = child_session_id.to_string();
8506                            let req_id = id.clone();
8507                            let body = body.clone();
8508                            let registry = approval_registry.cloned();
8509                            tokio::spawn(async move {
8510                                let approved = match tokio::time::timeout(
8511                                    CHILD_APPROVAL_TIMEOUT,
8512                                    host.approval_call(body),
8513                                )
8514                                .await
8515                                {
8516                                    Ok(Ok(reply)) => reply
8517                                        .get("approved")
8518                                        .and_then(|v| v.as_bool())
8519                                        .unwrap_or(false),
8520                                    // Transport error or timeout ⇒ fail closed.
8521                                    _ => false,
8522                                };
8523                                super::live::deliver_approval_scoped(
8524                                    registry.as_ref(),
8525                                    &child,
8526                                    child_attempt,
8527                                    &req_id,
8528                                    approved,
8529                                );
8530                            });
8531                        } else {
8532                            // There is no parent-agent reviewer or upstream actor
8533                            // to own this decision. Never open a manual/UI approval
8534                            // path: forced-ask is parent-reviewed or fail-closed.
8535                            tracing::warn!(
8536                                parent_session_id,
8537                                child_session_id,
8538                                request_id = %id,
8539                                "forced-ask request has no parent-agent reviewer; denying"
8540                            );
8541                            if client
8542                                .send(ParentFrame::ApprovalReply {
8543                                    id,
8544                                    approved: false,
8545                                })
8546                                .await
8547                                .is_err()
8548                            {
8549                                tracing::warn!(
8550                                    "failed to send fail-closed approval reply; connection failing"
8551                                );
8552                            }
8553                        }
8554                    }
8555                    Ok(Some(ChildFrame::SessionMessageAdmitted { confirmation })) => {
8556                        if let Some(activation) = plain_input {
8557                            if activation.initial_release_required { return Err(plain_actor_unsupported()); }
8558                            let binding = session_inbox_runtime.ok_or_else(|| plain_actor_unsupported())?;
8559                            let run_id = activation_run_id.ok_or_else(|| plain_actor_unsupported())?;
8560                            let claim = owned_input.as_ref().ok_or_else(|| plain_actor_unsupported())?;
8561                            activation.confirm_input(binding, logical_session, run_id, claim, &confirmation).await?;
8562                            owned_input = None;
8563                            continue;
8564                        }
8565                        let Some(binding) = session_inbox_runtime else {
8566                            tracing::warn!(
8567                                child_session_id,
8568                                "ignoring SessionInbox confirmation without a runtime binding"
8569                            );
8570                            continue;
8571                        };
8572                        let Some(bound_run_id) = activation_run_id else {
8573                            tracing::warn!(
8574                                child_session_id,
8575                                "ignoring SessionInbox confirmation without an activation owner"
8576                            );
8577                            continue;
8578                        };
8579                        let Some(claim) = inflight_claims.front() else {
8580                            tracing::warn!(
8581                                child_session_id,
8582                                envelope_id = %confirmation.envelope_id,
8583                                "rejecting stale SessionInbox confirmation with no in-flight canonical claim"
8584                            );
8585                            continue;
8586                        };
8587                        let exact = confirmation.target_session_id == logical_session.id
8588                            && confirmation.envelope_id == claim.envelope.id.as_str()
8589                            && confirmation.canonical_claim_generation == claim.generation
8590                            && confirmation.activation_run_id == bound_run_id;
8591                        if !exact
8592                            || !binding
8593                                .router
8594                                .owns_run(&logical_session.id, bound_run_id)
8595                                .await
8596                        {
8597                            tracing::warn!(
8598                                child_session_id,
8599                                expected_target = %logical_session.id,
8600                                received_target = %confirmation.target_session_id,
8601                                expected_envelope_id = %claim.envelope.id,
8602                                received_envelope_id = %confirmation.envelope_id,
8603                                expected_generation = claim.generation,
8604                                received_generation = confirmation.canonical_claim_generation,
8605                                expected_run_id = bound_run_id,
8606                                received_run_id = %confirmation.activation_run_id,
8607                                "rejecting stale or mismatched SessionInbox admission confirmation"
8608                            );
8609                            continue;
8610                        }
8611                        let claim = inflight_claims
8612                            .pop_front()
8613                            .expect("validated in-flight canonical claim");
8614                        // On failure the durable canonical cur file remains
8615                        // recoverable for the next owner.
8616                        checkpoint_and_ack_canonical_claim(
8617                            binding,
8618                            logical_session,
8619                            &claim,
8620                            remote_claim_fence,
8621                        )
8622                            .await?;
8623                        // Ordered single-consumer: only after the exact prior
8624                        // claim is checkpointed+acked may the driver claim and
8625                        // forward the next envelope.
8626                        if inflight_claims.is_empty() {
8627                            forward_next_canonical_claim(
8628                                client,
8629                                binding,
8630                                logical_session,
8631                                bound_run_id,
8632                                &mut inflight_claims,
8633                                remote_claim_fence,
8634                            )
8635                            .await?;
8636                        }
8637                    }
8638                    Ok(Some(ChildFrame::Terminal { status, result, error, final_event_watermark, .. })) => {
8639                        if local_history_tools.is_some() {
8640                            // Check before history commit or terminal receipt acceptance.
8641                            // Normal lossy observations do not promise a complete trace.
8642                            validate_strict_history_terminal(
8643                                final_event_watermark.as_ref(), logical_session, parent_session_id,
8644                                activation_run_id, current_epoch, expected_creation,
8645                                next_actor_event_seq - 1,
8646                            )?;
8647                        }
8648                        if remote_cancel_deadline.is_some() && status == TerminalStatus::Cancelled {
8649                            if let Some(tools) = local_history_tools {
8650                                // The watermark proves transport completeness, not
8651                                // persistence. Certify only an entirely validated
8652                                // local Read/Glob prefix with no pending handoff.
8653                                if permission_handshake.is_awaiting()
8654                                    || owned_input.is_some()
8655                                    || !inflight_claims.is_empty()
8656                                    || parent_question_checkpoint_id.is_some()
8657                                    || nested_wait_registered_this_run
8658                                    || strict_history_output.is_none()
8659                                {
8660                                    return Err(local_tool_history_unsupported());
8661                                }
8662                                commit_local_tool_history(
8663                                    local_history.take().ok_or_else(local_tool_history_unsupported)?,
8664                                    logical_session,
8665                                    session_inbox_runtime.ok_or_else(local_tool_history_unsupported)?,
8666                                    activation_run_id.ok_or_else(local_tool_history_unsupported)?,
8667                                    (tools, local_history_read_only),
8668                                    LocalToolTerminal::Cancelled,
8669                                    cancel_token,
8670                                ).await?;
8671                                let proof = bamboo_storage::v2::HostTerminalCompleteness::from_verified_host(
8672                                    logical_session,
8673                                    activation_run_id.ok_or_else(local_tool_history_unsupported)?,
8674                                    current_epoch,
8675                                    next_actor_event_seq - 1,
8676                                ).map_err(|_| local_tool_history_unsupported())?;
8677                                *strict_history_output.as_deref_mut()
8678                                    .ok_or_else(local_tool_history_unsupported)? = Some(StrictTerminalHistory {
8679                                        proof,
8680                                        watermark: final_event_watermark.ok_or_else(local_tool_history_unsupported)?,
8681                                    });
8682                            }
8683                            // A selected Worker terminal proves that its Run
8684                            // ended even if permission posture or an Inbox
8685                            // correction was still pending. Claims remain
8686                            // unacked for retry; only the placement fence can
8687                            // now be released after SDK saves Cancelled.
8688                            client.accept_durable_terminal(status);
8689                            if workflow_usage_requested {
8690                                logical_session.metadata.insert(bamboo_subagent::proto::WORKFLOW_TERMINAL_OBSERVATION_KEY.into(), activation_run_id.ok_or_else(plain_actor_unsupported)?.into());
8691                            }
8692                            return Err(AgentError::Cancelled);
8693                        }
8694                        if parent_question_checkpoint_id.is_some()
8695                            && status != TerminalStatus::Suspended
8696                        {
8697                            return Err(AgentError::LLM(
8698                                "Child question checkpoint must end in a suspended Run".into(),
8699                            ));
8700                        }
8701                        if permission_handshake.is_awaiting() {
8702                            if remote_environment_lease && status == TerminalStatus::Error {
8703                                if let Some(code) = error.as_deref().and_then(remote_environment_error_code) {
8704                                    // The Worker rejected the EnvironmentLease before it
8705                                    // could confirm its permission posture. This exact
8706                                    // correlated Outcome still proves the Run ended, so
8707                                    // retain its durable receipt and release the slot after
8708                                    // the Host records the failure. Other early terminals
8709                                    // remain unproven and keep their placement fence.
8710                                    client.accept_durable_terminal(status);
8711                            if workflow_usage_requested {
8712                                logical_session.metadata.insert(bamboo_subagent::proto::WORKFLOW_TERMINAL_OBSERVATION_KEY.into(), activation_run_id.ok_or_else(plain_actor_unsupported)?.into());
8713                            }
8714                                    return Err(AgentError::LLM(code.to_owned()));
8715                                }
8716                            }
8717                            return Err(AgentError::LLM(
8718                                "actor terminated before permission posture confirmation"
8719                                    .to_string(),
8720                            ));
8721                        }
8722                        if owned_input.is_some() {
8723                            return Err(AgentError::LLM("Actor terminated before confirming its durable correction; Inbox claim is retained".into()));
8724                        }
8725                        if let Some(claim) = inflight_claims.front() {
8726                            return Err(AgentError::LLM(format!(
8727                                "actor terminated before durably admitting SessionInbox message {}; canonical claim remains recoverable",
8728                                claim.envelope.id
8729                            )));
8730                        }
8731                        if status == TerminalStatus::Completed {
8732                            if let Some(collector) = local_history.take() {
8733                                commit_local_tool_history(
8734                                    collector, logical_session,
8735                                    session_inbox_runtime.ok_or_else(local_tool_history_unsupported)?,
8736                                    activation_run_id.ok_or_else(local_tool_history_unsupported)?,
8737                                    (local_history_tools.ok_or_else(local_tool_history_unsupported)?, local_history_read_only),
8738                                    LocalToolTerminal::Completed(result.as_deref()), cancel_token,
8739                                ).await?;
8740                            }
8741                        }
8742                        if let Some(collector) = readonly.take() {
8743                            if status == TerminalStatus::Completed {
8744                                let activation = plain_input.ok_or_else(|| plain_actor_unsupported())?;
8745                                let backlog = activation.input_inbox.inspect(&logical_session.id).await
8746                                    .map_err(|_| plain_actor_unsupported())?;
8747                                if backlog.pending != 0 || backlog.claimed != 0 || backlog.activation_pending() {
8748                                    return Err(AgentError::LLM("Glob Actor continuation is unsupported; pending Inbox input is preserved".into()));
8749                                }
8750                                *readonly_output.as_deref_mut().ok_or_else(|| plain_actor_unsupported())? =
8751                                    collector.finish(result.as_deref())?;
8752                            }
8753                        } else if let Some(activation) = plain_input {
8754                            if status == TerminalStatus::Completed {
8755                                let binding = session_inbox_runtime.ok_or_else(|| plain_actor_unsupported())?;
8756                                let run_id = activation_run_id.ok_or_else(|| plain_actor_unsupported())?;
8757                                if continuations < continuation_limit {
8758                                    if let Some((claim, epoch)) = activation.continue_input(
8759                                        client, binding, logical_session, run_id, plain_run, &mut expected_permission_posture,
8760                                        result.clone().filter(|text| !text.is_empty()).ok_or_else(|| plain_actor_unsupported())?, event_tx,
8761                                    ).await? {
8762                                        owned_input = Some(claim);
8763                                        released_input = None;
8764                                        continuations += 1;
8765                                        current_epoch = epoch;
8766                                        approval_epoch.store(epoch, Ordering::Release);
8767                                        // These are native coordinates scoped to the newly
8768                                        // correlated epoch. Public Host feed seq is untouched.
8769                                        next_actor_event_seq = 1;
8770                                        canonical_router = None;
8771                                        permission_handshake = PermissionPostureHandshake::new(expected_permission_posture.as_ref());
8772                                        display = ActorEventDisplay::default();
8773                                        continue;
8774                                    }
8775                                } else if activation.input_inbox.inspect(&logical_session.id).await
8776                                    .map_err(|_| plain_actor_unsupported())?.activation_pending() {
8777                                    if continuation_limit == 1 {
8778                                        return Err(AgentError::LLM("Only one Actor correction continuation is supported; remaining Inbox input is preserved".into()));
8779                                    }
8780                                    // The outer success path appends the last reply. An
8781                                    // explicit cap refusal must preserve it here instead,
8782                                    // without claiming/acking the excess input or saving
8783                                    // through the ordinary writer on this owned Actor.
8784                                    activation.append_reply(logical_session,
8785                                        result.clone().filter(|text| !text.is_empty()).ok_or_else(|| plain_actor_unsupported())?,
8786                                        event_tx).await?;
8787                                    return Err(AgentError::LLM("Actor correction continuation limit reached; completed reply committed and remaining Inbox input preserved".into()));
8788                                }
8789                            }
8790                        }
8791                        if status == TerminalStatus::Completed {
8792                            // Only an activation with a canonical Child creation
8793                            // identity can invoke the Host SubAgent route. Legacy
8794                            // Echo and other executors may share this runner's
8795                            // installed tool but have no nested wait to fence.
8796                            if let (Some(tool), Some(creation)) =
8797                                (canonical_subagent_tool.as_deref(), expected_creation)
8798                            {
8799                                let (Some(store), Some(binding), Some(run_id)) =
8800                                    (actor_directory_store, session_inbox_runtime,
8801                                        activation_run_id)
8802                                else {
8803                                    return Err(AgentError::LLM(
8804                                        "nested SubAgent Host authority unavailable at Child terminal"
8805                                            .into(),
8806                                    ));
8807                                };
8808                                let must_suspend = ensure_active_nested_wait(
8809                                    tool, store, binding, run_id, child_session_id,
8810                                    parent_session_id, logical_session, creation, plain_input,
8811                                ).await?;
8812                                if must_suspend || nested_wait_registered_this_run {
8813                                    // This is only a current-Run handoff fence.
8814                                    // sdk/spawn re-reads the durable Host wait;
8815                                    // copying a stale wait into this Session
8816                                    // would re-arm it after a fast grandchild
8817                                    // completion, while copying no wait would
8818                                    // erase an active registration on final save.
8819                                    logical_session.metadata.insert(
8820                                        "runtime.actor_nested_wait_handoff".into(),
8821                                        "true".into(),
8822                                    );
8823                                }
8824                            }
8825                        }
8826                        let terminal_result = match status {
8827                            TerminalStatus::Completed => Ok(result),
8828                            TerminalStatus::Cancelled => Err(AgentError::Cancelled),
8829                            TerminalStatus::Error => Err(AgentError::LLM(
8830                                error.unwrap_or_else(|| "actor child errored".to_string()),
8831                            )),
8832                            TerminalStatus::Suspended => {
8833                                use bamboo_agent_core::storage::Storage;
8834                                let (Some(request_id), Some(store), Some(binding), Some(run_id), Some(creation)) =
8835                                    (parent_question_checkpoint_id.as_ref(), actor_directory_store,
8836                                        session_inbox_runtime, activation_run_id, expected_creation)
8837                                else {
8838                                    return Err(AgentError::LLM("uncheckpointed Child suspension rejected".into()));
8839                                };
8840                                if load_active_subagent_caller(
8841                                    store, binding, run_id, child_session_id,
8842                                    parent_session_id, logical_session, creation, plain_input,
8843                                ).await.is_none() {
8844                                    return Err(AgentError::LLM("stale Child question suspension rejected".into()));
8845                                }
8846                                let id = bamboo_domain::SessionMessageId::parse(request_id.clone())
8847                                    .map_err(|_| AgentError::LLM("invalid Child question receipt".into()))?;
8848                                let child = store.load_session(child_session_id).await
8849                                    .map_err(|_| AgentError::LLM("canonical Child question unavailable".into()))?
8850                                    .ok_or_else(|| AgentError::LLM("canonical Child question missing".into()))?;
8851                                let parent = store.load_session(parent_session_id).await
8852                                    .map_err(|_| AgentError::LLM("canonical direct parent unavailable".into()))?
8853                                    .ok_or_else(|| AgentError::LLM("canonical direct parent missing".into()))?;
8854                                let pending = ParentQuestion::for_pending(&parent, &child)
8855                                    .is_some_and(|question| question.id == id);
8856                                let answered = bamboo_domain::ParentQuestionResolution::from_child(
8857                                    &parent, &child, &id,
8858                                ).is_some();
8859                                if !pending && !answered {
8860                                    return Err(AgentError::LLM("canonical Child question proof changed".into()));
8861                                }
8862                                logical_session.metadata.insert(
8863                                    "runtime.actor_parent_question_handoff".into(), "true".into(),
8864                                );
8865                                Ok(None)
8866                            }
8867                        };
8868                        if local_history_tools.is_some() {
8869                            // Failed strict Runs discard their collected suffix. A
8870                            // consumed watermark cannot certify those unapplied
8871                            // effects; retain every broker frame for recovery.
8872                            if status != TerminalStatus::Completed {
8873                                return terminal_result;
8874                            }
8875                            let proof = bamboo_storage::v2::HostTerminalCompleteness::from_verified_host(
8876                                logical_session,
8877                                activation_run_id.ok_or_else(local_tool_history_unsupported)?,
8878                                current_epoch,
8879                                next_actor_event_seq - 1,
8880                            ).map_err(|_| local_tool_history_unsupported())?;
8881                            let history = StrictTerminalHistory {
8882                                proof,
8883                                watermark: final_event_watermark.ok_or_else(local_tool_history_unsupported)?,
8884                            };
8885                            if let Some(output) = strict_history_output.as_deref_mut() {
8886                                *output = Some(history);
8887                            }
8888                        }
8889                        // This marker is set only after every Host terminal
8890                        // validation above succeeded. A surfaced broker Outcome
8891                        // that failed permission, Inbox, or question checks must
8892                        // never receive a durable ACK merely because SDK saved
8893                        // an error status. Genuine Worker Error/Cancelled frames
8894                        // are accepted terminals even though drive returns Err.
8895                        client.accept_durable_terminal(status);
8896                            if workflow_usage_requested {
8897                                logical_session.metadata.insert(bamboo_subagent::proto::WORKFLOW_TERMINAL_OBSERVATION_KEY.into(), activation_run_id.ok_or_else(plain_actor_unsupported)?.into());
8898                            }
8899                        if status == TerminalStatus::Completed {
8900                            if let Some(activation) = plain_input {
8901                                // Only the final epoch reaches here: continued Runs
8902                                // above reset their router and stay in the pump.
8903                                *activation.completion_router.lock().recover_poison() =
8904                                    canonical_router.take();
8905                            }
8906                        }
8907                        return terminal_result;
8908                    }
8909                    Ok(None) => {
8910                        return Err(AgentError::LLM(
8911                            "actor child closed before terminal".to_string(),
8912                        ));
8913                    }
8914                    Err(e) => {
8915                        return Err(AgentError::LLM(format!("actor transport error: {e}")));
8916                    }
8917                }
8918            }
8919        }
8920    }
8921
8922    // Only reached on cancellation: ask the child to stop (best-effort), then report cancelled.
8923    let _ = client.send(ParentFrame::Cancel).await;
8924    Err(AgentError::Cancelled)
8925}
8926
8927/// The assignment text = the child session's latest user message (falls back to its title).
8928fn project_id_for_actor_run(
8929    session: &Session,
8930) -> Result<Option<bamboo_domain::ProjectId>, AgentError> {
8931    match crate::project_context::ProjectContextResolver::session_project_identity(session) {
8932        crate::project_context::SessionProjectIdentity::Assigned(project_id) => {
8933            Ok(Some(project_id))
8934        }
8935        crate::project_context::SessionProjectIdentity::Unassigned => Ok(None),
8936        crate::project_context::SessionProjectIdentity::Invalid { raw, message } => {
8937            Err(AgentError::LLM(format!(
8938                "child session carries an invalid Project identity '{raw}': {message}"
8939            )))
8940        }
8941    }
8942}
8943
8944fn remote_broker_unavailable() -> AgentError {
8945    AgentError::LLM("remote_broker_activation_unavailable".into())
8946}
8947
8948/// Only these bounded worker admission errors may cross the fixed Remote
8949/// diagnostic boundary before permission posture confirmation. Transport and
8950/// arbitrary worker text retain the generic remote error.
8951fn remote_environment_error_code(value: &str) -> Option<&'static str> {
8952    const CODES: &[&str] = &[
8953        "remote_environment_lease_invalid",
8954        "remote_environment_snapshot_mismatch",
8955        "remote_environment_checkout_not_clean",
8956        "remote_environment_checkout_unsupported",
8957        "remote_environment_workspace_unavailable",
8958        "remote_environment_workspace_outside_git_root",
8959        "remote_environment_path_unsupported",
8960        "remote_environment_git_unavailable",
8961        "remote_environment_git_inspection_failed",
8962        "remote_environment_git_inspection_timed_out",
8963        "remote_environment_git_output_too_large",
8964        "remote_environment_snapshot_too_large",
8965        "remote_environment_snapshot_changed",
8966        "remote_environment_index_invalid",
8967        "remote_environment_index_unsupported",
8968        "remote_environment_host_path_or_grants_rejected",
8969        "remote_environment_worker_workspace_missing",
8970        "remote_environment_runspec_invalid",
8971        "remote_environment_lease_missing_or_host_path_present",
8972    ];
8973    CODES.iter().copied().find(|code| *code == value)
8974}
8975
8976// Read only: no ensure/repair/Actor initialization or subtree budget. Capture
8977// exactly the canonical chain and compare it again immediately before Run.
8978async fn remote_canonical_lineage(
8979    binding: &SessionInboxRuntimeBinding,
8980    session: &Session,
8981) -> Result<Vec<bamboo_domain::ActorSession>, AgentError> {
8982    let mut current = bamboo_domain::ActorSession::from_session(session)
8983        .map_err(|_| remote_broker_unavailable())?;
8984    let mut chain = Vec::new();
8985    loop {
8986        if chain.len() > session.spawn_depth as usize {
8987            return Err(remote_broker_unavailable());
8988        }
8989        let saved = binding
8990            .storage
8991            .load_session(&current.actor_id)
8992            .await
8993            .map_err(|_| remote_broker_unavailable())?
8994            .ok_or_else(remote_broker_unavailable)?;
8995        let observed = bamboo_domain::ActorSession::from_session(&saved)
8996            .map_err(|_| remote_broker_unavailable())?;
8997        if observed.actor_id != current.actor_id
8998            || observed.session_created_at != current.session_created_at
8999            || observed.parent_actor_id != current.parent_actor_id
9000            || observed.root_actor_id != current.root_actor_id
9001            || observed.spawn_depth != current.spawn_depth
9002            || observed.project_id != current.project_id
9003        {
9004            return Err(remote_broker_unavailable());
9005        }
9006        chain.push(observed.clone());
9007        let Some(parent_id) = observed.parent_actor_id.as_ref() else {
9008            if observed.actor_id != session.root_session_id || observed.spawn_depth != 0 {
9009                return Err(remote_broker_unavailable());
9010            }
9011            return Ok(chain);
9012        };
9013        let parent = binding
9014            .storage
9015            .load_session(parent_id)
9016            .await
9017            .map_err(|_| remote_broker_unavailable())?
9018            .ok_or_else(remote_broker_unavailable)?;
9019        let parent = bamboo_domain::ActorSession::from_session(&parent)
9020            .map_err(|_| remote_broker_unavailable())?;
9021        if parent.root_actor_id != observed.root_actor_id
9022            || parent.project_id != observed.project_id
9023            || parent.spawn_depth.checked_add(1) != Some(observed.spawn_depth)
9024            || parent.session_created_at > observed.session_created_at
9025        {
9026            return Err(remote_broker_unavailable());
9027        }
9028        current = parent;
9029    }
9030}
9031
9032async fn canonical_child_creation(
9033    binding: &SessionInboxRuntimeBinding,
9034    session: &Session,
9035    job: &SpawnJob,
9036) -> Result<bamboo_subagent::proto::ChildCreationIdentity, AgentError> {
9037    let conflict = || {
9038        AgentError::LLM(
9039            "child creation identity unavailable or changed in canonical host Storage".into(),
9040        )
9041    };
9042    let current = binding
9043        .storage
9044        .load_session(&job.child_session_id)
9045        .await
9046        .map_err(|_| conflict())?
9047        .ok_or_else(conflict)?;
9048    if bamboo_domain::ActorSession::from_session(&current).is_err()
9049        || bamboo_domain::ActorSession::from_session(session).is_err()
9050        || current.kind != bamboo_domain::SessionKind::Child
9051        || session.kind != current.kind
9052        || current.id != session.id
9053        || current.id != job.child_session_id
9054        || current.parent_session_id.as_deref() != Some(job.parent_session_id.as_str())
9055        || current.parent_session_id != session.parent_session_id
9056        || current.root_session_id.trim().is_empty()
9057        || current.root_session_id != session.root_session_id
9058        || current.created_at != session.created_at
9059        || current.spawn_depth != session.spawn_depth
9060        || current.spawn_depth == 0
9061        || project_id_for_actor_run(&current)? != project_id_for_actor_run(session)?
9062    {
9063        return Err(conflict());
9064    }
9065    Ok(bamboo_subagent::proto::ChildCreationIdentity {
9066        created_at: current.created_at,
9067        spawn_depth: current.spawn_depth,
9068    })
9069}
9070
9071fn logical_identity_for_actor_run(session: &Session, job: &SpawnJob) -> LogicalSessionIdentity {
9072    LogicalSessionIdentity {
9073        creation: None,
9074        session_id: session.id.clone(),
9075        parent_session_id: session
9076            .parent_session_id
9077            .clone()
9078            .or_else(|| Some(job.parent_session_id.clone())),
9079        root_session_id: if session.root_session_id.trim().is_empty() {
9080            job.parent_session_id.clone()
9081        } else {
9082            session.root_session_id.clone()
9083        },
9084    }
9085}
9086
9087fn extract_assignment(session: &Session) -> String {
9088    session
9089        .messages
9090        .iter()
9091        .rev()
9092        .find(|m| matches!(m.role, Role::User))
9093        .map(|m| m.content.clone())
9094        .unwrap_or_else(|| {
9095            session
9096                .metadata
9097                .get("title")
9098                .cloned()
9099                .unwrap_or_else(|| "Execute task".to_string())
9100        })
9101}
9102
9103#[cfg(test)]
9104mod tests {
9105    use super::*;
9106    use crate::SessionActivationRouter;
9107    use bamboo_domain::{RuntimeSessionPersistence, SessionInboxPort, Storage};
9108
9109    include!("expired_plain_owner_tests.rs");
9110
9111    #[test]
9112    fn plain_rejection_logs_branch_and_run_without_changing_error() {
9113        // Keep shared callsites consulting the current subscriber during scoped capture.
9114        let capture_interest_guard = tracing::Dispatch::new(
9115            tracing_subscriber::fmt()
9116                .with_max_level(tracing_subscriber::filter::LevelFilter::OFF)
9117                .with_writer(std::io::sink)
9118                .finish(),
9119        );
9120        let output = tempfile::NamedTempFile::new().unwrap();
9121        let subscriber = tracing_subscriber::fmt()
9122            .with_ansi(false)
9123            .without_time()
9124            .with_writer(output.reopen().unwrap())
9125            .finish();
9126        let branch_line = std::cell::Cell::new(0);
9127        let error = tracing::subscriber::with_default(subscriber, || {
9128            // Exercise the shared warning callsite without this thread's scoped subscriber.
9129            std::thread::spawn(|| drop(plain_actor_unsupported()))
9130                .join()
9131                .expect("unscoped rejection warning thread panicked");
9132            let span = tracing::warn_span!(
9133                "actor_child_run",
9134                child_session_id = %"diagnostic-child",
9135                activation_run_id = tracing::field::Empty
9136            );
9137            let _entered = span.enter();
9138            span.record("activation_run_id", "diagnostic-run");
9139            None::<()>
9140                .ok_or_else(|| {
9141                    let caller_line = line!() + 1;
9142                    let error = plain_actor_unsupported();
9143                    branch_line.set(caller_line);
9144                    error
9145                })
9146                .unwrap_err()
9147        });
9148        assert!(matches!(&error, AgentError::LLM(message)
9149            if message.starts_with("This Actor Child supports a fresh plain activation,")
9150                && message.ends_with("Other continuation is unsupported; durable history is preserved.")));
9151        let log = std::fs::read_to_string(output.path()).unwrap();
9152        assert!(log.contains("child_session_id=diagnostic-child"), "{log}");
9153        assert!(
9154            log.contains("activation_run_id=\"diagnostic-run\""),
9155            "{log}"
9156        );
9157        assert!(
9158            log.contains(&format!("process_id={}", std::process::id())),
9159            "{log}"
9160        );
9161        assert!(log.contains(&format!("source_file={:?}", file!())), "{log}");
9162        assert!(
9163            log.contains(&format!("source_line={}", branch_line.get())),
9164            "{log}"
9165        );
9166        assert!(!error.to_string().contains("diagnostic-run"));
9167        drop(capture_interest_guard);
9168    }
9169
9170    #[tokio::test]
9171    async fn completed_plain_continuation_requires_one_input_and_current_identity() {
9172        for case in [
9173            "valid",
9174            "two-inputs",
9175            "tool-history",
9176            "stale-lineage",
9177            "no-release",
9178        ] {
9179            let temp = tempfile::tempdir().unwrap();
9180            let store = Arc::new(
9181                bamboo_storage::SessionStoreV2::new(temp.path().into())
9182                    .await
9183                    .unwrap(),
9184            );
9185            let parent = Session::new("continuation-parent", "model");
9186            store.save_session(&parent).await.unwrap();
9187            let mut child =
9188                Session::new_child_of("continuation-child", &parent, "model", "first task");
9189            child.add_message(bamboo_agent_core::Message::assistant("first result", None));
9190            if case == "tool-history" {
9191                let mut tool = bamboo_agent_core::Message::assistant("old tool result", None);
9192                tool.role = Role::Tool;
9193                child.add_message(tool);
9194            }
9195            child.set_last_run_status("completed");
9196            store.save_session(&child).await.unwrap();
9197            store.ensure_actor(&child.id).await.unwrap();
9198            let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
9199            bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
9200            let now = chrono::Utc::now();
9201            let old = store
9202                .claim_activation(&ActorActivationClaim {
9203                    actor_id: child.id.clone(),
9204                    run_id: "first-run".into(),
9205                    lease_owner: "first-owner".into(),
9206                    lease_expires_at: now + chrono::Duration::minutes(1),
9207                    inbox_generation: 0,
9208                    placement_ref: None,
9209                    now,
9210                })
9211                .await
9212                .unwrap();
9213            store.start_activation(&old.fence(), now).await.unwrap();
9214            store
9215                .finish_activation(&old.fence(), now, ActorActivationFinish::Succeeded)
9216                .await
9217                .unwrap();
9218            let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
9219                store.clone(),
9220                Default::default(),
9221            ));
9222            for slot in 0..if case == "two-inputs" { 2 } else { 1 } {
9223                let envelope = bamboo_domain::SessionMessageEnvelope::user_input(
9224                    &child.id,
9225                    format!("new task {slot}"),
9226                );
9227                let receipt = inbox.deliver(&envelope).await.unwrap();
9228                inbox
9229                    .mark_activation_eligible(
9230                        &child.id,
9231                        receipt.generation,
9232                        bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
9233                    )
9234                    .await
9235                    .unwrap();
9236            }
9237            let storage: Arc<dyn Storage> = store.clone();
9238            let persistence = Arc::new(bamboo_storage::LockedSessionStore::new(storage));
9239            let binding = actor_binding(store.clone(), inbox, persistence);
9240            let _registration = binding
9241                .router
9242                .register_run(&child.id, "second-run")
9243                .await
9244                .unwrap();
9245            if case == "stale-lineage" {
9246                child.parent_session_id = Some("foreign-parent".into());
9247            }
9248            let result = PlainActorActivation::start(
9249                store.clone(),
9250                &child,
9251                &binding,
9252                Some("second-run"),
9253                Some(policy),
9254                true,
9255                (case != "no-release").then_some("second-worker"),
9256            )
9257            .await;
9258            if case == "valid" {
9259                let activation = result.expect("one new input on a completed plain Child");
9260                assert_eq!(activation.fence.attempt, old.attempt + 1);
9261                assert_eq!(activation.fence.actor_id, child.id);
9262                assert_eq!(activation.inbox_generation, 1);
9263                activation
9264                    .finish(ActorActivationFinish::Failed)
9265                    .await
9266                    .unwrap();
9267            } else {
9268                assert!(
9269                    result.is_err(),
9270                    "unsupported continuation {case} must fail before claim"
9271                );
9272                assert_eq!(
9273                    store
9274                        .inspect_actor(&child.id)
9275                        .await
9276                        .unwrap()
9277                        .actor
9278                        .current_attempt,
9279                    old.attempt
9280                );
9281            }
9282            let canonical = store.load_session(&child.id).await.unwrap().unwrap();
9283            assert_eq!(
9284                canonical.parent_session_id.as_deref(),
9285                Some(parent.id.as_str())
9286            );
9287            assert!(canonical
9288                .messages
9289                .iter()
9290                .any(|message| message.content == "first result"));
9291        }
9292    }
9293
9294    #[tokio::test]
9295    async fn pre_dispatch_placement_failure_fails_only_claimed_activation() {
9296        let temp = tempfile::tempdir().unwrap();
9297        let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
9298            .await
9299            .unwrap();
9300        let parent = Session::new("placement-parent", "model");
9301        let child = Session::new_child_of("placement-child", &parent, "model", "task");
9302        store.save_session(&parent).await.unwrap();
9303        store.save_session(&child).await.unwrap();
9304        store.ensure_actor(&child.id).await.unwrap();
9305        store
9306            .bind_control_plane(
9307                &child.id,
9308                bamboo_domain::ActorControlPlaneBinding {
9309                    policy_revision: None,
9310                    placement_intent: Some(bamboo_domain::ActorPlacementIntent::Pool {
9311                        pool_id: "remote-worker".into(),
9312                    }),
9313                },
9314            )
9315            .await
9316            .unwrap();
9317
9318        let claim = |run_id: &str| ActorActivationClaim {
9319            actor_id: child.id.clone(),
9320            run_id: run_id.into(),
9321            lease_owner: format!("host-{run_id}"),
9322            lease_expires_at: chrono::Utc::now() + chrono::Duration::seconds(80),
9323            inbox_generation: 0,
9324            placement_ref: Some(bamboo_domain::ActorPlacementRef {
9325                class: bamboo_domain::ActorPlacementClass::Remote,
9326                lease_id: format!("slot-{run_id}"),
9327                slot_epoch: Some(1),
9328            }),
9329            now: chrono::Utc::now(),
9330        };
9331        let first = store.claim_activation(&claim("first")).await.unwrap();
9332        store
9333            .start_activation(&first.fence(), chrono::Utc::now())
9334            .await
9335            .unwrap();
9336        assert!(store.claim_activation(&claim("retry")).await.is_err());
9337
9338        // This is the post-claim placement preflight failure path. No Run has
9339        // been dispatched, so the same ActorId can retry before lease expiry.
9340        fail_pre_dispatch_activation(&store, &first.fence())
9341            .await
9342            .unwrap();
9343        let failed = store.inspect_actor(&child.id).await.unwrap();
9344        assert_eq!(
9345            failed.activation.unwrap().status,
9346            bamboo_domain::ActorActivationStatus::Failed
9347        );
9348        let retry = store.claim_activation(&claim("retry")).await.unwrap();
9349        assert_eq!(retry.attempt, first.attempt + 1);
9350        assert!(fail_pre_dispatch_activation(&store, &first.fence())
9351            .await
9352            .is_err());
9353        assert_eq!(
9354            store.inspect_actor(&child.id).await.unwrap().activation,
9355            Some(retry)
9356        );
9357    }
9358
9359    #[tokio::test]
9360    async fn lost_run_delivery_receipt_keeps_actor_activation_live() {
9361        struct AcceptedWithoutReceipt {
9362            published: bool,
9363        }
9364
9365        #[async_trait]
9366        impl bamboo_subagent::ChildLink for AcceptedWithoutReceipt {
9367            async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
9368                assert!(matches!(frame, ParentFrame::Run(_)));
9369                self.published = true;
9370                Err(bamboo_subagent::TransportError::Protocol(
9371                    "broker delivery receipt lost after publication".into(),
9372                ))
9373            }
9374
9375            async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
9376                std::future::pending().await
9377            }
9378        }
9379
9380        let temp = tempfile::tempdir().unwrap();
9381        let store = Arc::new(
9382            bamboo_storage::SessionStoreV2::new(temp.path().into())
9383                .await
9384                .unwrap(),
9385        );
9386        let parent = Session::new("dispatch-parent", "model");
9387        let child = Session::new_child_of("dispatch-child", &parent, "model", "task");
9388        store.save_session(&parent).await.unwrap();
9389        store.save_session(&child).await.unwrap();
9390        store.ensure_actor(&child.id).await.unwrap();
9391        let now = chrono::Utc::now();
9392        let claim = ActorActivationClaim {
9393            actor_id: child.id.clone(),
9394            run_id: "dispatch-run".into(),
9395            lease_owner: "dispatch-host".into(),
9396            lease_expires_at: now + chrono::Duration::seconds(80),
9397            inbox_generation: 0,
9398            placement_ref: None,
9399            now,
9400        };
9401        let activation = store.claim_activation(&claim).await.unwrap();
9402        store
9403            .start_activation(&activation.fence(), chrono::Utc::now())
9404            .await
9405            .unwrap();
9406        let mut link = AcceptedWithoutReceipt { published: false };
9407        let mut send_invoked = false;
9408        let run = RunSpec {
9409            assignment: "task".into(),
9410            logical_session: None,
9411            project_id: None,
9412            reasoning_effort: None,
9413            permission_policy: None,
9414            messages: Vec::new(),
9415            activation_run_id: Some(claim.run_id.clone()),
9416            execution_epoch: 1,
9417            initial_session_messages: Vec::new(),
9418            secrets: Default::default(),
9419        };
9420        assert!(send_actor_run(&mut link, run, &mut send_invoked)
9421            .await
9422            .is_err());
9423        assert!(send_invoked && link.published);
9424        let slot = WorkerSlotLease {
9425            lease_id: "dispatch-slot".into(),
9426            host_ref: "dispatch-worker".into(),
9427            connection_generation: "dispatch-generation".into(),
9428            slot: 0,
9429            epoch: 1,
9430            actor_id: child.id.clone(),
9431            run_id: claim.run_id.clone(),
9432            expires_at: claim.lease_expires_at,
9433        };
9434        let mut lease = ActivePlacementLease {
9435            registry: bamboo_storage::v2::FileHostRegistry::new(temp.path().into())
9436                .await
9437                .unwrap(),
9438            actor_store: store.clone(),
9439            route: ScopedPlacementRoute {
9440                endpoint: String::new(),
9441                token: String::new(),
9442                ca_cert_file: PathBuf::new(),
9443                parent: bamboo_subagent::AgentRef {
9444                    session_id: "dispatch-parent".into(),
9445                    role: Some("host".into()),
9446                },
9447                worker_role: "worker".into(),
9448                pinned_mailbox: None,
9449                requirements: Default::default(),
9450            },
9451            mailbox: "dispatch-worker".into(),
9452            host_ref: "dispatch-worker".into(),
9453            fence: activation.fence(),
9454            placement_ref: bamboo_domain::ActorPlacementRef {
9455                class: bamboo_domain::ActorPlacementClass::Remote,
9456                lease_id: slot.lease_id.clone(),
9457                slot_epoch: Some(slot.epoch),
9458            },
9459            lease: Arc::new(tokio::sync::Mutex::new(slot)),
9460            heartbeat: tokio::spawn(std::future::pending()),
9461            run_cancel: CancellationToken::new(),
9462            lost: Arc::new(AtomicBool::new(false)),
9463            release_on_drop: AtomicBool::new(false),
9464        };
9465        assert!(!lease.abort_if_run_unsent(send_invoked).await.unwrap());
9466        assert_eq!(
9467            store
9468                .inspect_actor(&child.id)
9469                .await
9470                .unwrap()
9471                .activation
9472                .unwrap()
9473                .status,
9474            bamboo_domain::ActorActivationStatus::Running
9475        );
9476        let mut retry = claim.clone();
9477        retry.run_id = "dispatch-retry".into();
9478        retry.now = chrono::Utc::now();
9479        retry.lease_expires_at = retry.now + chrono::Duration::seconds(80);
9480        assert!(matches!(
9481            store.claim_activation(&retry).await,
9482            Err(bamboo_domain::ActorDirectoryError::Busy)
9483        ));
9484    }
9485
9486    #[test]
9487    fn accepted_cancelled_terminal_maps_to_cancelled_actor_activation() {
9488        let cancelled: Result<Option<String>, AgentError> = Err(AgentError::Cancelled);
9489        assert_eq!(
9490            remote_activation_finish(&cancelled, Some(TerminalStatus::Cancelled)),
9491            ActorActivationFinish::Cancelled
9492        );
9493        assert_eq!(
9494            remote_activation_finish(&cancelled, None),
9495            ActorActivationFinish::Failed
9496        );
9497        assert_eq!(
9498            remote_activation_finish(&cancelled, Some(TerminalStatus::Completed)),
9499            ActorActivationFinish::Failed
9500        );
9501    }
9502
9503    #[tokio::test]
9504    async fn remote_reply_is_fenced_before_terminal_status_save() {
9505        let temp = tempfile::tempdir().unwrap();
9506        let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
9507            .await
9508            .unwrap();
9509        let parent = Session::new("remote-reply-parent", "model");
9510        let mut child = Session::new_child_of("remote-reply-child", &parent, "model", "task");
9511        child.add_message(bamboo_agent_core::Message::user("task"));
9512        store.save_session(&parent).await.unwrap();
9513        store.save_session(&child).await.unwrap();
9514        store.ensure_actor(&child.id).await.unwrap();
9515        store
9516            .bind_control_plane(
9517                &child.id,
9518                bamboo_domain::ActorControlPlaneBinding {
9519                    policy_revision: None,
9520                    placement_intent: Some(bamboo_domain::ActorPlacementIntent::Pool {
9521                        pool_id: "remote-worker".into(),
9522                    }),
9523                },
9524            )
9525            .await
9526            .unwrap();
9527        let now = chrono::Utc::now();
9528        let activation = store
9529            .claim_activation(&ActorActivationClaim {
9530                actor_id: child.id.clone(),
9531                run_id: "remote-reply-run".into(),
9532                lease_owner: "remote-worker".into(),
9533                lease_expires_at: now + chrono::Duration::seconds(80),
9534                inbox_generation: 0,
9535                placement_ref: Some(bamboo_domain::ActorPlacementRef {
9536                    class: bamboo_domain::ActorPlacementClass::Remote,
9537                    lease_id: "remote-slot".into(),
9538                    slot_epoch: Some(1),
9539                }),
9540                now,
9541            })
9542            .await
9543            .unwrap();
9544        let fence = activation.fence();
9545        store.start_activation(&fence, now).await.unwrap();
9546
9547        append_fenced_remote_reply(&store, &fence, &mut child, "done")
9548            .await
9549            .unwrap();
9550        store
9551            .finish_activation(&fence, chrono::Utc::now(), ActorActivationFinish::Succeeded)
9552            .await
9553            .unwrap();
9554        child.set_last_run_status("completed");
9555        store.save_session(&child).await.unwrap();
9556        let saved = store.load_session(&child.id).await.unwrap().unwrap();
9557        assert_eq!(saved.last_run_status().as_deref(), Some("completed"));
9558        assert_eq!(saved.messages.len(), 2);
9559        assert_eq!(saved.messages.last().unwrap().content, "done");
9560        assert!(
9561            append_fenced_remote_reply(&store, &fence, &mut child, "duplicate")
9562                .await
9563                .is_err()
9564        );
9565        assert_eq!(
9566            store
9567                .load_session(&child.id)
9568                .await
9569                .unwrap()
9570                .unwrap()
9571                .messages
9572                .len(),
9573            2
9574        );
9575    }
9576
9577    async fn start_bus() -> (String, tempfile::TempDir) {
9578        let dir = tempfile::tempdir().unwrap();
9579        let core = Arc::new(bamboo_broker::BrokerCore::new(dir.path()));
9580        let server = Arc::new(bamboo_broker::BrokerServer::new(core, "t"));
9581        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9582        let addr = listener.local_addr().unwrap();
9583        tokio::spawn(async move {
9584            let _ = server.serve(listener).await;
9585        });
9586        (format!("ws://{addr}"), dir)
9587    }
9588
9589    struct DurableAckProbe {
9590        calls: Arc<AtomicUsize>,
9591        fail: bool,
9592        terminal_status: TerminalStatus,
9593        parent_mailbox: String,
9594    }
9595
9596    #[async_trait]
9597    impl bamboo_subagent::ChildLink for DurableAckProbe {
9598        async fn send(&mut self, _frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
9599            Ok(())
9600        }
9601
9602        async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
9603            Ok(None)
9604        }
9605
9606        fn has_pending_durable_terminal(&self) -> bool {
9607            true
9608        }
9609
9610        fn durable_delivery_receipt(&self) -> Option<bamboo_subagent::DurableChildDeliveryReceipt> {
9611            Some(bamboo_subagent::DurableChildDeliveryReceipt {
9612                broker_identity: "00000000-0000-4000-8000-000000000001".into(),
9613                parent_mailbox: self.parent_mailbox.clone(),
9614                correlation_id: "broker-run-1".into(),
9615                message_ids: vec!["broker-outcome-1".into()],
9616                terminal_status: self.terminal_status,
9617            })
9618        }
9619
9620        async fn acknowledge_durable_frames(&mut self) -> bamboo_subagent::TransportResult<()> {
9621            self.calls.fetch_add(1, Ordering::SeqCst);
9622            if self.fail {
9623                Err(bamboo_subagent::TransportError::Protocol(
9624                    "injected ACK delivery failure".into(),
9625                ))
9626            } else {
9627                Ok(())
9628            }
9629        }
9630    }
9631
9632    async fn insert_ack_probe(
9633        runner: &ActorChildRunner,
9634        session: &Session,
9635        calls: &Arc<AtomicUsize>,
9636        terminal_status: TerminalStatus,
9637    ) {
9638        insert_ack_probe_with_nested_wait(runner, session, calls, terminal_status, false).await;
9639    }
9640
9641    async fn insert_ack_probe_with_nested_wait(
9642        runner: &ActorChildRunner,
9643        session: &Session,
9644        calls: &Arc<AtomicUsize>,
9645        terminal_status: TerminalStatus,
9646        host_nested_wait_handoff: bool,
9647    ) {
9648        runner.pending_durable_links.lock().await.insert(
9649            (session.id.clone(), session.created_at, "run-1".into()),
9650            PendingDurableChildLink {
9651                link: Box::new(DurableAckProbe {
9652                    calls: calls.clone(),
9653                    fail: false,
9654                    terminal_status,
9655                    parent_mailbox: format!("p-{}", session.id),
9656                }),
9657                host_nested_wait_handoff,
9658                strict_history: None,
9659                strict_history_required: false,
9660                strict_history_committed: false,
9661            },
9662        );
9663    }
9664
9665    #[tokio::test]
9666    async fn broker_receipt_reconciler_repairs_only_the_proven_local_mailbox() {
9667        let (endpoint, broker_dir) = start_bus().await;
9668        let home = tempfile::tempdir().unwrap();
9669        let store = Arc::new(
9670            bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
9671                .await
9672                .unwrap(),
9673        );
9674        let root = Session::new("receipt-root", "model");
9675        store.save_session(&root).await.unwrap();
9676        let mut sender = bamboo_broker::BrokerClient::connect(
9677            &endpoint,
9678            bamboo_subagent::AgentRef {
9679                session_id: "receipt-worker".into(),
9680                role: None,
9681            },
9682            "t",
9683        )
9684        .await
9685        .unwrap();
9686        let broker_identity = sender.durable_broker_identity().await.unwrap();
9687        let wrong_broker_identity = if broker_identity == "00000000-0000-4000-8000-000000000001" {
9688            "00000000-0000-4000-8000-000000000002"
9689        } else {
9690            "00000000-0000-4000-8000-000000000001"
9691        };
9692
9693        for (id, receipt_mailbox, receipt_broker) in [
9694            ("receipt-good", "p-receipt-good", broker_identity.as_str()),
9695            (
9696                "receipt-wrong-mailbox",
9697                "p-another-child",
9698                broker_identity.as_str(),
9699            ),
9700            (
9701                "receipt-wrong-broker",
9702                "p-receipt-wrong-broker",
9703                wrong_broker_identity,
9704            ),
9705            (
9706                "receipt-local-role-changed",
9707                "p-receipt-local-role-changed",
9708                broker_identity.as_str(),
9709            ),
9710            (
9711                "receipt-remote-unproven",
9712                "remote-parent-unproven",
9713                broker_identity.as_str(),
9714            ),
9715        ] {
9716            let mut child = Session::new_child_of(id, &root, "model", "task");
9717            child
9718                .metadata
9719                .insert("runtime.kind".into(), "external".into());
9720            child
9721                .metadata
9722                .insert("external.protocol".into(), "actor".into());
9723            child.metadata.insert(
9724                "external.agent_id".into(),
9725                crate::external_agents::config::LOCAL_ACTOR_AGENT_ID.into(),
9726            );
9727            if id == "receipt-local-role-changed" || id == "receipt-remote-unproven" {
9728                child
9729                    .metadata
9730                    .insert("subagent_type".into(), "remote-worker".into());
9731            }
9732            child.add_message(bamboo_agent_core::Message::user("work"));
9733            store.save_session(&child).await.unwrap();
9734            let message_id = bamboo_subagent::MsgId::new();
9735            sender
9736                .deliver(
9737                    &format!("p-{id}"),
9738                    bamboo_subagent::InboxMessage {
9739                        id: message_id.clone(),
9740                        from: bamboo_subagent::AgentRef {
9741                            session_id: "receipt-worker".into(),
9742                            role: None,
9743                        },
9744                        kind: bamboo_subagent::InboxKind::Outcome,
9745                        body: serde_json::to_value(bamboo_subagent::ChildOutcome::completed(
9746                            "done",
9747                        ))
9748                        .unwrap(),
9749                        created_at: chrono::Utc::now(),
9750                        correlation_id: Some(bamboo_subagent::MsgId::new()),
9751                    },
9752                )
9753                .await
9754                .unwrap();
9755            // The Host can checkpoint only a frame already surfaced from the
9756            // broker, which moves its Maildir file from new/ into cur/.
9757            let mailbox =
9758                bamboo_subagent::Mailbox::at(broker_dir.path().join(format!("mailboxes/p-{id}")));
9759            assert_eq!(mailbox.drain().await.unwrap().len(), 1);
9760            child.add_message(bamboo_agent_core::Message::assistant("done", None));
9761            child.set_last_run_status("completed");
9762            if id == "receipt-good" {
9763                let prior_ids = child.messages[..child.messages.len() - 1]
9764                    .iter()
9765                    .map(|message| message.id.clone())
9766                    .collect();
9767                crate::execution::ChildCompletionSource::prepare(&mut child, "run-1", &prior_ids);
9768                assert!(
9769                    crate::execution::ChildCompletionSource::defer_for_durable_delivery(
9770                        &mut child, "run-1"
9771                    )
9772                    .unwrap()
9773                );
9774                let proof = bamboo_storage::v2::HostTerminalCompleteness::from_verified_host(
9775                    &child, "run-1", 7, 1,
9776                )
9777                .unwrap();
9778                store
9779                    .prepare_broker_terminal_receipt_with_completeness(
9780                        &child,
9781                        bamboo_storage::v2::BrokerTerminalRoute {
9782                            activation_run_id: "run-1",
9783                            broker_identity: receipt_broker,
9784                            parent_mailbox: receipt_mailbox,
9785                            broker_correlation_id: "correlation-1",
9786                            message_ids: &[message_id.as_str().to_owned()],
9787                        },
9788                        &proof,
9789                    )
9790                    .await
9791                    .unwrap();
9792                let mut staged = child.clone();
9793                staged.set_last_run_status("running");
9794                staged.clear_last_run_error();
9795                store.save_session(&staged).await.unwrap();
9796                store
9797                    .commit_broker_terminal_receipt_with_completeness(&child, "run-1", &proof)
9798                    .await
9799                    .unwrap();
9800            } else {
9801                store
9802                    .prepare_broker_terminal_receipt(
9803                        &child,
9804                        "run-1",
9805                        receipt_broker,
9806                        receipt_mailbox,
9807                        "correlation-1",
9808                        &[message_id.as_str().to_owned()],
9809                    )
9810                    .await
9811                    .unwrap();
9812                store.save_session(&child).await.unwrap();
9813            }
9814        }
9815
9816        let mut config = bamboo_config::Config::default();
9817        config.subagents_mut().broker = Some(bamboo_config::BrokerClientConfig {
9818            endpoint,
9819            token: "t".into(),
9820            ..Default::default()
9821        });
9822        config
9823            .subagents_mut()
9824            .remote_placements
9825            .push(bamboo_config::RemoteActorPlacement {
9826                role: "remote-worker".into(),
9827                endpoint: "ws://unscoped.example.invalid".into(),
9828                token_env: None,
9829                ca_cert_file: None,
9830                broker_peer: None,
9831                placement_requirements: None,
9832            });
9833        // Recover on an independently reopened Store, with no Actor executor or
9834        // provider available. Only the committed Host proof can authorize ACK.
9835        let cold_store = Arc::new(
9836            bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
9837                .await
9838                .unwrap(),
9839        );
9840        let before = cold_store
9841            .load_session("receipt-good")
9842            .await
9843            .unwrap()
9844            .unwrap();
9845        assert!(crate::execution::ChildCompletionSource::has_source_record(
9846            &before
9847        ));
9848        assert_eq!(before.last_run_status().as_deref(), Some("running"));
9849        assert!(crate::execution::ChildCompletionSource::from_committed_session(&before).is_none());
9850        let repair = BrokerTerminalReceiptReconciler::new(cold_store.clone(), &config);
9851        let report = repair.reconcile_once().await;
9852        assert_eq!(report.candidates, 5);
9853        assert_eq!(report.repaired, 2);
9854        assert_eq!(report.blocked, 3);
9855        let after = cold_store
9856            .load_session("receipt-good")
9857            .await
9858            .unwrap()
9859            .unwrap();
9860        assert!(crate::execution::ChildCompletionSource::from_committed_session(&after).is_some());
9861        assert_eq!(
9862            serde_json::to_value(before.messages).unwrap(),
9863            serde_json::to_value(after.messages).unwrap()
9864        );
9865        let retry = repair.reconcile_once().await;
9866        assert_eq!(retry.repaired, 0, "committed strict recovery is idempotent");
9867
9868        let good = bamboo_subagent::Mailbox::at(broker_dir.path().join("mailboxes/p-receipt-good"));
9869        let wrong = bamboo_subagent::Mailbox::at(
9870            broker_dir.path().join("mailboxes/p-receipt-wrong-mailbox"),
9871        );
9872        let wrong_broker = bamboo_subagent::Mailbox::at(
9873            broker_dir.path().join("mailboxes/p-receipt-wrong-broker"),
9874        );
9875        let local_role_changed = bamboo_subagent::Mailbox::at(
9876            broker_dir
9877                .path()
9878                .join("mailboxes/p-receipt-local-role-changed"),
9879        );
9880        let remote_unproven = bamboo_subagent::Mailbox::at(
9881            broker_dir
9882                .path()
9883                .join("mailboxes/p-receipt-remote-unproven"),
9884        );
9885        assert_eq!(good.pending_count().await.unwrap(), 0);
9886        assert_eq!(wrong.pending_count().await.unwrap(), 1);
9887        assert_eq!(wrong_broker.pending_count().await.unwrap(), 1);
9888        assert_eq!(local_role_changed.pending_count().await.unwrap(), 0);
9889        assert_eq!(remote_unproven.pending_count().await.unwrap(), 1);
9890        let good_child = store.load_session("receipt-good").await.unwrap().unwrap();
9891        let wrong_child = store
9892            .load_session("receipt-wrong-mailbox")
9893            .await
9894            .unwrap()
9895            .unwrap();
9896        let wrong_broker_child = store
9897            .load_session("receipt-wrong-broker")
9898            .await
9899            .unwrap()
9900            .unwrap();
9901        let local_role_changed_child = store
9902            .load_session("receipt-local-role-changed")
9903            .await
9904            .unwrap()
9905            .unwrap();
9906        let remote_unproven_child = store
9907            .load_session("receipt-remote-unproven")
9908            .await
9909            .unwrap()
9910            .unwrap();
9911        assert!(store
9912            .recover_broker_terminal_receipts(&good_child)
9913            .await
9914            .unwrap()
9915            .is_empty());
9916        assert_eq!(
9917            store
9918                .recover_broker_terminal_receipts(&wrong_child)
9919                .await
9920                .unwrap()
9921                .len(),
9922            1
9923        );
9924        assert_eq!(
9925            store
9926                .recover_broker_terminal_receipts(&wrong_broker_child)
9927                .await
9928                .unwrap()
9929                .len(),
9930            1
9931        );
9932        assert!(store
9933            .recover_broker_terminal_receipts(&local_role_changed_child)
9934            .await
9935            .unwrap()
9936            .is_empty());
9937        assert_eq!(
9938            store
9939                .recover_broker_terminal_receipts(&remote_unproven_child)
9940                .await
9941                .unwrap()
9942                .len(),
9943            1
9944        );
9945    }
9946
9947    #[tokio::test]
9948    async fn broker_receipt_confirmation_requires_exact_birth_run_and_committed_status() {
9949        let runner = ActorChildRunner::new(
9950            "test".into(),
9951            PathBuf::new(),
9952            vec![],
9953            PathBuf::new(),
9954            ExecutorSpec::BambooRuntime,
9955            vec![],
9956            "test".into(),
9957            1,
9958        );
9959        let home = tempfile::tempdir().unwrap();
9960        let store = Arc::new(
9961            bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
9962                .await
9963                .unwrap(),
9964        );
9965        let root = Session::new("ack-parent", "model");
9966        store.save_session(&root).await.unwrap();
9967        let mut child = Session::new_child_of("ack-child", &root, "model", "task");
9968        child.add_message(bamboo_agent_core::Message::user("work"));
9969        store.save_session(&child).await.unwrap();
9970        child.add_message(bamboo_agent_core::Message::assistant("done", None));
9971        child.set_last_run_status("completed");
9972        store
9973            .prepare_broker_terminal_receipt(
9974                &child,
9975                "run-1",
9976                "00000000-0000-4000-8000-000000000001",
9977                "p-ack-child",
9978                "broker-run-1",
9979                &["broker-outcome-1".into()],
9980            )
9981            .await
9982            .unwrap();
9983        store.save_session(&child).await.unwrap();
9984        runner.set_actor_directory_store(Some(store));
9985        let calls = Arc::new(AtomicUsize::new(0));
9986        insert_ack_probe(&runner, &child, &calls, TerminalStatus::Completed).await;
9987        runner
9988            .confirm_durable_child_delivery(&child, "wrong-run", true)
9989            .await
9990            .unwrap();
9991        let mut wrong_birth = child.clone();
9992        wrong_birth.created_at += chrono::Duration::seconds(1);
9993        runner
9994            .confirm_durable_child_delivery(&wrong_birth, "run-1", true)
9995            .await
9996            .unwrap();
9997        assert_eq!(runner.pending_durable_links.lock().await.len(), 1);
9998        child.set_last_run_status("completed");
9999        runner
10000            .confirm_durable_child_delivery(&child, "run-1", false)
10001            .await
10002            .unwrap();
10003        assert_eq!(calls.load(Ordering::SeqCst), 0);
10004
10005        insert_ack_probe(&runner, &child, &calls, TerminalStatus::Completed).await;
10006        child.set_last_run_status("error");
10007        runner
10008            .confirm_durable_child_delivery(&child, "run-1", true)
10009            .await
10010            .unwrap();
10011        assert_eq!(calls.load(Ordering::SeqCst), 0);
10012
10013        insert_ack_probe(&runner, &child, &calls, TerminalStatus::Completed).await;
10014        child.set_last_run_status("completed");
10015        runner
10016            .confirm_durable_child_delivery(&child, "run-1", true)
10017            .await
10018            .unwrap();
10019        assert_eq!(calls.load(Ordering::SeqCst), 1);
10020    }
10021
10022    #[tokio::test]
10023    async fn strict_completeness_gate_precedes_ack_and_ack_failure_preserves_cold_proof() {
10024        for commit in [false, true] {
10025            let runner = ActorChildRunner::new(
10026                "test".into(),
10027                PathBuf::new(),
10028                vec![],
10029                PathBuf::new(),
10030                ExecutorSpec::BambooRuntime,
10031                vec![],
10032                "test".into(),
10033                1,
10034            );
10035            let home = tempfile::tempdir().unwrap();
10036            let store = Arc::new(
10037                bamboo_storage::SessionStoreV2::new(home.path().into())
10038                    .await
10039                    .unwrap(),
10040            );
10041            let root = Session::new("strict-ack-parent", "model");
10042            store.save_session(&root).await.unwrap();
10043            let mut child = Session::new_child_of("strict-ack-child", &root, "model", "work");
10044            child.add_message(bamboo_agent_core::Message::user("work"));
10045            store.save_session(&child).await.unwrap();
10046            child.add_message(bamboo_agent_core::Message::assistant("finished", None));
10047            child.set_last_run_status("completed");
10048            let proof = bamboo_storage::v2::HostTerminalCompleteness::from_verified_host(
10049                &child, "run-1", 7, 2,
10050            )
10051            .unwrap();
10052            let creation = bamboo_subagent::proto::ChildCreationIdentity {
10053                created_at: child.created_at,
10054                spawn_depth: child.spawn_depth,
10055            };
10056            let history = StrictTerminalHistory {
10057                proof: proof.clone(),
10058                watermark: bamboo_subagent::ActorEventWatermark {
10059                    version: 1,
10060                    logical_session: Some(actor_event_logical_identity(
10061                        &child,
10062                        &root.id,
10063                        Some(&creation),
10064                    )),
10065                    activation_id: Some("run-1".into()),
10066                    execution_epoch: 7,
10067                    final_seq: 2,
10068                },
10069            };
10070            let parent_mailbox = "p-strict-ack-child";
10071            store
10072                .prepare_broker_terminal_receipt_with_completeness(
10073                    &child,
10074                    bamboo_storage::v2::BrokerTerminalRoute {
10075                        activation_run_id: "run-1",
10076                        broker_identity: "00000000-0000-4000-8000-000000000001",
10077                        parent_mailbox,
10078                        broker_correlation_id: "broker-run-1",
10079                        message_ids: &["broker-outcome-1".into()],
10080                    },
10081                    &proof,
10082                )
10083                .await
10084                .unwrap();
10085            store.save_session(&child).await.unwrap();
10086            runner.set_actor_directory_store(Some(store.clone()));
10087            let calls = Arc::new(AtomicUsize::new(0));
10088            runner.pending_durable_links.lock().await.insert(
10089                (child.id.clone(), child.created_at, "run-1".into()),
10090                PendingDurableChildLink {
10091                    link: Box::new(DurableAckProbe {
10092                        calls: calls.clone(),
10093                        fail: true,
10094                        terminal_status: TerminalStatus::Completed,
10095                        parent_mailbox: parent_mailbox.into(),
10096                    }),
10097                    host_nested_wait_handoff: false,
10098                    strict_history: Some(history),
10099                    strict_history_required: true,
10100                    strict_history_committed: false,
10101                },
10102            );
10103            if commit {
10104                runner
10105                    .commit_durable_child_delivery(&child, "run-1")
10106                    .await
10107                    .unwrap();
10108                assert_eq!(calls.load(Ordering::SeqCst), 0, "proof commit cannot ACK");
10109            }
10110            assert!(runner
10111                .confirm_durable_child_delivery(&child, "run-1", true)
10112                .await
10113                .is_err());
10114            assert_eq!(calls.load(Ordering::SeqCst), usize::from(commit));
10115            let cold = bamboo_storage::SessionStoreV2::new(home.path().into())
10116                .await
10117                .unwrap();
10118            let recovered = cold.recover_broker_terminal_receipts(&child).await;
10119            if commit {
10120                let receipts = recovered.unwrap();
10121                assert_eq!(receipts.len(), 1);
10122                assert_eq!(
10123                    receipts[0]
10124                        .terminal_completeness
10125                        .as_ref()
10126                        .unwrap()
10127                        .contiguous_applied_seq,
10128                    2
10129                );
10130                assert_eq!(
10131                    cold.load_session(&child.id)
10132                        .await
10133                        .unwrap()
10134                        .unwrap()
10135                        .last_run_status()
10136                        .as_deref(),
10137                    Some("completed")
10138                );
10139            } else {
10140                assert!(
10141                    recovered.is_err(),
10142                    "matching canonical history is not proof commitment"
10143                );
10144            }
10145            assert_eq!(
10146                serde_json::to_value(
10147                    cold.load_session(&child.id)
10148                        .await
10149                        .unwrap()
10150                        .unwrap()
10151                        .messages
10152                )
10153                .unwrap(),
10154                serde_json::to_value(&child.messages).unwrap(),
10155            );
10156        }
10157    }
10158
10159    #[tokio::test]
10160    async fn broker_receipt_confirms_canonical_worker_error_and_cancelled_status() {
10161        for (terminal_status, status) in [
10162            (TerminalStatus::Error, "error"),
10163            (TerminalStatus::Cancelled, "cancelled"),
10164        ] {
10165            let runner = ActorChildRunner::new(
10166                "test".into(),
10167                PathBuf::new(),
10168                vec![],
10169                PathBuf::new(),
10170                ExecutorSpec::BambooRuntime,
10171                vec![],
10172                "test".into(),
10173                1,
10174            );
10175            let home = tempfile::tempdir().unwrap();
10176            let store = Arc::new(
10177                bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
10178                    .await
10179                    .unwrap(),
10180            );
10181            let root = Session::new("failed-ack-parent", "model");
10182            store.save_session(&root).await.unwrap();
10183            let mut child = Session::new_child_of("failed-ack-child", &root, "model", "task");
10184            child.add_message(bamboo_agent_core::Message::user("work"));
10185            store.save_session(&child).await.unwrap();
10186            child.set_last_run_status(status);
10187            child.set_last_run_error(format!("worker {status}"));
10188            store
10189                .prepare_broker_terminal_receipt(
10190                    &child,
10191                    "run-1",
10192                    "00000000-0000-4000-8000-000000000001",
10193                    "p-failed-ack-child",
10194                    "broker-run-1",
10195                    &["broker-outcome-1".into()],
10196                )
10197                .await
10198                .unwrap();
10199            store.save_session(&child).await.unwrap();
10200            runner.set_actor_directory_store(Some(store));
10201            let calls = Arc::new(AtomicUsize::new(0));
10202            insert_ack_probe(&runner, &child, &calls, terminal_status).await;
10203            runner
10204                .confirm_durable_child_delivery(&child, "run-1", true)
10205                .await
10206                .unwrap();
10207            assert_eq!(calls.load(Ordering::SeqCst), 1, "{status}");
10208        }
10209    }
10210
10211    #[tokio::test]
10212    async fn broker_completed_terminal_projects_only_host_verified_nested_wait() {
10213        assert_eq!(
10214            effective_broker_terminal_status(TerminalStatus::Completed, true).unwrap(),
10215            "suspended"
10216        );
10217        for raw in [TerminalStatus::Error, TerminalStatus::Cancelled] {
10218            assert!(effective_broker_terminal_status(raw, true).is_err());
10219        }
10220        let runner = ActorChildRunner::new(
10221            "test".into(),
10222            PathBuf::new(),
10223            vec![],
10224            PathBuf::new(),
10225            ExecutorSpec::BambooRuntime,
10226            vec![],
10227            "test".into(),
10228            1,
10229        );
10230        let home = tempfile::tempdir().unwrap();
10231        let store = Arc::new(
10232            bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
10233                .await
10234                .unwrap(),
10235        );
10236        let root = Session::new("nested-ack-parent", "model");
10237        store.save_session(&root).await.unwrap();
10238        let mut child = Session::new_child_of("nested-ack-child", &root, "model", "task");
10239        child.add_message(bamboo_agent_core::Message::user("spawn a grandchild"));
10240        store.save_session(&child).await.unwrap();
10241        child.set_last_run_status("suspended");
10242        store
10243            .prepare_broker_terminal_receipt(
10244                &child,
10245                "run-1",
10246                "00000000-0000-4000-8000-000000000001",
10247                "p-nested-ack-child",
10248                "broker-run-1",
10249                &["broker-outcome-1".into()],
10250            )
10251            .await
10252            .unwrap();
10253        store.save_session(&child).await.unwrap();
10254        runner.set_actor_directory_store(Some(store.clone()));
10255        let calls = Arc::new(AtomicUsize::new(0));
10256
10257        insert_ack_probe(&runner, &child, &calls, TerminalStatus::Completed).await;
10258        runner
10259            .confirm_durable_child_delivery(&child, "run-1", true)
10260            .await
10261            .unwrap();
10262        assert_eq!(calls.load(Ordering::SeqCst), 0);
10263
10264        insert_ack_probe_with_nested_wait(&runner, &child, &calls, TerminalStatus::Completed, true)
10265            .await;
10266        runner
10267            .confirm_durable_child_delivery(&child, "run-1", true)
10268            .await
10269            .unwrap();
10270        assert_eq!(calls.load(Ordering::SeqCst), 1);
10271        assert_eq!(
10272            store
10273                .load_session(&child.id)
10274                .await
10275                .unwrap()
10276                .unwrap()
10277                .last_run_status()
10278                .as_deref(),
10279            Some("suspended")
10280        );
10281        assert!(store
10282            .recover_broker_terminal_receipts(&child)
10283            .await
10284            .unwrap()
10285            .is_empty());
10286    }
10287
10288    #[tokio::test]
10289    async fn host_completion_notifies_once_only_after_reply_and_finish_commit() {
10290        struct CommittedObserver {
10291            authority: PathBuf,
10292            events: std::sync::Mutex<Vec<PublicActorEvent>>,
10293        }
10294        impl ActorEventObserver for CommittedObserver {
10295            fn publish(&self, event: PublicActorEvent) {
10296                // Observe the real durable sidecar synchronously at publication.
10297                let entry: bamboo_domain::ActorDirectoryEntry =
10298                    serde_json::from_slice(&std::fs::read(&self.authority).unwrap()).unwrap();
10299                let committed = entry.activation.unwrap();
10300                assert_eq!(
10301                    committed.status,
10302                    bamboo_domain::ActorActivationStatus::Succeeded
10303                );
10304                assert_eq!(committed.activation_id, event.activation_id);
10305                assert!(committed.finished_at.is_some());
10306                self.events.lock().unwrap().push(event);
10307            }
10308        }
10309        struct CompletionLink(VecDeque<ChildFrame>);
10310        #[async_trait]
10311        impl bamboo_subagent::ChildLink for CompletionLink {
10312            async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
10313                Ok(())
10314            }
10315            async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
10316                Ok(self.0.pop_front())
10317            }
10318        }
10319        for cut in [
10320            "success",
10321            "no_terminal",
10322            "invalid_terminal",
10323            "stale",
10324            "storage_failure",
10325            "append_failure",
10326        ] {
10327            let temp = tempfile::tempdir().unwrap();
10328            let store = Arc::new(
10329                bamboo_storage::SessionStoreV2::new(temp.path().into())
10330                    .await
10331                    .unwrap(),
10332            );
10333            let root = Session::new("notify-root", "model");
10334            store.save_session(&root).await.unwrap();
10335            let mut child = Session::new_child_of("notify-child", &root, "model", "assignment");
10336            store.save_session(&child).await.unwrap();
10337            let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
10338                store.clone(),
10339                bamboo_domain::SessionInboxLimits::default(),
10340            ));
10341            let binding = actor_binding(
10342                store.clone(),
10343                inbox,
10344                Arc::new(bamboo_storage::LockedSessionStore::new(store.clone())),
10345            );
10346            let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
10347            bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
10348            let activation = PlainActorActivation::start(
10349                store.clone(),
10350                &child,
10351                &binding,
10352                Some("notify-run"),
10353                Some(policy),
10354                false,
10355                None,
10356            )
10357            .await
10358            .unwrap();
10359            let _owner = binding
10360                .router
10361                .register_run(&child.id, "notify-run")
10362                .await
10363                .unwrap();
10364            let batch = ActorEventBatch {
10365                logical_session: Some(LogicalSessionIdentity {
10366                    session_id: child.id.clone(),
10367                    parent_session_id: child.parent_session_id.clone(),
10368                    root_session_id: child.root_session_id.clone(),
10369                    creation: None,
10370                }),
10371                activation_id: Some("notify-run".into()),
10372                execution_epoch: 9,
10373                source_node_id: None,
10374                source_actor_id: Some("notify-worker".into()),
10375                first_seq: 1,
10376                last_seq: 2,
10377                qos: bamboo_subagent::ActorEventQos::Durable,
10378                events: vec![
10379                    serde_json::to_value(AgentEvent::Token {
10380                        content: "PRIVATE_PROVIDER_TOKEN".into(),
10381                    })
10382                    .unwrap(),
10383                    serde_json::to_value(AgentEvent::message_appended(
10384                        &child.id,
10385                        &bamboo_agent_core::Message::assistant("PRIVATE_WORKER_CACHE", None),
10386                    ))
10387                    .unwrap(),
10388                ],
10389            };
10390            let mut frames = VecDeque::from([ChildFrame::EventBatch { batch }]);
10391            if cut != "no_terminal" {
10392                let mut terminal = completed_actor_frame();
10393                if cut == "invalid_terminal" {
10394                    if let ChildFrame::Terminal { status, .. } = &mut terminal {
10395                        *status = TerminalStatus::Suspended;
10396                    }
10397                }
10398                frames.push_back(terminal);
10399            }
10400            let mut link = CompletionLink(frames);
10401            let (tx, mut rx) = mpsc::channel(8);
10402            let (_live, mut live_rx) = mpsc::unbounded_channel();
10403            let (_delivery, mut delivery_rx) = mpsc::unbounded_channel();
10404            let cancel = CancellationToken::new();
10405            let outcome = drive(ActorDriveContext {
10406                client: &mut link,
10407                parent_session_id: "notify-root",
10408                child_session_id: "notify-child",
10409                child_attempt: 0,
10410                approval_registry: None,
10411                approval_decider: None,
10412                approval_reviewer: None,
10413                escalation_bridge: None,
10414                event_tx: &tx,
10415                cancel_token: &cancel,
10416                live_rx: &mut live_rx,
10417                delivery_rx: &mut delivery_rx,
10418                logical_session: &mut child,
10419                expected_permission_posture: None,
10420                expected_creation: None,
10421                session_inbox_runtime: Some(&binding),
10422                actor_directory_store: Some(store.as_ref()),
10423                canonical_subagent_tool: None,
10424                ticket_service: None,
10425                activation_run_id: Some("notify-run"),
10426                execution_epoch: 9,
10427                expected_source_actor_id: "notify-worker",
10428                initial_inflight_claims: VecDeque::new(),
10429                plain_actor: true,
10430                remote_environment_lease: false,
10431                readonly_output: None,
10432                local_history_tools: None,
10433                local_history_read_only: false,
10434                strict_history_output: None,
10435                plain_input: Some(&activation),
10436                canonical_activation: None,
10437                canonical_placement_ref: None,
10438                actor_event_observer: None,
10439                plain_run: None,
10440                first_frame_timeout: Some(Duration::from_secs(1)),
10441            })
10442            .await;
10443            let admitted = !matches!(cut, "no_terminal" | "invalid_terminal");
10444            assert_eq!(outcome.is_ok(), admitted, "{cut}: {outcome:?}");
10445            assert_eq!(
10446                activation.completion_router.lock().unwrap().is_some(),
10447                admitted,
10448                "{cut}"
10449            );
10450            while rx.try_recv().is_ok() {}
10451            let authority = temp
10452                .path()
10453                .join(store.resolve_rel_path(&child.id).await.unwrap())
10454                .join("actor-authority.json");
10455            let observer = CommittedObserver {
10456                authority: authority.clone(),
10457                events: Default::default(),
10458            };
10459            if cut == "append_failure" {
10460                child.add_message(bamboo_agent_core::Message::user("uncommitted-prefix"));
10461                assert!(activation
10462                    .append_reply(&mut child, "PRIVATE_REPLY".into(), &tx)
10463                    .await
10464                    .is_err());
10465                activation
10466                    .finish(ActorActivationFinish::Failed)
10467                    .await
10468                    .unwrap();
10469                assert!(observer.events.lock().unwrap().is_empty());
10470                continue;
10471            }
10472            activation
10473                .append_reply(&mut child, "PRIVATE_REPLY".into(), &tx)
10474                .await
10475                .unwrap();
10476            assert!(
10477                rx.try_recv().is_ok(),
10478                "legacy content notification retained"
10479            );
10480            assert!(
10481                observer.events.lock().unwrap().is_empty(),
10482                "reply alone must not publish"
10483            );
10484            if cut == "stale" {
10485                store
10486                    .retire_actor(&child.id, chrono::Utc::now())
10487                    .await
10488                    .unwrap();
10489            } else if cut == "storage_failure" {
10490                // Make the canonical write path unreadable, rather than mocking
10491                // the store or allowing completion to fall back to ordinary save.
10492                std::fs::remove_file(&authority).unwrap();
10493                std::fs::create_dir(&authority).unwrap();
10494            }
10495            let result = activation
10496                .finish_with_observer(ActorActivationFinish::Succeeded, Some(&observer))
10497                .await;
10498            assert_eq!(
10499                result.is_ok(),
10500                matches!(cut, "success" | "no_terminal" | "invalid_terminal"),
10501                "{cut}"
10502            );
10503            let events = observer.events.lock().unwrap().clone();
10504            assert_eq!(events.len(), usize::from(cut == "success"), "{cut}");
10505            if let Some(event) = events.first() {
10506                assert_eq!(event.actor_id, child.id);
10507                assert_eq!(event.root_actor_id, root.id);
10508                assert_eq!(event.parent_actor_id, Some(root.id.clone()));
10509                assert_eq!(event.source_order.sequence, 3);
10510                let json = serde_json::to_string(event).unwrap();
10511                assert!(!json.contains("PRIVATE_") && !json.contains("notify-run"));
10512                let persisted = store.load_session(&child.id).await.unwrap().unwrap();
10513                assert_eq!(persisted.messages.last().unwrap().content, "PRIVATE_REPLY");
10514            }
10515            assert!(activation
10516                .finish_with_observer(ActorActivationFinish::Succeeded, Some(&observer))
10517                .await
10518                .is_err());
10519            assert_eq!(
10520                observer.events.lock().unwrap().len(),
10521                events.len(),
10522                "repeat finish cannot publish"
10523            );
10524        }
10525    }
10526
10527    async fn bind_local_control_plane(
10528        store: &bamboo_storage::SessionStoreV2,
10529        actor_id: &str,
10530        policy: &bamboo_tools::permission::PermissionConfig,
10531    ) {
10532        store
10533            .bind_control_plane(
10534                actor_id,
10535                bamboo_domain::ActorControlPlaneBinding {
10536                    policy_revision: Some(policy.policy_revision()),
10537                    placement_intent: Some(bamboo_domain::ActorPlacementIntent::Local),
10538                },
10539            )
10540            .await
10541            .unwrap();
10542    }
10543
10544    fn local_tool_sample(
10545        host: &Session,
10546        denied_write: bool,
10547    ) -> (LocalToolCollector, Vec<bamboo_agent_core::Message>) {
10548        local_tool_sample_with_reasoning(host, denied_write, None)
10549    }
10550
10551    fn local_tool_sample_with_reasoning(
10552        host: &Session,
10553        denied_write: bool,
10554        reasoning: Option<&str>,
10555    ) -> (LocalToolCollector, Vec<bamboo_agent_core::Message>) {
10556        use bamboo_agent_core::{FunctionCall, Message, ToolCall};
10557        let calls = ["Read", "Write"]
10558            .map(|name| ToolCall {
10559                id: format!("local-{name}"),
10560                tool_type: "function".into(),
10561                function: FunctionCall {
10562                    name: name.into(),
10563                    arguments: "{\"file_path\":\"answer.txt\"}".into(),
10564                },
10565            })
10566            .to_vec();
10567        let mut rows = vec![Message::assistant("checking", Some(calls))];
10568        rows[0].reasoning = reasoning.map(str::to_owned);
10569        let mut collector = LocalToolCollector::default();
10570        if let Some(reasoning) = reasoning {
10571            assert!(collector
10572                .event(
10573                    &serde_json::to_value(AgentEvent::ReasoningToken {
10574                        content: reasoning.into(),
10575                    })
10576                    .unwrap()
10577                )
10578                .unwrap());
10579        }
10580        for name in ["Read", "Write"] {
10581            let denied = name == "Write" && denied_write;
10582            let id = format!("local-{name}");
10583            if !denied {
10584                collector
10585                    .event(
10586                        &serde_json::to_value(AgentEvent::ToolStart {
10587                            tool_call_id: id.clone(),
10588                            tool_name: name.into(),
10589                            arguments: serde_json::json!({"file_path":"answer.txt"}),
10590                        })
10591                        .unwrap(),
10592                    )
10593                    .unwrap();
10594            }
10595            let text = if denied {
10596                "Error: native_tool_ceiling_denied: Write"
10597            } else {
10598                "actual file result"
10599            };
10600            let mut message = Message::tool_result_with_status(id.clone(), text, !denied);
10601            message.metadata = Some(serde_json::json!({"elapsed_ms":7,"tool_name":name,
10602                "is_mutating":name == "Write","auto_approved":name != "Write","success":!denied}));
10603            rows.push(message);
10604            let event = if denied {
10605                AgentEvent::ToolError {
10606                    tool_call_id: id,
10607                    error: "native_tool_ceiling_denied: Write".into(),
10608                }
10609            } else {
10610                AgentEvent::ToolComplete {
10611                    tool_call_id: id,
10612                    result: bamboo_agent_core::tools::ToolResult::text(true, text),
10613                }
10614            };
10615            collector
10616                .event(&serde_json::to_value(event).unwrap())
10617                .unwrap();
10618        }
10619        rows.push(Message::assistant("complete exact report", None));
10620        rows.last_mut().unwrap().reasoning = reasoning.map(str::to_owned);
10621        if let Some(reasoning) = reasoning {
10622            assert!(collector
10623                .event(
10624                    &serde_json::to_value(AgentEvent::ReasoningToken {
10625                        content: reasoning.into(),
10626                    })
10627                    .unwrap()
10628                )
10629                .unwrap());
10630        }
10631        let mut messages = host.messages.clone();
10632        if let Some(system) = messages
10633            .iter_mut()
10634            .find(|message| message.role == Role::System)
10635        {
10636            system.content = "worker-private system never imported".into();
10637        }
10638        messages.extend(rows.iter().cloned());
10639        let data = bamboo_subagent::proto::LocalToolMessages::Complete {
10640            version: 1,
10641            messages: messages
10642                .iter()
10643                .map(|message| serde_json::to_value(message).unwrap())
10644                .collect(),
10645        };
10646        assert!(!collector
10647            .event(&serde_json::to_value(data).unwrap())
10648            .unwrap());
10649        (collector, rows)
10650    }
10651
10652    #[test]
10653    fn ticket_task_event_requires_exact_host_callback_receipt() {
10654        let mut collector = LocalToolCollector::default();
10655        let args = serde_json::json!({"tasks":[{"content":"own step","status":"pending"}]});
10656        let start = serde_json::to_value(AgentEvent::ToolStart {
10657            tool_call_id: "task-call".into(),
10658            tool_name: "Task".into(),
10659            arguments: args.clone(),
10660        })
10661        .unwrap();
10662        let mut receipts = HashMap::new();
10663        assert!(collector.ticket_event(&start, &receipts).unwrap());
10664        collector.event(&start).unwrap();
10665        let complete = serde_json::to_value(AgentEvent::ToolComplete {
10666            tool_call_id: "task-call".into(),
10667            result: bamboo_agent_core::tools::ToolResult::text(true, "done"),
10668        })
10669        .unwrap();
10670        assert!(collector.ticket_event(&complete, &receipts).is_err());
10671        receipts.insert("other-call".into(), args.clone());
10672        assert!(collector.ticket_event(&complete, &receipts).is_err());
10673        receipts.insert("task-call".into(), serde_json::json!({"tasks":[]}));
10674        assert!(collector.ticket_event(&complete, &receipts).is_err());
10675        receipts.insert("task-call".into(), args);
10676        assert!(collector.ticket_event(&complete, &receipts).unwrap());
10677        let forged_root = serde_json::to_value(AgentEvent::TaskListUpdated {
10678            task_list: bamboo_tools::TaskTool::task_list_from_args(
10679                &serde_json::json!({"tasks":[{"content":"forged root","status":"completed"}]}),
10680                "root",
10681            )
10682            .unwrap(),
10683            version: Some(999),
10684        })
10685        .unwrap();
10686        assert!(!collector.ticket_event(&forged_root, &receipts).unwrap());
10687    }
10688
10689    #[test]
10690    fn ticket_question_terminal_requires_exact_host_receipt_and_tool_result() {
10691        use bamboo_agent_core::{FunctionCall, Message, ToolCall};
10692        let mut host = Session::new("question-child", "model");
10693        host.messages.push(Message::user("own assignment"));
10694        let question: bamboo_tickets::PendingRequest = serde_json::from_value(serde_json::json!({
10695            "id":"own-question", "work_id":"own-work", "assignment_id":"own-assignment",
10696            "generation":1, "contract_revision":1, "prompt_revision":1,
10697            "kind":{"kind":"question"}, "prompt":"精确问题", "status":"open",
10698            "answer":null, "consumed_attempt":null, "worker_packet_hash":"a".repeat(64), "updated_seq":3
10699        })).unwrap();
10700        let args = serde_json::json!({"tasks":[{"content":"own step","status":"blocked"}], "question":{"prompt":"精确问题"}});
10701        for case in ["exact", "no_host_receipt", "forged_request", "fake_report"] {
10702            let mut returned =
10703                serde_json::json!({"status":"waiting_for_answer", "request":question});
10704            if case == "forged_request" {
10705                returned["request"]["id"] = serde_json::json!("sibling-question");
10706            }
10707            let text = returned.to_string();
10708            let rows = [
10709                Message::assistant(
10710                    "",
10711                    Some(vec![ToolCall {
10712                        id: "own-call".into(),
10713                        tool_type: "function".into(),
10714                        function: FunctionCall {
10715                            name: "Task".into(),
10716                            arguments: args.to_string(),
10717                        },
10718                    }]),
10719                ),
10720                Message::tool_result_with_status("own-call", &text, true),
10721            ];
10722            let collector = LocalToolCollector {
10723                messages: Some(host.messages.iter().cloned().chain(rows).collect()),
10724                starts: HashMap::from([("own-call".into(), ("Task".into(), args.clone()))]),
10725                outcomes: HashMap::from([("own-call".into(), (true, text))]),
10726                ticket_yield: (case != "no_host_receipt")
10727                    .then(|| ("own-call".into(), question.clone())),
10728            };
10729            let terminal = if case == "fake_report" {
10730                Some("fake delivery")
10731            } else {
10732                Some("")
10733            };
10734            assert_eq!(
10735                collector
10736                    .suffix(&host, &["Task".into()], false, terminal)
10737                    .is_ok(),
10738                case == "exact",
10739                "{case}"
10740            );
10741        }
10742    }
10743
10744    #[test]
10745    fn local_tool_suffix_preserves_full_current_prefix_and_rejects_authority_or_collisions() {
10746        let mut host = Session::new("tool-history-prefix", "model");
10747        host.messages
10748            .push(bamboo_agent_core::Message::user("assignment"));
10749        let mut confirmed = bamboo_agent_core::Message::user("confirmed during Run");
10750        confirmed.metadata = Some(
10751            serde_json::json!({"session_message":{"id":confirmed.id,"target_session_id":host.id}}),
10752        );
10753        host.messages.push(confirmed);
10754        let tools = vec!["Read".into(), "Write".into()];
10755        let (good, rows) = local_tool_sample(&host, false);
10756        assert_eq!(
10757            serde_json::to_value(
10758                good.suffix(&host, &tools, false, Some("complete exact report"))
10759                    .unwrap()
10760            )
10761            .unwrap(),
10762            serde_json::to_value(&rows).unwrap()
10763        );
10764        for case in [
10765            "missing",
10766            "changed",
10767            "metadata",
10768            "system",
10769            "user",
10770            "collision",
10771            "dangling",
10772            "result",
10773            "terminal",
10774            "tool_metadata",
10775            "grant",
10776            "start_arguments",
10777            "ledger",
10778        ] {
10779            let (mut collector, _) = local_tool_sample(&host, false);
10780            let mut current = host.clone();
10781            let messages = collector.messages.as_mut().unwrap();
10782            match case {
10783                "missing" => {
10784                    messages.remove(host.messages.len() - 1);
10785                }
10786                "changed" => messages[0].content.push('!'),
10787                "metadata" => messages[1].metadata = None,
10788                "system" => messages.last_mut().unwrap().role = Role::System,
10789                "user" => messages.last_mut().unwrap().role = Role::User,
10790                "collision" => messages.last_mut().unwrap().id = host.messages[0].id.clone(),
10791                "dangling" => {
10792                    messages[host.messages.len() + 1].tool_call_id = Some("foreign".into())
10793                }
10794                "result" => messages[host.messages.len() + 1].content.push('!'),
10795                "tool_metadata" => {
10796                    messages[host.messages.len() + 1].metadata.as_mut().unwrap()["permission"] =
10797                        true.into()
10798                }
10799                "grant" => {
10800                    messages[host.messages.len()].tool_calls.as_mut().unwrap()[0]
10801                        .function
10802                        .name = "Bash".into()
10803                }
10804                "start_arguments" => {
10805                    collector.starts.get_mut("local-Read").unwrap().1 =
10806                        serde_json::json!({"file_path":"different.txt"})
10807                }
10808                "ledger" => {
10809                    current.append_provider_transcript_group(current.messages[0].id.clone(), None, vec![
10810                        bamboo_domain::session::provider_transcript::ProviderTranscriptItem::try_from_payload(
10811                            bamboo_domain::ProviderFamily::OpenAi,
10812                            bamboo_domain::ProviderProtocol::OpenAiResponsesV1,
10813                            bamboo_domain::session::provider_transcript::ProviderTranscriptOrigin::HostToolSearch,
10814                            bamboo_domain::session::provider_transcript::ProviderTranscriptAuthor::ToolResult,
10815                            serde_json::json!({"type":"tool_search_output","execution":"client","call_id":"search","status":"completed","tools":[]}),
10816                        ).unwrap(),
10817                    ]).unwrap();
10818                }
10819                "terminal" => {}
10820                _ => unreachable!(),
10821            }
10822            let terminal = if case == "terminal" {
10823                "different"
10824            } else {
10825                "complete exact report"
10826            };
10827            assert!(
10828                collector
10829                    .suffix(&current, &tools, false, Some(terminal))
10830                    .is_err(),
10831                "{case}"
10832            );
10833        }
10834        let (denied, _) = local_tool_sample(&host, true);
10835        assert!(denied
10836            .suffix(&host, &["Read".into()], true, Some("complete exact report"))
10837            .is_ok());
10838        let (mut contradicted, _) = local_tool_sample(&host, true);
10839        contradicted.starts.insert(
10840            "local-Write".into(),
10841            ("Read".into(), serde_json::json!({"file_path":"answer.txt"})),
10842        );
10843        assert!(contradicted
10844            .suffix(&host, &["Read".into()], true, Some("complete exact report"))
10845            .is_err());
10846        let (successful, _) = local_tool_sample(&host, false);
10847        assert!(successful
10848            .suffix(&host, &["Read".into()], true, Some("complete exact report"))
10849            .is_err());
10850        let mut empty = LocalToolCollector::default();
10851        assert!(empty
10852            .event(
10853                &serde_json::to_value(AgentEvent::ReasoningToken {
10854                    content: "private".into()
10855                })
10856                .unwrap()
10857            )
10858            .unwrap());
10859        assert!(
10860            empty
10861                .suffix(&host, &tools, false, Some("complete exact report"))
10862                .is_err(),
10863            "stream observations alone cannot commit history"
10864        );
10865        let (mut completed, _) = local_tool_sample(&host, false);
10866        assert!(completed.event(&serde_json::json!({"type":"local_client_tool_messages_v1","version":1,"messages":[]})).is_err());
10867    }
10868
10869    struct LocalToolConcurrentCheckpoint {
10870        real: SessionInboxRuntimeBinding,
10871        claim: SessionInboxClaim,
10872    }
10873    #[async_trait]
10874    impl RuntimeSessionPersistence for LocalToolConcurrentCheckpoint {
10875        async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
10876            self.real.persistence.save_runtime_session(session).await
10877        }
10878        async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
10879            // A real canonical admission wins after validation, before the
10880            // final append-safe checkpoint acquires its serialization lock.
10881            let mut latest = self.real.storage.load_session(&session.id).await?.unwrap();
10882            checkpoint_and_ack_canonical_claim(&self.real, &mut latest, &self.claim, None)
10883                .await
10884                .map_err(|_| std::io::Error::other("concurrent admission failed"))?;
10885            self.real
10886                .persistence
10887                .checkpoint_runtime_session(session)
10888                .await
10889        }
10890        async fn load_runtime_session(&self, id: &str) -> std::io::Result<Option<Session>> {
10891            self.real.storage.load_session(id).await
10892        }
10893    }
10894
10895    #[tokio::test]
10896    async fn local_tool_history_checkpoint_preserves_confirmed_input_and_cold_pairs_or_refuses() {
10897        for case in [
10898            "success",
10899            "reasoning",
10900            "concurrent_input",
10901            "checkpoint_error",
10902            "stale_prefix",
10903            "cancelled",
10904            "lost_owner",
10905        ] {
10906            let temp = tempfile::tempdir().unwrap();
10907            let store = Arc::new(
10908                bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
10909                    .await
10910                    .unwrap(),
10911            );
10912            let root = Session::new("local-tool-root", "model");
10913            store.save_session(&root).await.unwrap();
10914            let mut child = Session::new_child("local-tool-child", &root.id, "model", "child");
10915            let mut system = bamboo_agent_core::Message::user("Host-owned immutable System");
10916            system.role = Role::System;
10917            child.messages.push(system);
10918            child
10919                .messages
10920                .push(bamboo_agent_core::Message::user("assignment"));
10921            store.save_session(&child).await.unwrap();
10922            let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
10923                store.clone(),
10924                bamboo_domain::SessionInboxLimits::default(),
10925            ));
10926            let locked = Arc::new(bamboo_storage::LockedSessionStore::new(store.clone()));
10927            let binding = actor_binding(store.clone(), inbox.clone(), locked.clone());
10928            let mut owner = Some(
10929                binding
10930                    .router
10931                    .register_run(&child.id, "current-run")
10932                    .await
10933                    .unwrap(),
10934            );
10935            let envelope = bamboo_domain::SessionMessageEnvelope::user_input(
10936                &child.id,
10937                "current confirmed User",
10938            );
10939            let receipt = inbox.deliver(&envelope).await.unwrap();
10940            inbox
10941                .mark_activation_eligible(
10942                    &child.id,
10943                    receipt.generation,
10944                    bamboo_domain::SessionActivationPolicy::RespectSpecificWait,
10945                )
10946                .await
10947                .unwrap();
10948            let claim = inbox.claim(&child.id, 1).await.unwrap().remove(0);
10949            checkpoint_and_ack_canonical_claim(&binding, &mut child, &claim, None)
10950                .await
10951                .unwrap();
10952            assert!(inbox.was_admitted(&child.id, &envelope.id).await.unwrap());
10953            let before = serde_json::to_value(&child).unwrap();
10954            let reasoning = (case == "reasoning")
10955                .then_some("Complete plain reasoning before Read and the final reply 🪷.");
10956            let (collector, rows) = local_tool_sample_with_reasoning(&child, false, reasoning);
10957            let mut binding = binding;
10958            let late = bamboo_domain::SessionMessageEnvelope::user_input(
10959                &child.id,
10960                "concurrent confirmed User",
10961            );
10962            if case == "concurrent_input" {
10963                let receipt = inbox.deliver(&late).await.unwrap();
10964                inbox
10965                    .mark_activation_eligible(
10966                        &child.id,
10967                        receipt.generation,
10968                        bamboo_domain::SessionActivationPolicy::RespectSpecificWait,
10969                    )
10970                    .await
10971                    .unwrap();
10972                let late_claim = inbox.claim(&child.id, 1).await.unwrap().remove(0);
10973                binding.persistence = Arc::new(LocalToolConcurrentCheckpoint {
10974                    real: actor_binding(store.clone(), inbox.clone(), locked.clone()),
10975                    claim: late_claim,
10976                });
10977            }
10978            if case == "checkpoint_error" {
10979                binding.persistence = Arc::new(ActorFaultingPersistence {
10980                    inner: locked.clone(),
10981                    fail_checkpoint_once: AtomicBool::new(true),
10982                });
10983            }
10984            if case == "stale_prefix" {
10985                let mut latest = child.clone();
10986                latest
10987                    .messages
10988                    .push(bamboo_agent_core::Message::user("new confirmed prefix"));
10989                store.save_session(&latest).await.unwrap();
10990            }
10991            let cancel = CancellationToken::new();
10992            if case == "cancelled" {
10993                cancel.cancel();
10994            }
10995            if case == "lost_owner" {
10996                owner.take().unwrap().abandon().await;
10997            }
10998            let durable_before =
10999                serde_json::to_value(store.load_session(&child.id).await.unwrap()).unwrap();
11000            let result = commit_local_tool_history(
11001                collector,
11002                &mut child,
11003                &binding,
11004                "current-run",
11005                (&["Read".into(), "Write".into()], false),
11006                LocalToolTerminal::Completed(Some("complete exact report")),
11007                &cancel,
11008            )
11009            .await;
11010            let cold = bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
11011                .await
11012                .unwrap()
11013                .load_session(&child.id)
11014                .await
11015                .unwrap()
11016                .unwrap();
11017            if !matches!(case, "success" | "reasoning" | "concurrent_input") {
11018                assert!(result.is_err(), "{case}");
11019                assert_eq!(
11020                    serde_json::to_value(&child).unwrap(),
11021                    before,
11022                    "{case}: no partial mutable success"
11023                );
11024                assert_eq!(
11025                    serde_json::to_value(Some(&cold)).unwrap(),
11026                    durable_before,
11027                    "{case}: no write"
11028                );
11029                continue;
11030            }
11031            result.unwrap();
11032            assert_eq!(
11033                serde_json::to_value(&cold.messages[..3]).unwrap(),
11034                before["messages"]
11035            );
11036            let suffix_start = if case == "concurrent_input" {
11037                assert!(inbox.was_admitted(&child.id, &late.id).await.unwrap());
11038                assert_eq!(
11039                    serde_json::to_value(&cold.messages[3]).unwrap(),
11040                    serde_json::to_value(late.to_provider_message().unwrap()).unwrap()
11041                );
11042                assert!(child
11043                    .messages
11044                    .iter()
11045                    .any(|message| message.id == late.id.as_str()));
11046                4
11047            } else {
11048                3
11049            };
11050            assert_eq!(
11051                serde_json::to_value(&cold.messages[suffix_start..]).unwrap(),
11052                serde_json::to_value(rows).unwrap()
11053            );
11054            assert_eq!(
11055                cold.messages
11056                    .iter()
11057                    .filter(|message| message.id == envelope.id.as_str())
11058                    .count(),
11059                1
11060            );
11061            assert_eq!(
11062                cold.messages
11063                    .iter()
11064                    .filter(|message| message.content == "complete exact report")
11065                    .count(),
11066                1
11067            );
11068            assert_eq!(cold.metadata, child.metadata);
11069            assert!(cold.provider_transcript.is_empty());
11070        }
11071    }
11072
11073    #[tokio::test]
11074    async fn local_tool_completion_requires_current_ordered_batch_and_never_falls_back_to_text() {
11075        for case in [
11076            "legacy",
11077            "missing",
11078            "gap",
11079            "duplicate",
11080            "epoch",
11081            "source",
11082            "birth",
11083            "qos",
11084            "ticket_forged_success",
11085        ] {
11086            let mut session = Session::new_child("frame-child", "frame-root", "model", "child");
11087            session
11088                .messages
11089                .push(bamboo_agent_core::Message::user("original"));
11090            if case == "ticket_forged_success" {
11091                session.metadata.insert(
11092                    crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY.into(),
11093                    "assignment".into(),
11094                );
11095            }
11096            let before = serde_json::to_value(&session).unwrap();
11097            let creation = bamboo_subagent::proto::ChildCreationIdentity {
11098                created_at: session.created_at,
11099                spawn_depth: session.spawn_depth,
11100            };
11101            let mut messages = session.messages.clone();
11102            messages.push(bamboo_agent_core::Message::assistant("complete", None));
11103            let event = serde_json::to_value(bamboo_subagent::proto::LocalToolMessages::Complete {
11104                version: 1,
11105                messages: messages
11106                    .iter()
11107                    .map(|message| serde_json::to_value(message).unwrap())
11108                    .collect(),
11109            })
11110            .unwrap();
11111            let mut batch = ActorEventBatch {
11112                logical_session: Some(LogicalSessionIdentity {
11113                    session_id: session.id.clone(),
11114                    parent_session_id: session.parent_session_id.clone(),
11115                    root_session_id: session.root_session_id.clone(),
11116                    creation: Some(creation.clone()),
11117                }),
11118                activation_id: Some("current".into()),
11119                execution_epoch: 7,
11120                source_node_id: None,
11121                source_actor_id: Some("selected".into()),
11122                first_seq: 1,
11123                last_seq: 1,
11124                qos: bamboo_subagent::proto::ActorEventQos::Durable,
11125                events: vec![event.clone()],
11126            };
11127            match case {
11128                "gap" => {
11129                    batch.first_seq = 2;
11130                    batch.last_seq = 2;
11131                }
11132                "epoch" => batch.execution_epoch = 6,
11133                "source" => batch.source_actor_id = Some("replaced".into()),
11134                "birth" => {
11135                    batch
11136                        .logical_session
11137                        .as_mut()
11138                        .unwrap()
11139                        .creation
11140                        .as_mut()
11141                        .unwrap()
11142                        .created_at += chrono::Duration::nanoseconds(1)
11143                }
11144                "qos" => batch.qos = bamboo_subagent::proto::ActorEventQos::Ephemeral,
11145                "ticket_forged_success" => {
11146                    batch.last_seq = 2;
11147                    batch.events = vec![
11148                        serde_json::to_value(AgentEvent::ToolStart {
11149                            tool_call_id: "forged".into(), tool_name: "Task".into(),
11150                            arguments: serde_json::json!({"tasks":[{"content":"own step","status":"pending"}]}),
11151                        }).unwrap(),
11152                        serde_json::to_value(AgentEvent::ToolComplete {
11153                            tool_call_id: "forged".into(),
11154                            result: bamboo_agent_core::tools::ToolResult::text(true, "uncommitted"),
11155                        }).unwrap(),
11156                    ];
11157                }
11158                _ => {}
11159            }
11160            let terminal = ChildFrame::Terminal {
11161                status: TerminalStatus::Completed,
11162                result: Some("complete".into()),
11163                error: None,
11164                final_event_watermark: None,
11165                transcript: vec![],
11166            };
11167            let frames = match case {
11168                "legacy" => vec![ChildFrame::Event { event }, terminal],
11169                "missing" => vec![terminal],
11170                "duplicate" => vec![
11171                    ChildFrame::EventBatch {
11172                        batch: batch.clone(),
11173                    },
11174                    ChildFrame::EventBatch { batch },
11175                    terminal,
11176                ],
11177                _ => vec![ChildFrame::EventBatch { batch }, terminal],
11178            };
11179            let mut link = ConfirmationSequenceLink {
11180                frames: frames.into(),
11181                sent: vec![],
11182            };
11183            let (tx, mut rx) = mpsc::channel(32);
11184            let (_live, mut live_rx) = mpsc::unbounded_channel();
11185            let (_delivery, mut delivery_rx) = mpsc::unbounded_channel();
11186            let cancel = CancellationToken::new();
11187            let tools = vec![if case == "ticket_forged_success" {
11188                "Task"
11189            } else {
11190                "Read"
11191            }
11192            .into()];
11193            let outcome = drive(ActorDriveContext {
11194                client: &mut link,
11195                parent_session_id: "frame-root",
11196                child_session_id: "frame-child",
11197                child_attempt: 0,
11198                approval_registry: None,
11199                approval_decider: None,
11200                approval_reviewer: None,
11201                escalation_bridge: None,
11202                event_tx: &tx,
11203                cancel_token: &cancel,
11204                live_rx: &mut live_rx,
11205                delivery_rx: &mut delivery_rx,
11206                logical_session: &mut session,
11207                expected_permission_posture: None,
11208                expected_creation: Some(&creation),
11209                session_inbox_runtime: None,
11210                actor_directory_store: None,
11211                canonical_subagent_tool: None,
11212                ticket_service: None,
11213                activation_run_id: Some("current"),
11214                execution_epoch: 7,
11215                expected_source_actor_id: "selected",
11216                initial_inflight_claims: vec![].into(),
11217                plain_actor: false,
11218                remote_environment_lease: false,
11219                readonly_output: None,
11220                local_history_tools: Some(&tools),
11221                local_history_read_only: true,
11222                strict_history_output: None,
11223                plain_input: None,
11224                canonical_activation: None,
11225                canonical_placement_ref: None,
11226                actor_event_observer: None,
11227                plain_run: None,
11228                first_frame_timeout: Some(Duration::from_secs(1)),
11229            })
11230            .await;
11231            assert!(outcome.is_err(), "{case}: no last-text success fallback");
11232            if case == "ticket_forged_success" {
11233                assert!(outcome
11234                    .unwrap_err()
11235                    .to_string()
11236                    .contains("no exact Host receipt"));
11237                assert!(matches!(
11238                    rx.try_recv().unwrap(),
11239                    AgentEvent::ToolStart { .. }
11240                ));
11241            }
11242            assert_eq!(serde_json::to_value(&session).unwrap(), before, "{case}");
11243            assert!(link.sent.is_empty());
11244            assert!(rx.try_recv().is_err(), "DATA must never enter public feed");
11245        }
11246    }
11247
11248    struct HistoryTerminalProbe {
11249        frames: VecDeque<ChildFrame>,
11250        accepted: Option<TerminalStatus>,
11251        acks: usize,
11252    }
11253
11254    #[async_trait]
11255    impl bamboo_subagent::ChildLink for HistoryTerminalProbe {
11256        async fn send(&mut self, _frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
11257            Ok(())
11258        }
11259        async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
11260            Ok(self.frames.pop_front())
11261        }
11262        fn accept_durable_terminal(&mut self, status: TerminalStatus) {
11263            self.accepted = Some(status);
11264        }
11265        fn durable_delivery_receipt(&self) -> Option<bamboo_subagent::DurableChildDeliveryReceipt> {
11266            Some(bamboo_subagent::DurableChildDeliveryReceipt {
11267                broker_identity: "fixture".into(),
11268                parent_mailbox: "parent".into(),
11269                correlation_id: "current".into(),
11270                message_ids: vec!["tail".into(), "terminal".into()],
11271                terminal_status: self.accepted?,
11272            })
11273        }
11274        async fn acknowledge_durable_frames(&mut self) -> bamboo_subagent::TransportResult<()> {
11275            self.acks += 1;
11276            Ok(())
11277        }
11278    }
11279
11280    struct CancelConcurrentInputCheckpoint {
11281        real: SessionInboxRuntimeBinding,
11282        envelope: bamboo_domain::SessionMessageEnvelope,
11283    }
11284    #[async_trait]
11285    impl RuntimeSessionPersistence for CancelConcurrentInputCheckpoint {
11286        async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
11287            self.real.persistence.save_runtime_session(session).await
11288        }
11289        async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
11290            let receipt = self
11291                .real
11292                .inbox
11293                .deliver(&self.envelope)
11294                .await
11295                .map_err(|_| std::io::Error::other("concurrent delivery failed"))?;
11296            self.real
11297                .inbox
11298                .mark_activation_eligible(
11299                    &session.id,
11300                    receipt.generation,
11301                    bamboo_domain::SessionActivationPolicy::RespectSpecificWait,
11302                )
11303                .await
11304                .map_err(|_| std::io::Error::other("concurrent activation failed"))?;
11305            let claim = self
11306                .real
11307                .inbox
11308                .claim(&session.id, 1)
11309                .await
11310                .map_err(|_| std::io::Error::other("concurrent claim failed"))?
11311                .remove(0);
11312            let mut latest = self.real.storage.load_session(&session.id).await?.unwrap();
11313            checkpoint_and_ack_canonical_claim(&self.real, &mut latest, &claim, None)
11314                .await
11315                .map_err(|_| std::io::Error::other("concurrent admission failed"))?;
11316            self.real
11317                .persistence
11318                .checkpoint_runtime_session(session)
11319                .await
11320        }
11321        async fn load_runtime_session(&self, id: &str) -> std::io::Result<Option<Session>> {
11322            self.real.storage.load_session(id).await
11323        }
11324    }
11325
11326    struct CancelHistoryProbe {
11327        inner: HistoryTerminalProbe,
11328        cancel_requested: bool,
11329    }
11330
11331    #[async_trait]
11332    impl bamboo_subagent::ChildLink for CancelHistoryProbe {
11333        async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
11334            self.cancel_requested |= matches!(frame, ParentFrame::Cancel);
11335            Ok(())
11336        }
11337        async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
11338            if !self.cancel_requested {
11339                std::future::pending().await
11340            } else {
11341                self.inner.next_frame().await
11342            }
11343        }
11344        fn accept_durable_terminal(&mut self, status: TerminalStatus) {
11345            self.inner.accept_durable_terminal(status);
11346        }
11347        fn durable_delivery_receipt(&self) -> Option<bamboo_subagent::DurableChildDeliveryReceipt> {
11348            self.inner.durable_delivery_receipt()
11349        }
11350        async fn acknowledge_durable_frames(&mut self) -> bamboo_subagent::TransportResult<()> {
11351            self.inner.acknowledge_durable_frames().await
11352        }
11353    }
11354
11355    #[tokio::test]
11356    async fn selected_cancelled_read_history_requires_checkpointed_complete_prefix() {
11357        use bamboo_subagent::ChildLink;
11358        for case in [
11359            "success",
11360            "empty",
11361            "missing",
11362            "unclosed",
11363            "unobserved",
11364            "prefix",
11365            "metadata",
11366            "write",
11367            "not_read_only",
11368            "watermark",
11369            "epoch",
11370            "birth",
11371            "permission",
11372            "inbox",
11373            "pending",
11374            "pending_eligible",
11375            "claimed",
11376            "inspect_error",
11377            "checkpoint_error",
11378            "lost_owner",
11379            "concurrent_input",
11380        ] {
11381            let temp = tempfile::tempdir().unwrap();
11382            let store = Arc::new(
11383                bamboo_storage::SessionStoreV2::new(temp.path().into())
11384                    .await
11385                    .unwrap(),
11386            );
11387            let root = Session::new("cancel-history-root", "model");
11388            store.save_session(&root).await.unwrap();
11389            let mut child = Session::new_child("cancel-history-child", &root.id, "model", "child");
11390            child
11391                .messages
11392                .push(bamboo_agent_core::Message::user("exact assignment"));
11393            child.metadata.insert(
11394                bamboo_subagent::proto::WORKFLOW_USAGE_REQUESTED_KEY.into(),
11395                "true".into(),
11396            );
11397            store.save_session(&child).await.unwrap();
11398            let before = serde_json::to_value(&child.messages).unwrap();
11399            let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
11400                store.clone(),
11401                bamboo_domain::SessionInboxLimits::default(),
11402            ));
11403            let locked = Arc::new(bamboo_storage::LockedSessionStore::new(store.clone()));
11404            let mut binding = actor_binding(store.clone(), inbox.clone(), locked.clone());
11405            let mut owner = Some(
11406                binding
11407                    .router
11408                    .register_run(&child.id, "current")
11409                    .await
11410                    .unwrap(),
11411            );
11412            let creation = bamboo_subagent::proto::ChildCreationIdentity {
11413                created_at: child.created_at,
11414                spawn_depth: child.spawn_depth,
11415            };
11416            let identity = actor_event_logical_identity(&child, &root.id, Some(&creation));
11417            let tool = if case == "write" { "Write" } else { "Read" };
11418            let arguments = serde_json::json!({"file_path":"assigned.txt"});
11419            let call = bamboo_agent_core::ToolCall {
11420                id: "cancel-read".into(),
11421                tool_type: "function".into(),
11422                function: bamboo_agent_core::FunctionCall {
11423                    name: tool.into(),
11424                    arguments: arguments.to_string(),
11425                },
11426            };
11427            let mut rows = child.messages.clone();
11428            let mut events = Vec::new();
11429            if case != "empty" {
11430                rows.push(bamboo_agent_core::Message::assistant("", Some(vec![call])));
11431                events.push(
11432                    serde_json::to_value(AgentEvent::ToolStart {
11433                        tool_call_id: "cancel-read".into(),
11434                        tool_name: tool.into(),
11435                        arguments,
11436                    })
11437                    .unwrap(),
11438                );
11439                if case != "unclosed" {
11440                    let mut result = bamboo_agent_core::Message::tool_result_with_status(
11441                        "cancel-read",
11442                        "exact Read result",
11443                        true,
11444                    );
11445                    if case == "metadata" {
11446                        result.metadata = Some(serde_json::json!({"permission":true}));
11447                    }
11448                    rows.push(result);
11449                    events.push(
11450                        serde_json::to_value(AgentEvent::ToolComplete {
11451                            tool_call_id: "cancel-read".into(),
11452                            result: bamboo_agent_core::tools::ToolResult::text(
11453                                true,
11454                                "exact Read result",
11455                            ),
11456                        })
11457                        .unwrap(),
11458                    );
11459                }
11460            }
11461            if case == "unobserved" {
11462                events.clear();
11463            }
11464            if case == "prefix" {
11465                rows[0].content.push('!');
11466            }
11467            events.push(
11468                serde_json::to_value(bamboo_subagent::proto::LocalToolMessages::Complete {
11469                    version: 1,
11470                    messages: rows
11471                        .iter()
11472                        .map(|row| serde_json::to_value(row).unwrap())
11473                        .collect(),
11474                })
11475                .unwrap(),
11476            );
11477            if case == "missing" {
11478                events.clear();
11479            }
11480            let final_seq = events.len() as u64;
11481            let mut watermark = bamboo_subagent::ActorEventWatermark {
11482                version: 1,
11483                logical_session: Some(identity.clone()),
11484                activation_id: Some("current".into()),
11485                execution_epoch: 7,
11486                final_seq,
11487            };
11488            match case {
11489                "watermark" => watermark.final_seq += 1,
11490                "epoch" => watermark.execution_epoch -= 1,
11491                "birth" => {
11492                    watermark
11493                        .logical_session
11494                        .as_mut()
11495                        .unwrap()
11496                        .creation
11497                        .as_mut()
11498                        .unwrap()
11499                        .created_at += chrono::Duration::nanoseconds(1)
11500                }
11501                _ => {}
11502            }
11503            let mut frames = VecDeque::new();
11504            if !events.is_empty() {
11505                frames.push_back(ChildFrame::EventBatch {
11506                    batch: ActorEventBatch {
11507                        logical_session: Some(identity),
11508                        activation_id: Some("current".into()),
11509                        execution_epoch: 7,
11510                        source_node_id: None,
11511                        source_actor_id: Some("selected".into()),
11512                        first_seq: 1,
11513                        last_seq: final_seq,
11514                        qos: bamboo_subagent::ActorEventQos::Durable,
11515                        events,
11516                    },
11517                });
11518            }
11519            frames.push_back(ChildFrame::Terminal {
11520                status: TerminalStatus::Cancelled,
11521                result: None,
11522                error: None,
11523                transcript: vec![],
11524                final_event_watermark: Some(watermark),
11525            });
11526            let mut claims = VecDeque::new();
11527            let pending_input =
11528                bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "new canonical input");
11529            if matches!(case, "inbox" | "pending" | "pending_eligible" | "claimed") {
11530                let receipt = inbox.deliver(&pending_input).await.unwrap();
11531                if case != "pending" {
11532                    inbox
11533                        .mark_activation_eligible(
11534                            &child.id,
11535                            receipt.generation,
11536                            bamboo_domain::SessionActivationPolicy::RespectSpecificWait,
11537                        )
11538                        .await
11539                        .unwrap();
11540                }
11541                if matches!(case, "inbox" | "claimed") {
11542                    let claim = inbox.claim(&child.id, 1).await.unwrap().remove(0);
11543                    if case == "inbox" {
11544                        claims.push_back(claim);
11545                    }
11546                }
11547            }
11548            if case == "concurrent_input" {
11549                binding.persistence = Arc::new(CancelConcurrentInputCheckpoint {
11550                    real: actor_binding(store.clone(), inbox.clone(), locked.clone()),
11551                    envelope: pending_input.clone(),
11552                });
11553            }
11554            if case == "inspect_error" {
11555                let rel = store.resolve_rel_path(&child.id).await.unwrap();
11556                let dir = store.bamboo_home_dir().join(rel).join("inbox");
11557                std::fs::create_dir_all(&dir).unwrap();
11558                std::fs::create_dir(dir.join(".session-inbox.lock")).unwrap();
11559                assert!(inbox.inspect(&child.id).await.is_err());
11560            }
11561            let backlog_before =
11562                if matches!(case, "inbox" | "pending" | "pending_eligible" | "claimed") {
11563                    Some(inbox.inspect(&child.id).await.unwrap())
11564                } else {
11565                    None
11566                };
11567            if case == "checkpoint_error" {
11568                binding.persistence = Arc::new(ActorFaultingPersistence {
11569                    inner: locked.clone(),
11570                    fail_checkpoint_once: AtomicBool::new(true),
11571                });
11572            }
11573            if case == "lost_owner" {
11574                owner.take().unwrap().abandon().await;
11575            }
11576            let mut link = CancelHistoryProbe {
11577                inner: HistoryTerminalProbe {
11578                    frames,
11579                    accepted: None,
11580                    acks: 0,
11581                },
11582                cancel_requested: false,
11583            };
11584            let (tx, mut rx) = mpsc::channel(32);
11585            let (_live, mut live_rx) = mpsc::unbounded_channel();
11586            let (_delivery, mut delivery_rx) = mpsc::unbounded_channel();
11587            let cancel = CancellationToken::new();
11588            cancel.cancel();
11589            let tools = vec!["Read".into(), "Glob".into()];
11590            let mut strict_history = None;
11591            let outcome = drive(ActorDriveContext {
11592                client: &mut link,
11593                parent_session_id: &root.id,
11594                child_session_id: &child.id.clone(),
11595                child_attempt: 0,
11596                approval_registry: None,
11597                approval_decider: None,
11598                approval_reviewer: None,
11599                escalation_bridge: None,
11600                event_tx: &tx,
11601                cancel_token: &cancel,
11602                live_rx: &mut live_rx,
11603                delivery_rx: &mut delivery_rx,
11604                logical_session: &mut child,
11605                expected_permission_posture: (case == "permission")
11606                    .then(|| expected_default_permission_posture(7)),
11607                expected_creation: Some(&creation),
11608                session_inbox_runtime: Some(&binding),
11609                actor_directory_store: None,
11610                canonical_subagent_tool: None,
11611                ticket_service: None,
11612                activation_run_id: Some("current"),
11613                execution_epoch: 7,
11614                expected_source_actor_id: "selected",
11615                initial_inflight_claims: claims,
11616                plain_actor: false,
11617                remote_environment_lease: false,
11618                readonly_output: None,
11619                local_history_tools: Some(&tools),
11620                local_history_read_only: case != "not_read_only",
11621                strict_history_output: Some(&mut strict_history),
11622                plain_input: None,
11623                canonical_activation: None,
11624                canonical_placement_ref: None,
11625                actor_event_observer: None,
11626                plain_run: None,
11627                first_frame_timeout: None,
11628            })
11629            .await;
11630            let proven = matches!(case, "success" | "empty");
11631            assert_eq!(strict_history.is_some(), proven, "{case}");
11632            assert_eq!(link.durable_delivery_receipt().is_some(), proven, "{case}");
11633            assert_eq!(
11634                link.inner.acks, 0,
11635                "{case}: pump cannot ACK before SDK save/proof"
11636            );
11637            let cold = bamboo_storage::SessionStoreV2::new(temp.path().into())
11638                .await
11639                .unwrap()
11640                .load_session(&child.id)
11641                .await
11642                .unwrap()
11643                .unwrap();
11644            if proven {
11645                assert!(matches!(outcome, Err(AgentError::Cancelled)), "{case}");
11646                assert_eq!(link.inner.accepted, Some(TerminalStatus::Cancelled));
11647                let history = strict_history.unwrap();
11648                assert_eq!(history.proof.execution_epoch(), 7);
11649                assert_eq!(history.proof.contiguous_applied_seq(), final_seq);
11650                assert_eq!(
11651                    serde_json::to_value(&cold.messages).unwrap(),
11652                    serde_json::to_value(&rows).unwrap()
11653                );
11654            } else {
11655                assert!(outcome.is_err(), "{case}");
11656                if case == "concurrent_input" {
11657                    assert!(
11658                        cold.messages
11659                            .iter()
11660                            .any(|message| message.id == pending_input.id.as_str()),
11661                        "new input must survive refusal"
11662                    );
11663                } else {
11664                    assert_eq!(
11665                        serde_json::to_value(&cold.messages).unwrap(),
11666                        before,
11667                        "{case}: no unvalidated history write"
11668                    );
11669                }
11670            }
11671            if let Some(backlog) = backlog_before {
11672                assert!(!inbox
11673                    .was_admitted(&child.id, &pending_input.id)
11674                    .await
11675                    .unwrap());
11676                assert_eq!(
11677                    inbox.inspect(&child.id).await.unwrap(),
11678                    backlog,
11679                    "{case}: proof refusal must neither claim nor ACK the input"
11680                );
11681            }
11682            while let Ok(event) = rx.try_recv() {
11683                assert!(
11684                    !matches!(event, AgentEvent::SessionHistoryCommitted { .. }),
11685                    "private DATA is not a canonical public barrier"
11686                );
11687            }
11688        }
11689    }
11690
11691    #[tokio::test]
11692    async fn strict_terminal_watermark_gates_history_commit_and_durable_acceptance() {
11693        use bamboo_subagent::ChildLink;
11694        for case in [
11695            "success",
11696            "tail",
11697            "short",
11698            "missing",
11699            "version",
11700            "activation",
11701            "epoch",
11702            "birth",
11703            "duplicate",
11704            "overlap",
11705            "reorder",
11706            "midgap",
11707            "qos",
11708            "zero_missing",
11709            "zero_valid",
11710            "error_tail",
11711            "cancelled_tail",
11712            "cancelled_valid",
11713            "cancelled_zero_valid",
11714            "error_valid",
11715            "error_zero_valid",
11716            "ordinary_lossy",
11717        ] {
11718            let temp = tempfile::tempdir().unwrap();
11719            let store = Arc::new(
11720                bamboo_storage::SessionStoreV2::new(temp.path().into())
11721                    .await
11722                    .unwrap(),
11723            );
11724            let root = Session::new("watermark-root", "model");
11725            store.save_session(&root).await.unwrap();
11726            let mut child = Session::new_child("watermark-child", &root.id, "model", "child");
11727            child
11728                .messages
11729                .push(bamboo_agent_core::Message::user("assignment"));
11730            store.save_session(&child).await.unwrap();
11731            let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
11732                store.clone(),
11733                bamboo_domain::SessionInboxLimits::default(),
11734            ));
11735            let locked = Arc::new(bamboo_storage::LockedSessionStore::new(store.clone()));
11736            let binding = actor_binding(store.clone(), inbox, locked);
11737            let _owner = binding
11738                .router
11739                .register_run(&child.id, "current")
11740                .await
11741                .unwrap();
11742            let before = serde_json::to_value(&child).unwrap();
11743            let creation = bamboo_subagent::proto::ChildCreationIdentity {
11744                created_at: child.created_at,
11745                spawn_depth: child.spawn_depth,
11746            };
11747            let identity = actor_event_logical_identity(&child, &root.id, Some(&creation));
11748            let mut rows = child.messages.clone();
11749            rows.push(bamboo_agent_core::Message::assistant("complete", None));
11750            let data = serde_json::to_value(bamboo_subagent::proto::LocalToolMessages::Complete {
11751                version: 1,
11752                messages: rows
11753                    .iter()
11754                    .map(|row| serde_json::to_value(row).unwrap())
11755                    .collect(),
11756            })
11757            .unwrap();
11758            let batch = |seq, event, qos| ActorEventBatch {
11759                logical_session: Some(identity.clone()),
11760                activation_id: Some("current".into()),
11761                execution_epoch: 7,
11762                source_node_id: None,
11763                source_actor_id: Some("selected".into()),
11764                first_seq: seq,
11765                last_seq: seq,
11766                qos,
11767                events: vec![event],
11768            };
11769            let durable = bamboo_subagent::ActorEventQos::Durable;
11770            let token = serde_json::json!({"type":"token", "content":"observation"});
11771            let mut frames = vec![ChildFrame::EventBatch {
11772                batch: batch(1, data.clone(), durable),
11773            }];
11774            let mut marker = bamboo_subagent::ActorEventWatermark {
11775                version: 1,
11776                logical_session: Some(identity.clone()),
11777                activation_id: Some("current".into()),
11778                execution_epoch: 7,
11779                final_seq: 1,
11780            };
11781            match case {
11782                "tail" | "error_tail" | "cancelled_tail" => marker.final_seq = 2,
11783                "short" => marker.final_seq = 0,
11784                "version" => marker.version = 99,
11785                "activation" => marker.activation_id = Some("old".into()),
11786                "epoch" => marker.execution_epoch = 6,
11787                "birth" => {
11788                    marker
11789                        .logical_session
11790                        .as_mut()
11791                        .unwrap()
11792                        .creation
11793                        .as_mut()
11794                        .unwrap()
11795                        .created_at += chrono::Duration::nanoseconds(1)
11796                }
11797                "duplicate" => frames.push(frames[0].clone()),
11798                "overlap" | "midgap" | "qos" => {
11799                    frames = vec![
11800                        ChildFrame::EventBatch {
11801                            batch: batch(
11802                                1,
11803                                token.clone(),
11804                                if case == "qos" {
11805                                    bamboo_subagent::ActorEventQos::Ephemeral
11806                                } else {
11807                                    durable
11808                                },
11809                            ),
11810                        },
11811                        ChildFrame::EventBatch {
11812                            batch: batch(
11813                                if case == "overlap" {
11814                                    1
11815                                } else if case == "midgap" {
11816                                    3
11817                                } else {
11818                                    2
11819                                },
11820                                data,
11821                                durable,
11822                            ),
11823                        },
11824                    ];
11825                    marker.final_seq = 2;
11826                }
11827                "reorder" => {
11828                    frames = vec![
11829                        ChildFrame::EventBatch {
11830                            batch: batch(2, data, durable),
11831                        },
11832                        ChildFrame::EventBatch {
11833                            batch: batch(1, token, durable),
11834                        },
11835                    ]
11836                }
11837                "zero_missing" | "zero_valid" | "error_zero_valid" | "cancelled_zero_valid" => {
11838                    frames.clear();
11839                    marker.final_seq = 0;
11840                }
11841                "ordinary_lossy" => {
11842                    frames = vec![ChildFrame::EventBatch {
11843                        batch: batch(3, token, bamboo_subagent::ActorEventQos::Ephemeral),
11844                    }]
11845                }
11846                _ => {}
11847            }
11848            let status = match case {
11849                "error_tail" | "error_valid" | "error_zero_valid" => TerminalStatus::Error,
11850                "cancelled_tail" | "cancelled_valid" | "cancelled_zero_valid" => {
11851                    TerminalStatus::Cancelled
11852                }
11853                _ => TerminalStatus::Completed,
11854            };
11855            frames.push(ChildFrame::Terminal {
11856                status,
11857                result: Some("complete".into()),
11858                error: Some("worker failed".into()),
11859                transcript: vec![],
11860                final_event_watermark: (!matches!(
11861                    case,
11862                    "missing" | "zero_missing" | "ordinary_lossy"
11863                ))
11864                .then_some(marker),
11865            });
11866            let mut link = HistoryTerminalProbe {
11867                frames: frames.into(),
11868                accepted: None,
11869                acks: 0,
11870            };
11871            let (tx, mut rx) = mpsc::channel(32);
11872            let (_live, mut live_rx) = mpsc::unbounded_channel();
11873            let (_delivery, mut delivery_rx) = mpsc::unbounded_channel();
11874            let cancel = CancellationToken::new();
11875            let tools = vec!["Read".into()];
11876            let mut strict_history = None;
11877            let outcome = drive(ActorDriveContext {
11878                client: &mut link,
11879                parent_session_id: &root.id,
11880                child_session_id: "watermark-child",
11881                child_attempt: 0,
11882                approval_registry: None,
11883                approval_decider: None,
11884                approval_reviewer: None,
11885                escalation_bridge: None,
11886                event_tx: &tx,
11887                cancel_token: &cancel,
11888                live_rx: &mut live_rx,
11889                delivery_rx: &mut delivery_rx,
11890                logical_session: &mut child,
11891                expected_permission_posture: None,
11892                expected_creation: Some(&creation),
11893                session_inbox_runtime: Some(&binding),
11894                actor_directory_store: None,
11895                canonical_subagent_tool: None,
11896                ticket_service: None,
11897                activation_run_id: Some("current"),
11898                execution_epoch: 7,
11899                expected_source_actor_id: "selected",
11900                initial_inflight_claims: vec![].into(),
11901                plain_actor: false,
11902                remote_environment_lease: false,
11903                readonly_output: None,
11904                local_history_tools: (case != "ordinary_lossy").then_some(&tools),
11905                local_history_read_only: true,
11906                strict_history_output: Some(&mut strict_history),
11907                plain_input: None,
11908                canonical_activation: None,
11909                canonical_placement_ref: None,
11910                actor_event_observer: None,
11911                plain_run: None,
11912                first_frame_timeout: Some(Duration::from_secs(1)),
11913            })
11914            .await;
11915            assert_eq!(strict_history.is_some(), case == "success", "{case}");
11916            if let Some(history) = strict_history {
11917                assert_eq!(history.proof.execution_epoch(), 7);
11918                assert_eq!(history.proof.contiguous_applied_seq(), 1);
11919                assert_eq!(history.watermark.final_seq, 1);
11920            }
11921            let cold = store.load_session(&child.id).await.unwrap().unwrap();
11922            if matches!(case, "success" | "ordinary_lossy") {
11923                assert_eq!(outcome.unwrap().as_deref(), Some("complete"), "{case}");
11924            } else {
11925                let error = outcome.unwrap_err();
11926                if matches!(case, "cancelled_valid" | "cancelled_zero_valid") {
11927                    assert!(matches!(&error, AgentError::Cancelled), "{case}: {error}");
11928                }
11929                let error = error.to_string();
11930                if !matches!(
11931                    case,
11932                    "duplicate"
11933                        | "overlap"
11934                        | "reorder"
11935                        | "midgap"
11936                        | "qos"
11937                        | "zero_valid"
11938                        | "error_valid"
11939                        | "error_zero_valid"
11940                        | "cancelled_valid"
11941                        | "cancelled_zero_valid"
11942                ) {
11943                    assert!(
11944                        error.contains("actor_history_tail_incomplete"),
11945                        "{case}: {error}"
11946                    );
11947                }
11948            }
11949            if case == "success" {
11950                assert_eq!(
11951                    serde_json::to_value(&cold.messages).unwrap(),
11952                    serde_json::to_value(rows).unwrap()
11953                );
11954            } else {
11955                assert_eq!(
11956                    serde_json::to_value(&cold).unwrap(),
11957                    before,
11958                    "{case}: no cold history commit"
11959                );
11960                assert_eq!(
11961                    serde_json::to_value(&child).unwrap(),
11962                    before,
11963                    "{case}: no mutable history commit"
11964                );
11965            }
11966            let accepted = matches!(case, "success" | "ordinary_lossy");
11967            assert_eq!(link.accepted.is_some(), accepted, "{case}");
11968            assert_eq!(
11969                link.durable_delivery_receipt().is_some(),
11970                accepted,
11971                "{case}"
11972            );
11973            assert_eq!(link.acks, 0, "frame pump never ACKs uncheckpointed history");
11974            while let Ok(event) = rx.try_recv() {
11975                assert!(
11976                    matches!(event, AgentEvent::Token { .. }),
11977                    "typed DATA cannot leak"
11978                );
11979            }
11980        }
11981    }
11982
11983    fn processless_pool_worker(mailbox_id: &str) -> PooledWorker {
11984        PooledWorker {
11985            worker: SpawnedChild::remote(AgentRecord {
11986                agent_id: mailbox_id.to_string(),
11987                role: "test".to_string(),
11988                labels: Vec::new(),
11989                endpoint: "ws://127.0.0.1:1".to_string(),
11990                pid: 0,
11991                version: String::new(),
11992                started_at: chrono::Utc::now(),
11993                lease_expires_at: chrono::Utc::now(),
11994            }),
11995            mailbox_id: mailbox_id.to_string(),
11996            parked_at: None,
11997        }
11998    }
11999
12000    #[test]
12001    fn parked_worker_deadline_is_inclusive_and_ignores_checked_out_workers() {
12002        let now = Instant::now();
12003        let timeout = Duration::from_secs(300);
12004        assert!(!parked_worker_expired(None, now, timeout));
12005        assert!(!parked_worker_expired(
12006            Some(now - timeout + Duration::from_millis(1)),
12007            now,
12008            timeout
12009        ));
12010        assert!(parked_worker_expired(Some(now - timeout), now, timeout));
12011    }
12012
12013    #[test]
12014    fn idle_pool_global_cap_bounds_distinct_fingerprints() {
12015        let mut pool = WorkerPool::new();
12016        pool.insert(
12017            "fingerprint-a".to_string(),
12018            vec![processless_pool_worker("a")],
12019        );
12020        pool.insert(
12021            "fingerprint-b".to_string(),
12022            vec![processless_pool_worker("b")],
12023        );
12024
12025        assert_eq!(
12026            idle_pool_limit_reason(&pool, "fingerprint-c", 4, 2),
12027            Some("pool_global_limit")
12028        );
12029        assert_eq!(idle_pool_limit_reason(&pool, "fingerprint-c", 4, 3), None);
12030        assert_eq!(
12031            idle_pool_limit_reason(&pool, "fingerprint-a", 1, 8),
12032            Some("pool_per_key_limit")
12033        );
12034    }
12035
12036    #[test]
12037    fn pool_sweep_removes_dead_workers_and_empty_fingerprint_buckets() {
12038        let mut pool = WorkerPool::new();
12039        pool.insert(
12040            "fingerprint-a".to_string(),
12041            vec![processless_pool_worker("dead")],
12042        );
12043
12044        let reaped = take_reapable_workers(
12045            &mut pool,
12046            Instant::now(),
12047            Duration::from_secs(POOLED_IDLE_TIMEOUT_SECS),
12048        );
12049
12050        assert!(pool.is_empty());
12051        assert_eq!(reaped.len(), 1);
12052        assert_eq!(reaped[0].1, PoolReapReason::ProcessExited);
12053    }
12054
12055    #[test]
12056    fn actor_preflight_counts_only_current_session_scoped_denies() {
12057        let config = bamboo_tools::permission::PermissionConfig::new();
12058        let secret_matcher = "TOP_SECRET_ACTOR_DENY_MATCHER";
12059        config.deny_scoped_session_permission(
12060            "target-session",
12061            bamboo_tools::permission::PermissionType::ExecuteCommand,
12062            secret_matcher,
12063        );
12064        config.deny_scoped_session_permission(
12065            "other-session",
12066            bamboo_tools::permission::PermissionType::WriteFile,
12067            "/other/**",
12068        );
12069
12070        assert_eq!(
12071            active_scoped_session_deny_count(&config, "target-session"),
12072            1
12073        );
12074        assert_eq!(
12075            active_scoped_session_deny_count(&config, "clean-session"),
12076            0
12077        );
12078        let error = ensure_no_active_scoped_session_denies(&config, "target-session")
12079            .unwrap_err()
12080            .to_string();
12081        assert!(!error.contains(secret_matcher));
12082        ensure_no_active_scoped_session_denies(&config, "clean-session")
12083            .expect("another session's deny must not block this activation");
12084    }
12085
12086    #[test]
12087    fn permission_posture_event_rejects_oversized_executor_mapping() {
12088        let session = Session::new("mapping-bound", "model");
12089        let event = AgentEvent::PermissionPostureActivated {
12090            session_id: session.id.clone(),
12091            policy_revision: 1,
12092            requested_mode: "default".to_string(),
12093            effective_mode: "default".to_string(),
12094            executor_mapping: "x".repeat(bamboo_domain::MAX_PERMISSION_EXECUTOR_MAPPING_CHARS + 1),
12095        };
12096
12097        assert!(permission_posture_seed_from_event(&session, &event)
12098            .unwrap_err()
12099            .contains("executor mapping"));
12100    }
12101
12102    #[test]
12103    fn remote_audit_revision_and_timestamp_fields_cannot_poison_host_audit() {
12104        let mut session = Session::new("host-resigns-audit", "model");
12105        let hostile_timestamp = "9".repeat(1024);
12106        let event: AgentEvent = serde_json::from_value(serde_json::json!({
12107            "type": "permission_posture_activated",
12108            "session_id": session.id.clone(),
12109            "policy_revision": 31,
12110            "requested_mode": "default",
12111            "effective_mode": "default",
12112            "executor_mapping": "codex_exec:approval_policy=never",
12113            "audit_revision": u64::MAX,
12114            "transitioned_at": hostile_timestamp,
12115        }))
12116        .expect("unknown remote audit fields are ignored by the typed event");
12117        let seed = permission_posture_seed_from_event(&session, &event)
12118            .unwrap()
12119            .expect("permission event");
12120
12121        let host_revision =
12122            bamboo_domain::record_permission_audit(&mut session.metadata, &seed, None).unwrap();
12123        let host_audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
12124            .expect("host-generated complete audit");
12125        assert_eq!(host_audit.audit_revision, host_revision);
12126        assert!(host_audit.audit_revision < bamboo_domain::MAX_PERMISSION_AUDIT_REVISION);
12127        assert_ne!(host_audit.transitioned_at, hostile_timestamp);
12128        assert!(chrono::DateTime::parse_from_rfc3339(&host_audit.transitioned_at).is_ok());
12129    }
12130
12131    struct ActorFaultingPersistence {
12132        inner: Arc<bamboo_storage::LockedSessionStore>,
12133        fail_checkpoint_once: std::sync::atomic::AtomicBool,
12134    }
12135
12136    #[async_trait]
12137    impl RuntimeSessionPersistence for ActorFaultingPersistence {
12138        async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
12139            self.inner.merge_save_runtime(session).await
12140        }
12141
12142        async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
12143            if self
12144                .fail_checkpoint_once
12145                .swap(false, std::sync::atomic::Ordering::SeqCst)
12146            {
12147                return Err(std::io::Error::other("injected actor checkpoint failure"));
12148            }
12149            self.inner.checkpoint_runtime_session(session).await
12150        }
12151
12152        async fn load_runtime_session(&self, session_id: &str) -> std::io::Result<Option<Session>> {
12153            self.inner.storage().load_session(session_id).await
12154        }
12155    }
12156
12157    struct ActorFailBeforeAckInbox {
12158        inner: Arc<dyn SessionInboxPort>,
12159        fail_once: std::sync::atomic::AtomicBool,
12160    }
12161
12162    #[async_trait]
12163    impl SessionInboxPort for ActorFailBeforeAckInbox {
12164        async fn deliver(
12165            &self,
12166            envelope: &bamboo_domain::SessionMessageEnvelope,
12167        ) -> Result<bamboo_domain::SessionInboxReceipt, bamboo_domain::SessionInboxError> {
12168            self.inner.deliver(envelope).await
12169        }
12170
12171        async fn mark_activation_eligible(
12172            &self,
12173            target_session_id: &str,
12174            generation: u64,
12175            policy: bamboo_domain::SessionActivationPolicy,
12176        ) -> Result<(), bamboo_domain::SessionInboxError> {
12177            self.inner
12178                .mark_activation_eligible(target_session_id, generation, policy)
12179                .await
12180        }
12181
12182        async fn claim(
12183            &self,
12184            target_session_id: &str,
12185            limit: usize,
12186        ) -> Result<Vec<SessionInboxClaim>, bamboo_domain::SessionInboxError> {
12187            self.inner.claim(target_session_id, limit).await
12188        }
12189
12190        async fn was_admitted(
12191            &self,
12192            target_session_id: &str,
12193            id: &bamboo_domain::SessionMessageId,
12194        ) -> Result<bool, bamboo_domain::SessionInboxError> {
12195            self.inner.was_admitted(target_session_id, id).await
12196        }
12197
12198        async fn ack(
12199            &self,
12200            target_session_id: &str,
12201            claim: &SessionInboxClaim,
12202        ) -> Result<(), bamboo_domain::SessionInboxError> {
12203            if self
12204                .fail_once
12205                .swap(false, std::sync::atomic::Ordering::SeqCst)
12206            {
12207                return Err(bamboo_domain::SessionInboxError::Storage(
12208                    "injected actor pre-ack failure".to_string(),
12209                ));
12210            }
12211            self.inner.ack(target_session_id, claim).await
12212        }
12213
12214        async fn inspect(
12215            &self,
12216            target_session_id: &str,
12217        ) -> Result<bamboo_domain::SessionInboxBacklog, bamboo_domain::SessionInboxError> {
12218            self.inner.inspect(target_session_id).await
12219        }
12220    }
12221
12222    async fn actor_inbox_fixture(
12223        session_id: &str,
12224    ) -> (
12225        tempfile::TempDir,
12226        Arc<bamboo_storage::SessionStoreV2>,
12227        Arc<bamboo_storage::LockedSessionStore>,
12228        Arc<dyn SessionInboxPort>,
12229        Session,
12230        SessionInboxClaim,
12231    ) {
12232        let temp = tempfile::tempdir().unwrap();
12233        let store = Arc::new(
12234            bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
12235                .await
12236                .unwrap(),
12237        );
12238        let storage: Arc<dyn Storage> = store.clone();
12239        let locked = Arc::new(bamboo_storage::LockedSessionStore::new(storage));
12240        let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12241            store.clone(),
12242            bamboo_domain::SessionInboxLimits::default(),
12243        ));
12244        let session = Session::new(session_id, "model");
12245        store.save_session(&session).await.unwrap();
12246        let mut envelope =
12247            bamboo_domain::SessionMessageEnvelope::user_input(session_id, "actor follow-up");
12248        envelope.id =
12249            bamboo_domain::SessionMessageId::parse(format!("{session_id}-message")).unwrap();
12250        let receipt = inbox.deliver(&envelope).await.unwrap();
12251        inbox
12252            .mark_activation_eligible(
12253                session_id,
12254                receipt.generation,
12255                bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
12256            )
12257            .await
12258            .unwrap();
12259        let claim = inbox.claim(session_id, 1).await.unwrap().remove(0);
12260        (temp, store, locked, inbox, session, claim)
12261    }
12262
12263    fn actor_binding(
12264        store: Arc<bamboo_storage::SessionStoreV2>,
12265        inbox: Arc<dyn SessionInboxPort>,
12266        persistence: Arc<dyn RuntimeSessionPersistence>,
12267    ) -> SessionInboxRuntimeBinding {
12268        let storage: Arc<dyn Storage> = store;
12269        SessionInboxRuntimeBinding {
12270            router: SessionActivationRouter::new(),
12271            inbox,
12272            storage,
12273            persistence,
12274            parent_question_lock: None,
12275        }
12276    }
12277
12278    #[tokio::test]
12279    async fn remote_claim_seed_rejects_unsupported_inbox_before_dispatch() {
12280        let (_temp, store, locked, inbox, mut session, claim) =
12281            actor_inbox_fixture("unsupported-remote-inbox").await;
12282        let wrapped: Arc<dyn SessionInboxPort> = Arc::new(ActorFailBeforeAckInbox {
12283            inner: inbox.clone(),
12284            fail_once: std::sync::atomic::AtomicBool::new(false),
12285        });
12286        let binding = actor_binding(store.clone(), wrapped, locked);
12287        let fence = ActorActivationFence {
12288            schema_version: 1,
12289            actor_id: session.id.clone(),
12290            activation_id: "unsupported-inbox-activation".into(),
12291            attempt: 1,
12292            run_id: "unsupported-inbox-run".into(),
12293            lease_owner: "unsupported-inbox-host".into(),
12294            lease_epoch: 1,
12295        };
12296        let before = session.clone();
12297
12298        let error = checkpoint_claim_context_before_dispatch(
12299            &binding,
12300            &mut session,
12301            &[claim],
12302            Some((store.as_ref(), &fence)),
12303        )
12304        .await
12305        .unwrap_err();
12306        assert!(error
12307            .to_string()
12308            .contains("requires canonical FileSessionInbox"));
12309        assert_eq!(
12310            serde_json::to_value(&session.messages).unwrap(),
12311            serde_json::to_value(&before.messages).unwrap()
12312        );
12313        assert_eq!(
12314            serde_json::to_value(
12315                &store
12316                    .load_session(&session.id)
12317                    .await
12318                    .unwrap()
12319                    .unwrap()
12320                    .messages
12321            )
12322            .unwrap(),
12323            serde_json::to_value(&before.messages).unwrap()
12324        );
12325        assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12326    }
12327
12328    #[tokio::test]
12329    async fn remote_broker_lineage_rejects_missing_parent_and_project_drift() {
12330        let (_temp, store, locked, inbox, parent, _claim) =
12331            actor_inbox_fixture("remote-parent").await;
12332        let child = Session::new_child_of("remote-child", &parent, "model", "child");
12333        store.save_session(&child).await.unwrap();
12334        let binding = actor_binding(store.clone(), inbox, locked);
12335        let first = remote_canonical_lineage(&binding, &child).await.unwrap();
12336        assert_eq!(first.len(), 2);
12337        let mut changed = child.clone();
12338        changed.set_project_id_meta("different-project");
12339        assert!(remote_canonical_lineage(&binding, &changed).await.is_err());
12340        store.delete_session(&parent.id).await.unwrap();
12341        assert!(remote_canonical_lineage(&binding, &child).await.is_err());
12342    }
12343
12344    #[tokio::test]
12345    async fn spawn_depth_remote_lineage_accepts_existing_tree_above_default() {
12346        let (_temp, store, locked, inbox, root, _claim) =
12347            actor_inbox_fixture("deep-remote-root").await;
12348        let mut parent = root;
12349        for depth in 1..=6 {
12350            let child =
12351                Session::new_child_of(format!("remote-depth-{depth}"), &parent, "model", "child");
12352            store.save_session(&child).await.unwrap();
12353            parent = child;
12354        }
12355        let binding = actor_binding(store, inbox, locked);
12356        let chain = remote_canonical_lineage(&binding, &parent).await.unwrap();
12357        assert_eq!(chain.len(), 7);
12358        assert_eq!(chain.last().unwrap().spawn_depth, 0);
12359    }
12360
12361    #[tokio::test]
12362    async fn child_creation_requires_canonical_host_lifetime_not_cached_candidate() {
12363        let (_temp, store, locked, inbox, parent, _claim) =
12364            actor_inbox_fixture("creation-parent").await;
12365        let mut child = Session::new_child_of("creation-child", &parent, "model", "child");
12366        child.set_project_id_meta("creation-project");
12367        store.save_session(&child).await.unwrap();
12368        let binding = actor_binding(store.clone(), inbox, locked);
12369        let job = SpawnJob {
12370            parent_session_id: parent.id.clone(),
12371            child_session_id: child.id.clone(),
12372            model: "model".into(),
12373            disabled_tools: None,
12374        };
12375        let creation = canonical_child_creation(&binding, &child, &job)
12376            .await
12377            .unwrap();
12378        assert_eq!(creation.created_at, child.created_at);
12379        assert_eq!(creation.spawn_depth, child.spawn_depth);
12380        for damage in 0..7 {
12381            let mut held = child.clone();
12382            match damage {
12383                0 => held.created_at += chrono::Duration::nanoseconds(1),
12384                1 => held.parent_session_id = Some("foreign-parent".into()),
12385                2 => held.root_session_id = "foreign-root".into(),
12386                3 => held.spawn_depth += 1,
12387                4 => held.set_project_id_meta("foreign-project"),
12388                5 => held.kind = bamboo_domain::SessionKind::Root,
12389                _ => held.id = " creation-child ".into(),
12390            }
12391            assert!(
12392                canonical_child_creation(&binding, &held, &job)
12393                    .await
12394                    .is_err(),
12395                "damage {damage}"
12396            );
12397        }
12398        store.delete_session(&child.id).await.unwrap();
12399        assert!(canonical_child_creation(&binding, &child, &job)
12400            .await
12401            .is_err());
12402        let mut replacement = Session::new_child_of(&child.id, &parent, "model", "replacement");
12403        replacement.set_project_id_meta("creation-project");
12404        assert_ne!(replacement.created_at, child.created_at);
12405        store.save_session(&replacement).await.unwrap();
12406        assert!(
12407            canonical_child_creation(&binding, &child, &job)
12408                .await
12409                .is_err(),
12410            "old held spec cannot adopt replacement birth"
12411        );
12412        canonical_child_creation(&binding, &replacement, &job)
12413            .await
12414            .unwrap();
12415    }
12416
12417    #[tokio::test]
12418    async fn actor_immediate_claims_keep_exact_policy_across_mixed_order_ack_and_restart() {
12419        use bamboo_domain::{SessionActivationPolicy, SessionInboxLimits, SessionMessageEnvelope};
12420        for policies in [
12421            [
12422                SessionActivationPolicy::RespectSpecificWait,
12423                SessionActivationPolicy::InterruptSpecificWait,
12424            ],
12425            [
12426                SessionActivationPolicy::InterruptSpecificWait,
12427                SessionActivationPolicy::RespectSpecificWait,
12428            ],
12429        ] {
12430            let temp = tempfile::tempdir().unwrap();
12431            let store = Arc::new(
12432                bamboo_storage::SessionStoreV2::new(temp.path().into())
12433                    .await
12434                    .unwrap(),
12435            );
12436            let session_id = "actor-mixed-intent-policy";
12437            store
12438                .save_session(&Session::new(session_id, "model"))
12439                .await
12440                .unwrap();
12441            let inbox = bamboo_storage::FileSessionInbox::new(store, SessionInboxLimits::default());
12442            let staged = SessionMessageEnvelope::user_input(session_id, "staged sibling");
12443            let staged_receipt = inbox.deliver(&staged).await.unwrap();
12444            let mut sent = Vec::new();
12445            for policy in policies {
12446                let envelope = SessionMessageEnvelope::user_input(session_id, "immediate");
12447                let receipt = inbox
12448                    .deliver_with_activation_intent(&envelope, policy, None)
12449                    .await
12450                    .unwrap();
12451                sent.push((envelope, policy, receipt));
12452            }
12453            let restarted = Arc::new(
12454                bamboo_storage::SessionStoreV2::new(temp.path().into())
12455                    .await
12456                    .unwrap(),
12457            );
12458            let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12459                restarted.clone(),
12460                SessionInboxLimits::default(),
12461            ));
12462            let binding = actor_binding(
12463                restarted.clone(),
12464                inbox.clone(),
12465                Arc::new(bamboo_storage::LockedSessionStore::new(restarted.clone())),
12466            );
12467            let mut session = restarted.load_session(session_id).await.unwrap().unwrap();
12468            let pairs =
12469                claim_canonical_deliveries(&binding, &mut session, "run-1", usize::MAX, None)
12470                    .await
12471                    .unwrap();
12472            assert_eq!(pairs.len(), 2, "the staged sibling has no permission");
12473            for ((claim, delivery), (envelope, policy, receipt)) in pairs.iter().zip(&sent) {
12474                assert_eq!(claim.envelope.id, envelope.id);
12475                assert_eq!(claim.generation, receipt.generation);
12476                assert_eq!(claim.activation_policy, *policy);
12477                assert_eq!(delivery.activation_policy, *policy);
12478                checkpoint_and_ack_canonical_claim(&binding, &mut session, claim, None)
12479                    .await
12480                    .unwrap();
12481            }
12482            let restarted = Arc::new(
12483                bamboo_storage::SessionStoreV2::new(temp.path().into())
12484                    .await
12485                    .unwrap(),
12486            );
12487            let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12488                restarted.clone(),
12489                SessionInboxLimits::default(),
12490            ));
12491            let binding = actor_binding(
12492                restarted.clone(),
12493                inbox.clone(),
12494                Arc::new(bamboo_storage::LockedSessionStore::new(restarted.clone())),
12495            );
12496            session = restarted.load_session(session_id).await.unwrap().unwrap();
12497            for (envelope, policy, receipt) in &sent {
12498                assert_eq!(
12499                    inbox
12500                        .deliver_with_activation_intent(envelope, *policy, None)
12501                        .await
12502                        .unwrap(),
12503                    *receipt
12504                );
12505            }
12506            assert!(
12507                claim_canonical_deliveries(&binding, &mut session, "run-2", usize::MAX, None)
12508                    .await
12509                    .unwrap()
12510                    .is_empty()
12511            );
12512            inbox
12513                .mark_activation_eligible(
12514                    session_id,
12515                    staged_receipt.generation,
12516                    SessionActivationPolicy::RespectSpecificWait,
12517                )
12518                .await
12519                .unwrap();
12520            let pairs =
12521                claim_canonical_deliveries(&binding, &mut session, "run-2", usize::MAX, None)
12522                    .await
12523                    .unwrap();
12524            assert_eq!(pairs.len(), 1);
12525            assert_eq!(pairs[0].0.envelope.id, staged.id);
12526            assert_eq!(
12527                pairs[0].1.activation_policy,
12528                SessionActivationPolicy::RespectSpecificWait
12529            );
12530        }
12531    }
12532
12533    #[tokio::test]
12534    async fn actor_mismatched_typed_marker_id_collision_never_acks() {
12535        let (_temp, store, locked, inbox, mut session, claim) =
12536            actor_inbox_fixture("actor-live-id-collision").await;
12537        let mut forged = claim.envelope.to_provider_message().unwrap();
12538        forged.metadata = Some(serde_json::json!({
12539            "session_message": {
12540                "id": claim.envelope.id,
12541                "target_session_id": "different-session"
12542            }
12543        }));
12544        session.add_message(forged);
12545        let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12546        let binding = actor_binding(store, inbox.clone(), persistence);
12547
12548        assert!(
12549            checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12550                .await
12551                .is_err()
12552        );
12553        assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12554        assert!(!inbox
12555            .was_admitted(&session.id, &claim.envelope.id)
12556            .await
12557            .unwrap());
12558    }
12559
12560    #[tokio::test]
12561    async fn actor_concurrent_durable_id_collision_after_claim_never_acks() {
12562        let (_temp, store, locked, inbox, mut session, claim) =
12563            actor_inbox_fixture("actor-durable-id-collision").await;
12564        let mut concurrent = store.load_session(&session.id).await.unwrap().unwrap();
12565        let mut forged = bamboo_agent_core::Message::user("concurrent actor collision");
12566        forged.id = claim.envelope.id.to_string();
12567        concurrent.add_message(forged);
12568        store.save_session(&concurrent).await.unwrap();
12569        let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12570        let binding = actor_binding(store.clone(), inbox.clone(), persistence);
12571
12572        assert!(
12573            checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12574                .await
12575                .is_err()
12576        );
12577        assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12578        assert!(!inbox
12579            .was_admitted(&session.id, &claim.envelope.id)
12580            .await
12581            .unwrap());
12582        let durable = store.load_session(&session.id).await.unwrap().unwrap();
12583        assert!(!durable.messages.iter().any(|message| {
12584            bamboo_domain::is_matching_session_message(message, &claim.envelope)
12585        }));
12586    }
12587
12588    #[tokio::test]
12589    async fn actor_concurrent_durable_typed_body_mismatch_never_acks() {
12590        let (_temp, store, locked, inbox, mut session, claim) =
12591            actor_inbox_fixture("actor-durable-typed-body-collision").await;
12592        let mut different = claim.envelope.clone();
12593        different.body = bamboo_domain::SessionMessageBody::Content(
12594            bamboo_domain::SessionMessageContent::text("forged actor body"),
12595        );
12596        let mut concurrent = store.load_session(&session.id).await.unwrap().unwrap();
12597        concurrent.add_message(different.to_provider_message().unwrap());
12598        store.save_session(&concurrent).await.unwrap();
12599        let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12600        let binding = actor_binding(store.clone(), inbox.clone(), persistence);
12601
12602        assert!(
12603            checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12604                .await
12605                .is_err()
12606        );
12607        assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12608        assert!(!inbox
12609            .was_admitted(&session.id, &claim.envelope.id)
12610            .await
12611            .unwrap());
12612        let durable = store.load_session(&session.id).await.unwrap().unwrap();
12613        assert!(!durable
12614            .messages
12615            .iter()
12616            .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope)));
12617    }
12618
12619    #[tokio::test]
12620    async fn actor_checkpoint_failure_rolls_back_and_restart_admits_once() {
12621        let (_temp, store, locked, inbox, mut session, claim) =
12622            actor_inbox_fixture("actor-checkpoint-failure").await;
12623        let envelope_id = claim.envelope.id.clone();
12624        let fault: Arc<dyn RuntimeSessionPersistence> = Arc::new(ActorFaultingPersistence {
12625            inner: locked.clone(),
12626            fail_checkpoint_once: std::sync::atomic::AtomicBool::new(true),
12627        });
12628        let binding = actor_binding(store.clone(), inbox.clone(), fault);
12629
12630        assert!(
12631            checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12632                .await
12633                .is_err()
12634        );
12635        assert!(!session
12636            .messages
12637            .iter()
12638            .any(|message| message.id == envelope_id.as_str()));
12639        assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12640        assert!(!inbox.was_admitted(&session.id, &envelope_id).await.unwrap());
12641
12642        let reopened: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12643            store.clone(),
12644            bamboo_domain::SessionInboxLimits::default(),
12645        ));
12646        let recovered = reopened.claim(&session.id, 1).await.unwrap().remove(0);
12647        let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12648        let binding = actor_binding(store.clone(), reopened.clone(), persistence);
12649        let mut restarted = store.load_session(&session.id).await.unwrap().unwrap();
12650        checkpoint_and_ack_canonical_claim(&binding, &mut restarted, &recovered, None)
12651            .await
12652            .unwrap();
12653        assert_eq!(
12654            restarted
12655                .messages
12656                .iter()
12657                .filter(|message| message.id == envelope_id.as_str())
12658                .count(),
12659            1
12660        );
12661        assert!(reopened
12662            .was_admitted(&session.id, &envelope_id)
12663            .await
12664            .unwrap());
12665        let backlog = reopened.inspect(&session.id).await.unwrap();
12666        assert_eq!(backlog.pending + backlog.claimed, 0);
12667    }
12668
12669    #[tokio::test]
12670    async fn actor_checkpoint_success_pre_ack_failure_recovers_without_duplicate() {
12671        let (_temp, store, locked, real_inbox, mut session, claim) =
12672            actor_inbox_fixture("actor-pre-ack-failure").await;
12673        let envelope_id = claim.envelope.id.clone();
12674        let faulted: Arc<dyn SessionInboxPort> = Arc::new(ActorFailBeforeAckInbox {
12675            inner: real_inbox.clone(),
12676            fail_once: std::sync::atomic::AtomicBool::new(true),
12677        });
12678        let persistence: Arc<dyn RuntimeSessionPersistence> = locked.clone();
12679        let binding = actor_binding(store.clone(), faulted, persistence);
12680
12681        assert!(
12682            checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12683                .await
12684                .is_err()
12685        );
12686        let durable = store.load_session(&session.id).await.unwrap().unwrap();
12687        assert_eq!(
12688            durable
12689                .messages
12690                .iter()
12691                .filter(|message| message.id == envelope_id.as_str())
12692                .count(),
12693            1
12694        );
12695        assert_eq!(real_inbox.inspect(&session.id).await.unwrap().claimed, 1);
12696        assert!(!real_inbox
12697            .was_admitted(&session.id, &envelope_id)
12698            .await
12699            .unwrap());
12700
12701        let reopened: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12702            store.clone(),
12703            bamboo_domain::SessionInboxLimits::default(),
12704        ));
12705        let recovered = reopened.claim(&session.id, 1).await.unwrap().remove(0);
12706        let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12707        let binding = actor_binding(store.clone(), reopened.clone(), persistence);
12708        let mut restarted = durable;
12709        checkpoint_and_ack_canonical_claim(&binding, &mut restarted, &recovered, None)
12710            .await
12711            .unwrap();
12712        assert_eq!(
12713            restarted
12714                .messages
12715                .iter()
12716                .filter(|message| message.id == envelope_id.as_str())
12717                .count(),
12718            1
12719        );
12720        assert!(reopened
12721            .was_admitted(&session.id, &envelope_id)
12722            .await
12723            .unwrap());
12724        let backlog = reopened.inspect(&session.id).await.unwrap();
12725        assert_eq!(backlog.pending + backlog.claimed, 0);
12726    }
12727
12728    struct ConfirmationSequenceLink {
12729        frames: VecDeque<ChildFrame>,
12730        sent: Vec<ParentFrame>,
12731    }
12732
12733    #[async_trait]
12734    impl bamboo_subagent::ChildLink for ConfirmationSequenceLink {
12735        async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
12736            self.sent.push(frame);
12737            Ok(())
12738        }
12739
12740        async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
12741            match self.frames.pop_front() {
12742                Some(frame) => Ok(Some(frame)),
12743                None => std::future::pending().await,
12744            }
12745        }
12746    }
12747
12748    #[tokio::test]
12749    async fn failed_actor_retry_checkpoints_new_input_and_rejects_old_or_stale_state() {
12750        for case in [
12751            "accepted",
12752            "readonly-refusal",
12753            "prefix-refusal",
12754            "old-claim",
12755            "old-checkpoint",
12756            "succeeded",
12757            "changed-birth",
12758            "oversize",
12759        ] {
12760            let temp = tempfile::tempdir().unwrap();
12761            let store = Arc::new(
12762                bamboo_storage::SessionStoreV2::new(temp.path().into())
12763                    .await
12764                    .unwrap(),
12765            );
12766            let parent = Session::new("retry-parent", "model");
12767            store.save_session(&parent).await.unwrap();
12768            let mut child =
12769                Session::new_child_of("retry-child", &parent, "model", "original assignment");
12770            child.add_message(bamboo_agent_core::Message::user("original assignment"));
12771            store.save_session(&child).await.unwrap();
12772            let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
12773            bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
12774            let locked = Arc::new(bamboo_storage::LockedSessionStore::new(store.clone()));
12775            let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12776                store.clone(),
12777                bamboo_domain::SessionInboxLimits::default(),
12778            ));
12779            let binding = actor_binding(store.clone(), inbox, locked);
12780            let registration = binding
12781                .router
12782                .register_run(&child.id, "first-run")
12783                .await
12784                .unwrap();
12785            let first = PlainActorActivation::start(
12786                store.clone(),
12787                &child,
12788                &binding,
12789                Some("first-run"),
12790                Some(policy.clone()),
12791                true,
12792                None,
12793            )
12794            .await
12795            .unwrap();
12796            if matches!(case, "old-claim" | "old-checkpoint") {
12797                let old = bamboo_domain::SessionMessageEnvelope::user_input(
12798                    &child.id,
12799                    "unconfirmed old input",
12800                );
12801                let receipt = first.input_inbox.deliver(&old).await.unwrap();
12802                first
12803                    .input_inbox
12804                    .mark_activation_eligible(
12805                        &child.id,
12806                        receipt.generation,
12807                        bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
12808                    )
12809                    .await
12810                    .unwrap();
12811                let claim = first
12812                    .claim_input(&binding, &child, "first-run")
12813                    .await
12814                    .unwrap()
12815                    .unwrap();
12816                if case == "old-checkpoint" {
12817                    first
12818                        .checkpoint_input(&mut child, "first-run", &claim)
12819                        .await
12820                        .unwrap();
12821                }
12822            }
12823            first
12824                .finish(if case == "succeeded" {
12825                    ActorActivationFinish::Succeeded
12826                } else {
12827                    ActorActivationFinish::Failed
12828                })
12829                .await
12830                .unwrap();
12831            drop(registration);
12832            let envelope = bamboo_domain::SessionMessageEnvelope::user_input(
12833                &child.id,
12834                if case == "oversize" {
12835                    "x".repeat(8193)
12836                } else {
12837                    "NEW_CORRECTION".into()
12838                },
12839            );
12840            let receipt = first.input_inbox.deliver(&envelope).await.unwrap();
12841            first
12842                .input_inbox
12843                .mark_activation_eligible(
12844                    &child.id,
12845                    receipt.generation,
12846                    bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
12847                )
12848                .await
12849                .unwrap();
12850            let before = store.load_session(&child.id).await.unwrap().unwrap();
12851            let expected_messages = serde_json::to_value(&before.messages).unwrap();
12852            child = before.clone();
12853            if case == "prefix-refusal" {
12854                child.messages.last_mut().unwrap().content = "stale cached prefix".into();
12855            }
12856            if case == "changed-birth" {
12857                child.created_at += chrono::Duration::milliseconds(1);
12858            }
12859            let _retry_registration = binding
12860                .router
12861                .register_run(&child.id, "retry-run")
12862                .await
12863                .unwrap();
12864            let result = PlainActorActivation::start_and_prepare(
12865                store.clone(),
12866                &mut child,
12867                &binding,
12868                Some("retry-run"),
12869                Some(policy.clone()),
12870                case != "readonly-refusal",
12871                None,
12872            )
12873            .await;
12874            if case != "accepted" {
12875                assert!(result.is_err(), "{case}: no retry Run may be dispatched");
12876                let actual = store.load_session(&before.id).await.unwrap().unwrap();
12877                assert_eq!(
12878                    serde_json::to_value(&actual.messages).unwrap(),
12879                    expected_messages,
12880                    "{case}: failed preparation preserves Main history"
12881                );
12882                assert!(
12883                    !first
12884                        .input_inbox
12885                        .was_admitted(&before.id, &envelope.id)
12886                        .await
12887                        .unwrap(),
12888                    "{case}: no ACK"
12889                );
12890                if case == "readonly-refusal" {
12891                    let entry = store.inspect_actor(&before.id).await.unwrap();
12892                    assert_eq!(entry.actor.current_attempt, 1);
12893                    assert_eq!(
12894                        entry.activation.unwrap().activation_id,
12895                        first.fence.activation_id
12896                    );
12897                    let backlog = first.input_inbox.inspect(&before.id).await.unwrap();
12898                    assert_eq!((backlog.pending, backlog.claimed), (1, 0));
12899                }
12900                if matches!(case, "prefix-refusal" | "changed-birth" | "oversize") {
12901                    assert_eq!(
12902                        first.input_inbox.inspect(&before.id).await.unwrap().claimed,
12903                        1
12904                    );
12905                    assert_eq!(
12906                        store
12907                            .inspect_actor(&before.id)
12908                            .await
12909                            .unwrap()
12910                            .activation
12911                            .unwrap()
12912                            .status,
12913                        bamboo_domain::ActorActivationStatus::Failed
12914                    );
12915                }
12916                if case == "oversize" {
12917                    let reopened = Arc::new(
12918                        bamboo_storage::SessionStoreV2::new(temp.path().into())
12919                            .await
12920                            .unwrap(),
12921                    );
12922                    let cold_inbox = bamboo_storage::FileSessionInbox::new(
12923                        reopened,
12924                        bamboo_domain::SessionInboxLimits::default(),
12925                    );
12926                    let now = chrono::Utc::now();
12927                    let leases = cold_inbox
12928                        .inspect_owned_leases(&before.id, 2, now)
12929                        .await
12930                        .unwrap();
12931                    assert_eq!(leases.len(), 1);
12932                    assert_eq!(leases[0].failure_count, 1);
12933                    assert_eq!(
12934                        leases[0].last_error_code.as_deref(),
12935                        Some("unsupported_actor_input")
12936                    );
12937                    let retry_after = leases[0].retry_after.unwrap();
12938                    assert!(retry_after > now);
12939                    let readiness = cold_inbox
12940                        .inspect_wake_readiness(&before.id, now)
12941                        .await
12942                        .unwrap();
12943                    assert!(
12944                        readiness.ready.is_none(),
12945                        "failed correction must not hot-loop"
12946                    );
12947                    assert_eq!(readiness.next_due_at, Some(retry_after));
12948                }
12949                continue;
12950            }
12951            let (retry, prepared) = result.unwrap();
12952            let (prefix, delivery) = prepared.unwrap();
12953            assert_eq!(
12954                prefix,
12955                serde_json::to_value(&before.messages)
12956                    .unwrap()
12957                    .as_array()
12958                    .unwrap()
12959                    .clone()
12960            );
12961            assert_eq!(delivery.envelope, envelope);
12962            assert_eq!(delivery.activation_run_id, "retry-run");
12963            assert_eq!(delivery.canonical_claim_generation, receipt.generation);
12964            assert!(store
12965                .validate_fence(&first.fence, chrono::Utc::now())
12966                .await
12967                .is_err());
12968            let running = store.inspect_actor(&child.id).await.unwrap();
12969            assert_eq!(running.actor.current_attempt, 2);
12970            assert_eq!(
12971                running.activation.as_ref().unwrap().status,
12972                bamboo_domain::ActorActivationStatus::Running
12973            );
12974            assert!(running.activation.unwrap().lease_epoch > first.fence.lease_epoch);
12975            let claim = retry.initial_input.as_ref().unwrap();
12976            assert!(child
12977                .session_inbox_admission()
12978                .unwrap()
12979                .contains(&envelope.id));
12980            assert!(
12981                !retry
12982                    .input_inbox
12983                    .was_admitted(&child.id, &envelope.id)
12984                    .await
12985                    .unwrap(),
12986                "checkpoint is not startup confirmation"
12987            );
12988            let mut confirmation = admission_confirmation(&child.id, &claim.claim, "retry-run");
12989            confirmation.activation_run_id = "first-run".into();
12990            assert!(retry
12991                .confirm_input(&binding, &child, "retry-run", claim, &confirmation)
12992                .await
12993                .is_err());
12994            assert!(!retry
12995                .input_inbox
12996                .was_admitted(&child.id, &envelope.id)
12997                .await
12998                .unwrap());
12999            confirmation.activation_run_id = "retry-run".into();
13000            retry
13001                .confirm_input(&binding, &child, "retry-run", claim, &confirmation)
13002                .await
13003                .unwrap();
13004            let (tx, mut rx) = mpsc::channel(1);
13005            retry
13006                .append_reply(&mut child, "RETRY_REPLY".into(), &tx)
13007                .await
13008                .unwrap();
13009            rx.recv().await.unwrap();
13010            retry
13011                .finish(ActorActivationFinish::Succeeded)
13012                .await
13013                .unwrap();
13014            let reopened = Arc::new(
13015                bamboo_storage::SessionStoreV2::new(temp.path().into())
13016                    .await
13017                    .unwrap(),
13018            );
13019            let cold = reopened.load_session(&child.id).await.unwrap().unwrap();
13020            assert_eq!(cold.created_at, before.created_at);
13021            assert_eq!(cold.messages.len(), before.messages.len() + 2);
13022            assert_eq!(
13023                cold.messages[before.messages.len()].id,
13024                envelope.id.as_str()
13025            );
13026            assert_eq!(cold.messages.last().unwrap().content, "RETRY_REPLY");
13027            assert_eq!(
13028                cold.messages
13029                    .iter()
13030                    .filter(|m| m.id == envelope.id.as_str())
13031                    .count(),
13032                1
13033            );
13034            let cold_inbox = bamboo_storage::FileSessionInbox::new(
13035                reopened,
13036                bamboo_domain::SessionInboxLimits::default(),
13037            );
13038            assert!(cold_inbox
13039                .was_admitted(&cold.id, &envelope.id)
13040                .await
13041                .unwrap());
13042            assert_eq!(cold_inbox.inspect(&cold.id).await.unwrap().claimed, 0);
13043        }
13044    }
13045
13046    #[tokio::test]
13047    async fn pre_ack_owned_recovery_requires_expiry_provenance_and_verified_already() {
13048        for case in [
13049            "accepted",
13050            "actor-live",
13051            "claim-live",
13052            "legacy",
13053            "ack",
13054            "readonly",
13055            "prefix",
13056            "duplicate",
13057            "missing-marker",
13058            "mixed",
13059            "project",
13060            "birth",
13061        ] {
13062            let temp = tempfile::tempdir().unwrap();
13063            let store = Arc::new(
13064                bamboo_storage::SessionStoreV2::new(temp.path().into())
13065                    .await
13066                    .unwrap(),
13067            );
13068            let parent = Session::new("recovery-parent", "model");
13069            store.save_session(&parent).await.unwrap();
13070            let mut child =
13071                Session::new_child_of("recovery-child", &parent, "model", "original task");
13072            child.add_message(bamboo_agent_core::Message::user("original task"));
13073            store.save_session(&child).await.unwrap();
13074            let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
13075            bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
13076            let inbox = bamboo_storage::FileSessionInbox::new(
13077                store.clone(),
13078                bamboo_domain::SessionInboxLimits::default(),
13079            );
13080            // Complete unfenced persistence before starting the setup lease window.
13081            let envelope =
13082                bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "RECOVER_SAME_INPUT");
13083            let receipt = inbox.deliver(&envelope).await.unwrap();
13084            inbox
13085                .mark_activation_eligible(
13086                    &child.id,
13087                    receipt.generation,
13088                    bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
13089                )
13090                .await
13091                .unwrap();
13092            let now = chrono::Utc::now();
13093            let cutoff = now + chrono::Duration::seconds(10);
13094            let consumer = SessionInboxConsumerId::new();
13095            let old = store
13096                .claim_activation(&ActorActivationClaim {
13097                    actor_id: child.id.clone(),
13098                    run_id: "old-run".into(),
13099                    lease_owner: consumer.as_str().into(),
13100                    lease_expires_at: if case == "actor-live" {
13101                        now + chrono::Duration::minutes(5)
13102                    } else {
13103                        cutoff
13104                    },
13105                    inbox_generation: 0,
13106                    placement_ref: if case == "legacy" {
13107                        None
13108                    } else {
13109                        Some(bamboo_domain::ActorPlacementRef {
13110                            class: bamboo_domain::ActorPlacementClass::Local,
13111                            lease_id: "owned-initial-release-v1:actual-old-worker".into(),
13112                            slot_epoch: None,
13113                        })
13114                    },
13115                    now,
13116                })
13117                .await
13118                .unwrap();
13119            store
13120                .start_activation(&old.fence(), chrono::Utc::now())
13121                .await
13122                .unwrap();
13123            let claim_now = chrono::Utc::now();
13124            let claim = inbox
13125                .claim_owned(
13126                    &child.id,
13127                    1,
13128                    Some("old-run"),
13129                    &SessionInboxLeaseRequest {
13130                        consumer,
13131                        now: claim_now,
13132                        duration: if case == "claim-live" {
13133                            chrono::Duration::minutes(5)
13134                        } else {
13135                            cutoff - claim_now
13136                        },
13137                    },
13138                )
13139                .await
13140                .unwrap()
13141                .pop()
13142                .unwrap();
13143            child = inbox
13144                .checkpoint_actor_input(bamboo_storage::ActorInputCheckpoint {
13145                    fence: old.fence(),
13146                    expected_created_at: child.created_at,
13147                    claim: claim.clone(),
13148                    expected_messages: child.messages.clone(),
13149                    expected_provider_transcript: child.provider_transcript.clone(),
13150                    expected_admission: None,
13151                })
13152                .await
13153                .unwrap()
13154                .session;
13155            // The real checkpoint must succeed under the original live authority.
13156            store
13157                .validate_fence(&old.fence(), chrono::Utc::now())
13158                .await
13159                .unwrap_or_else(|error| panic!("{case}: setup fence: {error:?}"));
13160            let setup_leases = inbox
13161                .inspect_owned_leases(&child.id, 2, chrono::Utc::now())
13162                .await
13163                .unwrap();
13164            assert_eq!(setup_leases.len(), 1, "{case}: original setup claim");
13165            assert_eq!(setup_leases[0].epoch, claim.lease.epoch, "{case}");
13166            assert_eq!(setup_leases[0].expires_at, claim.lease.expires_at, "{case}");
13167            assert!(!setup_leases[0].expired, "{case}: setup claim must be live");
13168            let setup_at = chrono::Utc::now();
13169            assert!(
13170                setup_at < old.lease_expires_at,
13171                "{case}: setup Actor expired"
13172            );
13173            assert!(
13174                setup_at < claim.lease.expires_at,
13175                "{case}: setup claim expired"
13176            );
13177            eprintln!("pre-ACK {case}: setup live at {setup_at}, cutoff {cutoff}");
13178            if case == "ack" {
13179                inbox
13180                    .ack_owned(&child.id, &claim, chrono::Utc::now())
13181                    .await
13182                    .unwrap();
13183            }
13184            if case == "mixed" {
13185                inbox
13186                    .deliver(&bamboo_domain::SessionMessageEnvelope::user_input(
13187                        &child.id,
13188                        "different input",
13189                    ))
13190                    .await
13191                    .unwrap();
13192            }
13193            let directory = store
13194                .bamboo_home_dir()
13195                .join(store.resolve_rel_path(&child.id).await.unwrap());
13196            let main = std::fs::read(directory.join("session.json")).unwrap();
13197            let canonical = child.clone();
13198            match case {
13199                "prefix" => child.messages[0].content.push_str(" stale"),
13200                "duplicate" => child.messages.push(child.messages.last().unwrap().clone()),
13201                "missing-marker" => {
13202                    child
13203                        .messages
13204                        .last_mut()
13205                        .unwrap()
13206                        .metadata
13207                        .as_mut()
13208                        .unwrap()
13209                        .as_object_mut()
13210                        .unwrap()
13211                        .remove("_bamboo_owned_input_checkpoint");
13212                }
13213                "project" => child.set_project_id_meta(bamboo_domain::ProjectId::new().to_string()),
13214                "birth" => child.created_at += chrono::Duration::nanoseconds(1),
13215                _ => {}
13216            }
13217            // One shared short deadline per case; never wait for the live controls.
13218            // No reclaim, renewal or authority-file edit occurs between these stages.
13219            let delay = (cutoff - chrono::Utc::now()).to_std().unwrap_or_default();
13220            tokio::time::sleep(delay + Duration::from_millis(10)).await;
13221            let recovery_at = chrono::Utc::now();
13222            assert!(recovery_at >= cutoff, "{case}: real expiry is required");
13223            let before_recovery = store.inspect_actor(&child.id).await.unwrap();
13224            let original = before_recovery.activation.unwrap();
13225            assert_eq!(original.fence(), old.fence(), "{case}: same authority");
13226            assert_eq!(original.lease_expires_at, old.lease_expires_at, "{case}");
13227            assert_eq!(
13228                store
13229                    .validate_fence(&old.fence(), recovery_at)
13230                    .await
13231                    .is_ok(),
13232                case == "actor-live",
13233                "{case}: Actor expiry control"
13234            );
13235            let recovery_leases = inbox
13236                .inspect_owned_leases(&child.id, 2, recovery_at)
13237                .await
13238                .unwrap();
13239            if case == "ack" {
13240                assert!(recovery_leases.is_empty(), "{case}: terminal ACK");
13241            } else {
13242                assert_eq!(recovery_leases.len(), 1, "{case}: original recovery claim");
13243                assert_eq!(recovery_leases[0].epoch, claim.lease.epoch, "{case}");
13244                assert_eq!(
13245                    recovery_leases[0].expires_at, claim.lease.expires_at,
13246                    "{case}"
13247                );
13248                assert_eq!(recovery_leases[0].expired, case != "claim-live", "{case}");
13249            }
13250            eprintln!("pre-ACK {case}: recovery expiry control at {recovery_at}");
13251            let second = Arc::new(
13252                bamboo_storage::SessionStoreV2::new(temp.path().into())
13253                    .await
13254                    .unwrap(),
13255            );
13256            let binding = actor_binding(
13257                second.clone(),
13258                Arc::new(bamboo_storage::FileSessionInbox::new(
13259                    second.clone(),
13260                    bamboo_domain::SessionInboxLimits::default(),
13261                )),
13262                Arc::new(bamboo_storage::LockedSessionStore::new(second.clone())),
13263            );
13264            let _owner = binding
13265                .router
13266                .register_run(&child.id, "replacement-run")
13267                .await
13268                .unwrap();
13269            let prepared = PlainActorActivation::start_and_prepare(
13270                second.clone(),
13271                &mut child,
13272                &binding,
13273                Some("replacement-run"),
13274                Some(policy.clone()),
13275                case != "readonly",
13276                Some("probed-new-worker"),
13277            )
13278            .await;
13279            assert_eq!(
13280                std::fs::read(directory.join("session.json")).unwrap(),
13281                main,
13282                "{case}: Already or refusal never rewrites Main"
13283            );
13284            if case != "accepted" {
13285                assert!(prepared.is_err(), "{case}");
13286                assert_eq!(
13287                    inbox.was_admitted(&child.id, &envelope.id).await.unwrap(),
13288                    case == "ack"
13289                );
13290                continue;
13291            }
13292            let (replacement, delivery) = prepared.unwrap();
13293            assert!(replacement.recovering_pre_ack);
13294            let (messages, delivery) = delivery.unwrap();
13295            let new_claim = replacement.initial_input.as_ref().unwrap();
13296            assert_eq!(new_claim.claim.envelope, claim.claim.envelope);
13297            assert_eq!(new_claim.claim.generation, claim.claim.generation);
13298            assert_eq!(
13299                new_claim.claim.activation_policy,
13300                claim.claim.activation_policy
13301            );
13302            assert!(new_claim.lease.epoch > claim.lease.epoch);
13303            assert_ne!(new_claim.lease.incarnation, claim.lease.incarnation);
13304            assert_eq!(
13305                replacement.inbox_generation, 1,
13306                "old Actor generation was zero"
13307            );
13308            assert_eq!(
13309                second
13310                    .inspect_actor(&child.id)
13311                    .await
13312                    .unwrap()
13313                    .actor
13314                    .current_attempt,
13315                2
13316            );
13317            assert!(second
13318                .validate_fence(&old.fence(), chrono::Utc::now())
13319                .await
13320                .is_err());
13321            assert!(inbox
13322                .ack_owned(&child.id, &claim, chrono::Utc::now())
13323                .await
13324                .is_err());
13325            // The original Store/claim survives the handoff. Even with the
13326            // current exact prefix and dedupe cursor, its delayed checkpoint
13327            // must fail on authority before AlreadyCheckpointed can succeed.
13328            let authority = second.inspect_actor(&child.id).await.unwrap();
13329            let leases = inbox
13330                .inspect_owned_leases(&child.id, 2, chrono::Utc::now())
13331                .await
13332                .unwrap();
13333            let delayed = inbox
13334                .checkpoint_actor_input(bamboo_storage::ActorInputCheckpoint {
13335                    fence: old.fence(),
13336                    expected_created_at: child.created_at,
13337                    claim: claim.clone(),
13338                    expected_messages: child.messages.clone(),
13339                    expected_provider_transcript: child.provider_transcript.clone(),
13340                    expected_admission: child.session_inbox_admission().cloned(),
13341                })
13342                .await;
13343            assert!(matches!(
13344                delayed,
13345                Err(bamboo_storage::ActorInputCheckpointError::Actor(
13346                    bamboo_domain::ActorDirectoryError::StaleFence
13347                ))
13348            ));
13349            assert_eq!(std::fs::read(directory.join("session.json")).unwrap(), main);
13350            assert_eq!(second.inspect_actor(&child.id).await.unwrap(), authority);
13351            assert_eq!(
13352                inbox
13353                    .inspect_owned_leases(&child.id, 2, chrono::Utc::now())
13354                    .await
13355                    .unwrap(),
13356                leases
13357            );
13358            assert!(!inbox.was_admitted(&child.id, &envelope.id).await.unwrap());
13359            assert_eq!(
13360                serde_json::to_value(&child.messages).unwrap(),
13361                serde_json::to_value(&canonical.messages).unwrap()
13362            );
13363            let mut worker: Vec<bamboo_agent_core::Message> = messages
13364                .iter()
13365                .cloned()
13366                .map(|v| serde_json::from_value(v).unwrap())
13367                .collect();
13368            let target = worker.last_mut().unwrap();
13369            assert!(target
13370                .metadata
13371                .as_ref()
13372                .unwrap()
13373                .get("_bamboo_owned_input_checkpoint")
13374                .is_none());
13375            assert!(bamboo_domain::is_matching_session_message(
13376                target, &envelope
13377            ));
13378            let mut wanted = canonical.messages.last().unwrap().clone();
13379            wanted
13380                .metadata
13381                .as_mut()
13382                .unwrap()
13383                .as_object_mut()
13384                .unwrap()
13385                .remove("_bamboo_owned_input_checkpoint");
13386            assert_eq!(
13387                serde_json::to_value(target).unwrap(),
13388                serde_json::to_value(wanted).unwrap()
13389            );
13390            let run = RunSpec {
13391                assignment: "original task".into(),
13392                logical_session: Some(LogicalSessionIdentity {
13393                    session_id: child.id.clone(),
13394                    parent_session_id: child.parent_session_id.clone(),
13395                    root_session_id: child.root_session_id.clone(),
13396                    creation: Some(bamboo_subagent::proto::ChildCreationIdentity {
13397                        created_at: child.created_at,
13398                        spawn_depth: child.spawn_depth,
13399                    }),
13400                }),
13401                project_id: None,
13402                reasoning_effort: None,
13403                permission_policy: Some(PermissionPolicyContext {
13404                    revision: policy.policy_revision(),
13405                    requested_mode: "default".into(),
13406                    effective_mode: "default".into(),
13407                    bypass_permissions: false,
13408                    auto_approve_permissions: false,
13409                    session_id: child.id.clone(),
13410                    workspace_path: child.workspace.clone(),
13411                    environment_lease: None,
13412                    inherit_session_grants: false,
13413                    policy: serde_json::to_value(policy.to_serializable()).unwrap(),
13414                }),
13415                messages,
13416                activation_run_id: Some("replacement-run".into()),
13417                execution_epoch: 2,
13418                initial_session_messages: vec![delivery.clone()],
13419                secrets: Default::default(),
13420            };
13421            let policy_revision = policy.policy_revision();
13422            let mut expected = Some(expected_default_permission_posture(policy_revision));
13423            let ChildFrame::Event { event } = permission_posture_frame(&child.id, policy_revision)
13424            else {
13425                unreachable!()
13426            };
13427            let (events, mut rx) = mpsc::channel(1);
13428            process_actor_event(
13429                event,
13430                true,
13431                &mut PermissionPostureHandshake::Awaiting,
13432                expected.as_ref(),
13433                Some(&binding),
13434                &mut child,
13435                &events,
13436                &mut ActorEventDisplay::default(),
13437            )
13438            .await
13439            .unwrap();
13440            rx.recv().await.unwrap();
13441            let request = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
13442                &run,
13443                &delivery,
13444                uuid::Uuid::new_v4().to_string(),
13445            )
13446            .unwrap();
13447            replacement
13448                .release_initial_input(
13449                    &binding,
13450                    &child,
13451                    &run,
13452                    2,
13453                    new_claim,
13454                    &request,
13455                    &mut expected,
13456                    None,
13457                )
13458                .await
13459                .unwrap();
13460            assert!(inbox.was_admitted(&child.id, &envelope.id).await.unwrap());
13461            replacement
13462                .append_reply(&mut child, "RECOVERY_REPLY".into(), &events)
13463                .await
13464                .unwrap();
13465            rx.recv().await.unwrap();
13466            replacement
13467                .finish(ActorActivationFinish::Succeeded)
13468                .await
13469                .unwrap();
13470            let cold = second.load_session(&child.id).await.unwrap().unwrap();
13471            assert_eq!(cold.created_at, canonical.created_at);
13472            assert_eq!(
13473                serde_json::to_value(&cold.messages[..canonical.messages.len()]).unwrap(),
13474                serde_json::to_value(&canonical.messages).unwrap()
13475            );
13476            assert_eq!(cold.messages.last().unwrap().content, "RECOVERY_REPLY");
13477            assert_eq!(
13478                cold.messages
13479                    .iter()
13480                    .filter(|m| m.id == envelope.id.as_str())
13481                    .count(),
13482                1
13483            );
13484        }
13485    }
13486
13487    #[cfg(unix)]
13488    #[tokio::test]
13489    async fn owned_initial_release_missing_only_new_capability_rejects_before_spawn_or_run() {
13490        use bamboo_subagent::provision::*;
13491        use std::os::unix::fs::PermissionsExt;
13492        let temp = tempfile::tempdir().unwrap();
13493        let worker = temp.path().join("mixed-worker");
13494        let spawned = temp.path().join("spawned");
13495        let run = temp.path().join("run");
13496        let probes = temp.path().join("probes");
13497        let fabric = temp.path().join("fabric");
13498        let mut report = WorkerCapabilityReport::current();
13499        report
13500            .capabilities
13501            .retain(|name| name != INITIAL_INPUT_RELEASE_WORKER_CAPABILITY);
13502        assert!(!report.supports(INITIAL_INPUT_RELEASE_WORKER_CAPABILITY));
13503        let report = serde_json::to_string(&report).unwrap();
13504        std::fs::write(&worker, format!(
13505            "#!/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"
13506        )).unwrap();
13507        std::fs::set_permissions(&worker, std::fs::Permissions::from_mode(0o700)).unwrap();
13508        let args = vec![
13509            spawned.to_str().unwrap().into(),
13510            run.to_str().unwrap().into(),
13511            probes.to_str().unwrap().into(),
13512        ];
13513        // Real subprocess probes prove the rejection cannot be an earlier missing bit.
13514        for capability in [
13515            REQUIRED_CHILD_CONTEXT_WORKER_CAPABILITY,
13516            CHILD_CREATION_IDENTITY_WORKER_CAPABILITY,
13517            NATIVE_TOOL_CEILING_WORKER_CAPABILITY,
13518            TYPED_READ_ONLY_WORKER_CAPABILITY,
13519        ] {
13520            bamboo_subagent::fleet::require_worker_capability(&worker, &args, capability)
13521                .await
13522                .unwrap();
13523        }
13524        let runner = ActorChildRunner::new(
13525            "mixed".into(),
13526            worker,
13527            args,
13528            fabric.clone(),
13529            ExecutorSpec::BambooRuntime,
13530            vec![],
13531            "openai".into(),
13532            1,
13533        );
13534        // The actual Host method remains mandatory before claim/provision/Run.
13535        let error = runner
13536            .require_initial_input_release_worker()
13537            .await
13538            .unwrap_err();
13539        assert!(error
13540            .to_string()
13541            .contains("owned initial release worker capability unconfirmed"));
13542        assert_eq!(std::fs::read_to_string(probes).unwrap().lines().count(), 5);
13543        assert!(!spawned.exists() && !run.exists() && !fabric.exists());
13544        assert!(runner.pool.lock().await.is_empty());
13545    }
13546
13547    #[tokio::test]
13548    async fn ordinary_glob_only_observes_own_initialization_signals() {
13549        let temp = tempfile::tempdir().unwrap();
13550        let home = temp.path().canonicalize().unwrap();
13551        let store = bamboo_storage::SessionStoreV2::new(home.clone())
13552            .await
13553            .unwrap();
13554        let parent = Session::new("glob-standard-parent", "model");
13555        store.save_session(&parent).await.unwrap();
13556        let child = Session::new_child_of("glob-standard-child", &parent, "model", "one Glob");
13557        store.save_session(&child).await.unwrap();
13558        let directory = home.join(store.resolve_rel_path(&child.id).await.unwrap());
13559        // An unrelated bad child would reject a full tree snapshot, but is not
13560        // part of this own-session no-replay observation.
13561        let unrelated = directory.parent().unwrap().join("unrelated");
13562        std::fs::create_dir(&unrelated).unwrap();
13563        std::fs::write(unrelated.join("session.json"), b"invalid").unwrap();
13564        require_unowned_glob(&store, &child).await.unwrap();
13565        for name in ["actor-authority.json", "actor-authority.initialized.json"] {
13566            let path = directory.join(name);
13567            std::fs::write(&path, b"invalid prior signal").unwrap();
13568            assert!(require_unowned_glob(&store, &child).await.is_err());
13569            std::fs::remove_file(&path).unwrap();
13570        }
13571        #[cfg(unix)]
13572        {
13573            let marker = directory.join("actor-authority.initialized.json");
13574            std::os::unix::fs::symlink(directory.join("missing"), &marker).unwrap();
13575            assert!(require_unowned_glob(&store, &child).await.is_err());
13576        }
13577    }
13578
13579    #[test]
13580    fn owned_glob_collector_requires_actual_ordered_tool_trace_and_typed_tail() {
13581        let call = bamboo_domain::ToolCall {
13582            id: "glob".into(),
13583            tool_type: "function".into(),
13584            function: bamboo_domain::FunctionCall {
13585                name: "Glob".into(),
13586                arguments: r#"{"pattern":"marker.txt"}"#.into(),
13587            },
13588        };
13589        let messages = vec![
13590            bamboo_agent_core::Message::assistant("", Some(vec![call])),
13591            bamboo_agent_core::Message::tool_result("glob", "marker.txt"),
13592            bamboo_agent_core::Message::assistant("done", None),
13593        ];
13594        let start = serde_json::to_value(AgentEvent::ToolStart {
13595            tool_call_id: "glob".into(),
13596            tool_name: "Glob".into(),
13597            arguments: serde_json::json!({"pattern":"marker.txt"}),
13598        })
13599        .unwrap();
13600        let complete = serde_json::to_value(AgentEvent::ToolComplete {
13601            tool_call_id: "glob".into(),
13602            result: bamboo_domain::ToolResult::text(true, "marker.txt"),
13603        })
13604        .unwrap();
13605        let tail =
13606            serde_json::to_value(bamboo_subagent::proto::ReadOnlyActorTranscript::Complete {
13607                messages: messages
13608                    .iter()
13609                    .map(serde_json::to_value)
13610                    .collect::<Result<_, _>>()
13611                    .unwrap(),
13612            })
13613            .unwrap();
13614        let mut ordered = ReadOnlyActorCollector::default();
13615        assert!(ordered.event(&start).unwrap());
13616        assert!(ordered.event(&complete).unwrap());
13617        assert!(!ordered.event(&tail).unwrap());
13618        assert!(ordered.event(&tail).is_err(), "duplicate suffix");
13619        assert_eq!(ordered.finish(Some("done")).unwrap().len(), 3);
13620        assert!(ReadOnlyActorCollector::default()
13621            .finish(Some("done"))
13622            .is_err());
13623        let mut unfinished = ReadOnlyActorCollector::default();
13624        unfinished.event(&start).unwrap();
13625        unfinished.event(&complete).unwrap();
13626        assert!(unfinished.finish(Some("done")).is_err(), "missing suffix");
13627        let mut closed = tail.clone();
13628        closed["grant"] = true.into();
13629        assert!(
13630            serde_json::from_value::<bamboo_subagent::proto::ReadOnlyActorTranscript>(closed)
13631                .is_err()
13632        );
13633        let mut oversized = tail.clone();
13634        oversized["messages"][2]["content"] = "x".repeat(64 * 1024).into();
13635        assert!(
13636            serde_json::from_value::<bamboo_subagent::proto::ReadOnlyActorTranscript>(oversized)
13637                .unwrap()
13638                .validate()
13639                .is_err()
13640        );
13641        assert!(ReadOnlyActorCollector::default().event(&complete).is_err());
13642        assert!(ReadOnlyActorCollector::default().event(&tail).is_err());
13643        let mut bad = start.clone();
13644        bad["tool_name"] = "Bash".into();
13645        assert!(ReadOnlyActorCollector::default().event(&bad).is_err());
13646        let mut duplicate = ReadOnlyActorCollector::default();
13647        duplicate.event(&start).unwrap();
13648        assert!(duplicate.event(&start).is_err());
13649        let hidden = serde_json::to_value(AgentEvent::ReasoningToken {
13650            content: "hidden".into(),
13651        })
13652        .unwrap();
13653        assert!(ReadOnlyActorCollector::default().event(&hidden).is_err());
13654        let mut mismatched = ReadOnlyActorCollector::default();
13655        mismatched.event(&start).unwrap();
13656        let mut bad = complete.clone();
13657        bad["tool_call_id"] = "foreign".into();
13658        assert!(mismatched.event(&bad).is_err());
13659        mismatched.event(&complete).unwrap();
13660        let mut changed = tail.clone();
13661        changed["messages"][1]["content"] = "fabricated".into();
13662        assert!(mismatched.event(&changed).is_err());
13663    }
13664
13665    #[cfg(unix)]
13666    #[tokio::test]
13667    async fn owned_initial_release_requires_current_prefix_ack_and_same_receipt() {
13668        let temp = tempfile::tempdir().unwrap();
13669        let store = Arc::new(
13670            bamboo_storage::SessionStoreV2::new(temp.path().into())
13671                .await
13672                .unwrap(),
13673        );
13674        let parent = Session::new("release-host-parent", "model");
13675        store.save_session(&parent).await.unwrap();
13676        let mut child = Session::new_child_of("release-host-child", &parent, "model", "assignment");
13677        store.save_session(&child).await.unwrap();
13678        let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
13679            store.clone(),
13680            bamboo_domain::SessionInboxLimits::default(),
13681        ));
13682        let binding = actor_binding(
13683            store.clone(),
13684            inbox,
13685            Arc::new(bamboo_storage::LockedSessionStore::new(store.clone())),
13686        );
13687        let _owner = binding
13688            .router
13689            .register_run(&child.id, "release-host-run")
13690            .await
13691            .unwrap();
13692        let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
13693        let revision = policy.policy_revision();
13694        bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
13695        let activation = PlainActorActivation::start(
13696            store.clone(),
13697            &child,
13698            &binding,
13699            Some("release-host-run"),
13700            Some(policy.clone()),
13701            true,
13702            Some("probed-physical-worker"),
13703        )
13704        .await
13705        .unwrap();
13706        let envelope = bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "RELEASE_ONCE");
13707        let receipt = activation.input_inbox.deliver(&envelope).await.unwrap();
13708        activation
13709            .input_inbox
13710            .mark_activation_eligible(
13711                &child.id,
13712                receipt.generation,
13713                bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
13714            )
13715            .await
13716            .unwrap();
13717        let claim = activation
13718            .claim_input(&binding, &child, "release-host-run")
13719            .await
13720            .unwrap()
13721            .unwrap();
13722        let (prefix, delivery) = activation
13723            .checkpoint_input(&mut child, "release-host-run", &claim)
13724            .await
13725            .unwrap();
13726        let mut expected = Some(expected_default_permission_posture(revision));
13727        let ChildFrame::Event { event } = permission_posture_frame(&child.id, revision) else {
13728            unreachable!()
13729        };
13730        let (events, mut events_rx) = mpsc::channel(1);
13731        process_actor_event(
13732            event,
13733            true,
13734            &mut PermissionPostureHandshake::Awaiting,
13735            expected.as_ref(),
13736            Some(&binding),
13737            &mut child,
13738            &events,
13739            &mut ActorEventDisplay::default(),
13740        )
13741        .await
13742        .unwrap();
13743        events_rx.recv().await.unwrap();
13744        let run = RunSpec {
13745            assignment: "assignment".into(),
13746            logical_session: Some(LogicalSessionIdentity {
13747                session_id: child.id.clone(),
13748                parent_session_id: child.parent_session_id.clone(),
13749                root_session_id: child.root_session_id.clone(),
13750                creation: Some(bamboo_subagent::proto::ChildCreationIdentity {
13751                    created_at: child.created_at,
13752                    spawn_depth: child.spawn_depth,
13753                }),
13754            }),
13755            project_id: None,
13756            reasoning_effort: None,
13757            permission_policy: Some(bamboo_subagent::proto::PermissionPolicyContext {
13758                revision,
13759                requested_mode: "default".into(),
13760                effective_mode: "default".into(),
13761                bypass_permissions: false,
13762                auto_approve_permissions: false,
13763                session_id: child.id.clone(),
13764                workspace_path: child.workspace.clone(),
13765                environment_lease: None,
13766                inherit_session_grants: false,
13767                policy: serde_json::to_value(policy.to_serializable()).unwrap(),
13768            }),
13769            messages: prefix,
13770            activation_run_id: Some("release-host-run".into()),
13771            execution_epoch: 9,
13772            initial_session_messages: vec![delivery.clone()],
13773            secrets: Default::default(),
13774        };
13775        let request = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
13776            &run,
13777            &delivery,
13778            uuid::Uuid::new_v4().to_string(),
13779        )
13780        .unwrap();
13781        let mut stale = child.clone();
13782        stale.add_message(bamboo_agent_core::Message::user("not canonical"));
13783        assert!(activation
13784            .release_initial_input(
13785                &binding,
13786                &stale,
13787                &run,
13788                9,
13789                &claim,
13790                &request,
13791                &mut expected,
13792                None
13793            )
13794            .await
13795            .is_err());
13796        assert!(!activation
13797            .input_inbox
13798            .was_admitted(&child.id, &envelope.id)
13799            .await
13800            .unwrap());
13801        // Keep receipt lookup readable so Already reaches the real ACK write.
13802        // Unix root can bypass these modes; this FS fault needs an unprivileged user.
13803        use std::os::unix::fs::PermissionsExt;
13804        struct RestorePermissions(std::path::PathBuf, std::fs::Permissions);
13805        impl Drop for RestorePermissions {
13806            fn drop(&mut self) {
13807                let _ = std::fs::set_permissions(&self.0, self.1.clone());
13808            }
13809        }
13810        let admitted = store
13811            .bamboo_home_dir()
13812            .join(store.resolve_rel_path(&child.id).await.unwrap())
13813            .join("inbox/admitted");
13814        std::fs::create_dir_all(&admitted).unwrap();
13815        let restore = RestorePermissions(
13816            admitted.clone(),
13817            std::fs::metadata(&admitted).unwrap().permissions(),
13818        );
13819        std::fs::set_permissions(&admitted, std::fs::Permissions::from_mode(0o555)).unwrap();
13820        let verified = activation
13821            .input_inbox
13822            .checkpoint_actor_input(bamboo_storage::ActorInputCheckpoint {
13823                fence: activation.fence.clone(),
13824                expected_created_at: child.created_at,
13825                claim: claim.clone(),
13826                expected_messages: child.messages.clone(),
13827                expected_provider_transcript: child.provider_transcript.clone(),
13828                expected_admission: child.session_inbox_admission().cloned(),
13829            })
13830            .await
13831            .unwrap();
13832        assert_eq!(
13833            verified.status,
13834            bamboo_storage::ActorInputCheckpointStatus::AlreadyCheckpointed
13835        );
13836        let error = activation
13837            .release_initial_input(
13838                &binding,
13839                &child,
13840                &run,
13841                9,
13842                &claim,
13843                &request,
13844                &mut expected,
13845                None,
13846            )
13847            .await
13848            .unwrap_err();
13849        std::fs::set_permissions(&admitted, restore.1.clone()).unwrap();
13850        drop(restore);
13851        assert!(
13852            error.to_string().contains("ACK unresolved"),
13853            "actual release error (<=512B): {}",
13854            error.to_string().chars().take(128).collect::<String>()
13855        );
13856        let release = activation
13857            .release_initial_input(
13858                &binding,
13859                &child,
13860                &run,
13861                9,
13862                &claim,
13863                &request,
13864                &mut expected,
13865                None,
13866            )
13867            .await
13868            .unwrap();
13869        assert!(activation
13870            .input_inbox
13871            .was_admitted(&child.id, &envelope.id)
13872            .await
13873            .unwrap());
13874        assert_eq!(
13875            release.expires_at,
13876            activation
13877                .actor_deadline
13878                .min(claim.lease.expires_at)
13879                .min(activation.input_deadline)
13880        );
13881        assert_eq!(
13882            activation
13883                .release_initial_input(
13884                    &binding,
13885                    &child,
13886                    &run,
13887                    9,
13888                    &claim,
13889                    &request,
13890                    &mut expected,
13891                    Some(&release)
13892                )
13893                .await
13894                .unwrap(),
13895            release
13896        );
13897        let mut wrong = request.clone();
13898        wrong.nonce = uuid::Uuid::new_v4().to_string();
13899        assert!(activation
13900            .release_initial_input(
13901                &binding,
13902                &child,
13903                &run,
13904                9,
13905                &claim,
13906                &wrong,
13907                &mut expected,
13908                Some(&release)
13909            )
13910            .await
13911            .is_err());
13912        assert!(activation
13913            .release_initial_input(
13914                &binding,
13915                &child,
13916                &run,
13917                10,
13918                &claim,
13919                &request,
13920                &mut expected,
13921                Some(&release)
13922            )
13923            .await
13924            .is_err());
13925        // A second real checkpoint/release reuses the activation, not its old epoch or nonce.
13926        let deadlines = (
13927            activation.fence.clone(),
13928            activation.actor_deadline,
13929            activation.input_deadline,
13930        );
13931        let second = bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "RELEASE_TWICE");
13932        let receipt = activation.input_inbox.deliver(&second).await.unwrap();
13933        activation
13934            .input_inbox
13935            .mark_activation_eligible(
13936                &child.id,
13937                receipt.generation,
13938                bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
13939            )
13940            .await
13941            .unwrap();
13942        let mut link = ConfirmationSequenceLink {
13943            frames: VecDeque::new(),
13944            sent: Vec::new(),
13945        };
13946        let epochs = AtomicU64::new(9);
13947        let (claim2, epoch2) = activation
13948            .continue_input(
13949                &mut link,
13950                &binding,
13951                &mut child,
13952                "release-host-run",
13953                Some((&run, &epochs)),
13954                &mut expected,
13955                "SECOND_REPLY".into(),
13956                &events,
13957            )
13958            .await
13959            .unwrap()
13960            .unwrap();
13961        events_rx.recv().await.unwrap();
13962        assert_eq!(epoch2, 10);
13963        let ParentFrame::Run(next) = &link.sent[0] else {
13964            panic!("second correlated Run")
13965        };
13966        assert_eq!(next.logical_session, run.logical_session);
13967        assert_eq!(next.activation_run_id, run.activation_run_id);
13968        assert_eq!(next.initial_session_messages.len(), 1);
13969        assert!(next
13970            .messages
13971            .iter()
13972            .any(|m| m["id"] == envelope.id.as_str()));
13973        assert!(next.messages.iter().any(|m| m["content"] == "SECOND_REPLY"));
13974        assert!(!next.messages.iter().any(|m| m["id"] == second.id.as_str()));
13975        let request2 = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
13976            next,
13977            &next.initial_session_messages[0],
13978            uuid::Uuid::new_v4().to_string(),
13979        )
13980        .unwrap();
13981        assert_ne!(request2.nonce, request.nonce);
13982        macro_rules! release_second {
13983            ($candidate:expr, $request:expr, $epoch:expr, $previous:expr) => {
13984                activation
13985                    .release_initial_input(
13986                        &binding,
13987                        $candidate,
13988                        next,
13989                        $epoch,
13990                        &claim2,
13991                        $request,
13992                        &mut expected,
13993                        $previous,
13994                    )
13995                    .await
13996            };
13997        }
13998        assert!(release_second!(&child, &request, epoch2, None).is_err());
13999        assert!(release_second!(&child, &request2, 9, None).is_err());
14000        let mut stale2 = child.clone();
14001        stale2.add_message(bamboo_agent_core::Message::user("stale second prefix"));
14002        assert!(release_second!(&stale2, &request2, epoch2, None).is_err());
14003        assert!(!activation
14004            .input_inbox
14005            .was_admitted(&child.id, &second.id)
14006            .await
14007            .unwrap());
14008        let release2 = release_second!(&child, &request2, epoch2, None).unwrap();
14009        assert_eq!(
14010            release_second!(&child, &request2, epoch2, Some(&release2)).unwrap(),
14011            release2
14012        );
14013        let mut wrong2 = request2.clone();
14014        wrong2.nonce = request.nonce.clone();
14015        assert!(release_second!(&child, &wrong2, epoch2, Some(&release2)).is_err());
14016        assert!(activation
14017            .input_inbox
14018            .was_admitted(&child.id, &second.id)
14019            .await
14020            .unwrap());
14021        let expired = bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "EXPIRED_INPUT");
14022        let receipt = activation.input_inbox.deliver(&expired).await.unwrap();
14023        activation
14024            .input_inbox
14025            .mark_activation_eligible(
14026                &child.id,
14027                receipt.generation,
14028                bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
14029            )
14030            .await
14031            .unwrap();
14032        let now = chrono::Utc::now();
14033        let expired_claim = activation
14034            .input_inbox
14035            .claim_owned(
14036                &child.id,
14037                1,
14038                Some("release-host-run"),
14039                &SessionInboxLeaseRequest {
14040                    consumer: activation.input_consumer.clone(),
14041                    now,
14042                    duration: chrono::Duration::seconds(1),
14043                },
14044            )
14045            .await
14046            .unwrap()
14047            .pop()
14048            .unwrap();
14049        let (prefix, delivery3) = activation
14050            .checkpoint_input(&mut child, "release-host-run", &expired_claim)
14051            .await
14052            .unwrap();
14053        let mut expired_run = next.clone();
14054        expired_run.execution_epoch = 11;
14055        expired_run.messages = prefix;
14056        expired_run.initial_session_messages = vec![delivery3.clone()];
14057        let expired_request = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
14058            &expired_run,
14059            &delivery3,
14060            uuid::Uuid::new_v4().to_string(),
14061        )
14062        .unwrap();
14063        tokio::time::sleep(
14064            (expired_claim.lease.expires_at - chrono::Utc::now())
14065                .to_std()
14066                .unwrap_or_default(),
14067        )
14068        .await;
14069        assert!(chrono::Utc::now() >= expired_claim.lease.expires_at);
14070        assert!(activation
14071            .release_initial_input(
14072                &binding,
14073                &child,
14074                &expired_run,
14075                11,
14076                &expired_claim,
14077                &expired_request,
14078                &mut expected,
14079                None
14080            )
14081            .await
14082            .is_err());
14083        assert!(!activation
14084            .input_inbox
14085            .was_admitted(&child.id, &expired.id)
14086            .await
14087            .unwrap());
14088        assert_eq!(
14089            (
14090                activation.fence.clone(),
14091                activation.actor_deadline,
14092                activation.input_deadline
14093            ),
14094            deadlines
14095        );
14096        assert_eq!(
14097            link.sent.len(),
14098            1,
14099            "no dispatch from rejected release checks"
14100        );
14101        let cold = store.load_session(&child.id).await.unwrap().unwrap();
14102        for id in [&envelope.id, &second.id] {
14103            assert_eq!(
14104                cold.messages.iter().filter(|m| m.id == id.as_str()).count(),
14105                1
14106            );
14107            assert!(cold.session_inbox_admission().unwrap().contains(id));
14108        }
14109        // Mutate the actual captured policy Arc; cached ACK/release is not a fresh grant.
14110        policy.deny_scoped_session_permission(
14111            &child.id,
14112            bamboo_tools::permission::PermissionType::ExecuteCommand,
14113            "blocked-action",
14114        );
14115        assert!(release_second!(&child, &request2, epoch2, Some(&release2)).is_err());
14116        assert!(activation
14117            .input_inbox
14118            .was_admitted(&child.id, &second.id)
14119            .await
14120            .unwrap());
14121        activation
14122            .finish(ActorActivationFinish::Failed)
14123            .await
14124            .unwrap();
14125        // Terminal ACK can be idempotent; stopped/expired Actor cannot grant.
14126        activation
14127            .input_inbox
14128            .ack_owned(&child.id, &claim, chrono::Utc::now())
14129            .await
14130            .unwrap();
14131        assert!(activation
14132            .release_initial_input(
14133                &binding,
14134                &child,
14135                &run,
14136                9,
14137                &claim,
14138                &request,
14139                &mut expected,
14140                Some(&release)
14141            )
14142            .await
14143            .is_err());
14144        let cold = bamboo_storage::SessionStoreV2::new(temp.path().into())
14145            .await
14146            .unwrap()
14147            .load_session(&child.id)
14148            .await
14149            .unwrap()
14150            .unwrap();
14151        assert_eq!(
14152            cold.messages
14153                .iter()
14154                .filter(|m| m.id == envelope.id.as_str())
14155                .count(),
14156            1
14157        );
14158    }
14159
14160    #[tokio::test]
14161    async fn owned_actor_input_checkpoint_precedes_dispatch_and_exact_ack() {
14162        for reject_checkpoint in [false, true] {
14163            let temp = tempfile::tempdir().unwrap();
14164            let store = Arc::new(
14165                bamboo_storage::SessionStoreV2::new(temp.path().into())
14166                    .await
14167                    .unwrap(),
14168            );
14169            let parent = Session::new("owned-input-parent", "model");
14170            store.save_session(&parent).await.unwrap();
14171            let mut child =
14172                Session::new_child_of("owned-input-child", &parent, "model", "one plain task");
14173            store.save_session(&child).await.unwrap();
14174            let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
14175            let raw_storage: Arc<dyn Storage> = store.clone();
14176            let locked = Arc::new(bamboo_storage::LockedSessionStore::new(raw_storage));
14177            let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
14178                store.clone(),
14179                bamboo_domain::SessionInboxLimits::default(),
14180            ));
14181            let binding = actor_binding(store.clone(), inbox, locked);
14182            let run_id = "owned-input-run";
14183            let _registration = binding
14184                .router
14185                .register_run(&child.id, run_id)
14186                .await
14187                .unwrap();
14188            assert!(PlainActorActivation::start(
14189                store.clone(),
14190                &child,
14191                &binding,
14192                Some(run_id),
14193                None,
14194                true,
14195                None,
14196            )
14197            .await
14198            .is_err());
14199            assert!(PlainActorActivation::start(
14200                store.clone(),
14201                &child,
14202                &binding,
14203                Some(run_id),
14204                Some(policy.clone()),
14205                true,
14206                None,
14207            )
14208            .await
14209            .is_err());
14210            assert_eq!(
14211                store
14212                    .inspect_actor(&child.id)
14213                    .await
14214                    .unwrap()
14215                    .actor
14216                    .current_attempt,
14217                0
14218            );
14219            bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
14220            let activation = PlainActorActivation::start(
14221                store.clone(),
14222                &child,
14223                &binding,
14224                Some(run_id),
14225                Some(policy.clone()),
14226                true,
14227                None,
14228            )
14229            .await
14230            .unwrap();
14231            let envelope =
14232                bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "CORRECTION_ONCE");
14233            let receipt = activation.input_inbox.deliver(&envelope).await.unwrap();
14234            activation
14235                .input_inbox
14236                .mark_activation_eligible(
14237                    &child.id,
14238                    receipt.generation,
14239                    bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
14240                )
14241                .await
14242                .unwrap();
14243            let mut link = ConfirmationSequenceLink {
14244                frames: VecDeque::new(),
14245                sent: Vec::new(),
14246            };
14247            let policy_revision = policy.policy_revision();
14248            let run = RunSpec {
14249                assignment: "one plain task".into(),
14250                logical_session: None,
14251                project_id: None,
14252                reasoning_effort: None,
14253                permission_policy: Some(PermissionPolicyContext {
14254                    revision: policy_revision,
14255                    requested_mode: "default".into(),
14256                    effective_mode: "default".into(),
14257                    bypass_permissions: false,
14258                    auto_approve_permissions: false,
14259                    session_id: child.id.clone(),
14260                    workspace_path: child.workspace.clone(),
14261                    environment_lease: None,
14262                    inherit_session_grants: false,
14263                    policy: serde_json::to_value(policy.to_serializable()).unwrap(),
14264                }),
14265                messages: vec![],
14266                activation_run_id: Some(run_id.into()),
14267                execution_epoch: 1,
14268                initial_session_messages: vec![],
14269                secrets: Default::default(),
14270            };
14271            let epochs = AtomicU64::new(1);
14272            let (tx, mut rx) = mpsc::channel(1);
14273            let mut expected = Some(expected_default_permission_posture(policy_revision));
14274            let ChildFrame::Event { event } = permission_posture_frame(&child.id, policy_revision)
14275            else {
14276                unreachable!()
14277            };
14278            process_actor_event(
14279                event,
14280                true,
14281                &mut PermissionPostureHandshake::Awaiting,
14282                expected.as_ref(),
14283                Some(&binding),
14284                &mut child,
14285                &tx,
14286                &mut ActorEventDisplay::default(),
14287            )
14288            .await
14289            .unwrap();
14290            rx.recv().await.unwrap();
14291            let first_audit =
14292                bamboo_domain::PermissionAuditSnapshot::from_metadata(&child.metadata)
14293                    .unwrap()
14294                    .audit_revision;
14295            if reject_checkpoint {
14296                // A real bounded publication channel blocks AFTER first-reply
14297                // commit, so change actual Actor authority before checkpoint.
14298                tx.send(AgentEvent::Complete {
14299                    usage: Default::default(),
14300                })
14301                .await
14302                .unwrap();
14303            }
14304            let mut operation = activation.continue_input(
14305                &mut link,
14306                &binding,
14307                &mut child,
14308                run_id,
14309                Some((&run, &epochs)),
14310                &mut expected,
14311                "FIRST_PLAIN_REPLY".into(),
14312                &tx,
14313            );
14314            if reject_checkpoint {
14315                tokio::select! {
14316                    result = &mut operation => panic!("publication barrier released early: {result:?}"),
14317                    _ = async {
14318                        tokio::time::timeout(Duration::from_secs(5), async {
14319                            loop {
14320                                let actual = store.load_session("owned-input-child").await.unwrap().unwrap();
14321                                if actual.messages.iter().any(|m| m.content == "FIRST_PLAIN_REPLY") { break; }
14322                                tokio::task::yield_now().await;
14323                            }
14324                        }).await.unwrap();
14325                        activation.finish(ActorActivationFinish::Failed).await.unwrap();
14326                        rx.recv().await.unwrap();
14327                    } => {}
14328                }
14329            }
14330            let result = operation.await;
14331            if reject_checkpoint {
14332                assert!(result
14333                    .unwrap_err()
14334                    .to_string()
14335                    .contains("checkpoint unconfirmed"));
14336                assert!(
14337                    link.sent.is_empty(),
14338                    "zero worker/provider correction dispatch"
14339                );
14340                let durable = store.load_session(&child.id).await.unwrap().unwrap();
14341                assert!(durable
14342                    .messages
14343                    .iter()
14344                    .any(|m| m.content == "FIRST_PLAIN_REPLY"));
14345                assert!(!durable
14346                    .messages
14347                    .iter()
14348                    .any(|m| m.id == envelope.id.as_str()));
14349                assert!(!activation
14350                    .input_inbox
14351                    .was_admitted(&child.id, &envelope.id)
14352                    .await
14353                    .unwrap());
14354                assert_eq!(
14355                    activation
14356                        .input_inbox
14357                        .inspect(&child.id)
14358                        .await
14359                        .unwrap()
14360                        .claimed,
14361                    1
14362                );
14363                continue;
14364            }
14365            let (claim, epoch) = result.unwrap().unwrap();
14366            rx.recv().await.unwrap(); // Actual first-reply publication.
14367            assert_eq!(epoch, 2);
14368            assert_eq!(link.sent.len(), 1);
14369            let ParentFrame::Run(next) = &link.sent[0] else {
14370                panic!("correlated continuation Run")
14371            };
14372            assert_eq!(next.execution_epoch, 2);
14373            assert_eq!(next.activation_run_id.as_deref(), Some(run_id));
14374            assert!(next
14375                .messages
14376                .iter()
14377                .any(|m| m["content"] == "FIRST_PLAIN_REPLY"));
14378            assert!(!next
14379                .messages
14380                .iter()
14381                .any(|m| m["id"] == envelope.id.as_str()));
14382            let delivery = &next.initial_session_messages[0];
14383            assert_eq!(delivery.envelope, envelope);
14384            let durable = store.load_session(&child.id).await.unwrap().unwrap();
14385            assert!(durable
14386                .session_inbox_admission()
14387                .unwrap()
14388                .contains(&envelope.id));
14389            assert_eq!(
14390                durable
14391                    .messages
14392                    .iter()
14393                    .filter(|m| m.id == envelope.id.as_str())
14394                    .count(),
14395                1
14396            );
14397            assert!(
14398                !activation
14399                    .input_inbox
14400                    .was_admitted(&child.id, &envelope.id)
14401                    .await
14402                    .unwrap(),
14403                "checkpoint is not a worker admission receipt"
14404            );
14405            let mut confirmation = admission_confirmation(&child.id, &claim.claim, run_id);
14406            confirmation.canonical_claim_generation += 1;
14407            assert!(activation
14408                .confirm_input(&binding, &child, run_id, &claim, &confirmation)
14409                .await
14410                .is_err());
14411            assert!(!activation
14412                .input_inbox
14413                .was_admitted(&child.id, &envelope.id)
14414                .await
14415                .unwrap());
14416            confirmation.canonical_claim_generation = claim.claim.generation;
14417            activation
14418                .confirm_input(&binding, &child, run_id, &claim, &confirmation)
14419                .await
14420                .unwrap();
14421            assert!(activation
14422                .input_inbox
14423                .was_admitted(&child.id, &envelope.id)
14424                .await
14425                .unwrap());
14426            assert_eq!(
14427                expected.as_ref().unwrap().expected_audit_revision,
14428                Some(first_audit)
14429            );
14430            let ChildFrame::Event { event } = permission_posture_frame(&child.id, policy_revision)
14431            else {
14432                unreachable!()
14433            };
14434            process_actor_event(
14435                event,
14436                true,
14437                &mut PermissionPostureHandshake::Awaiting,
14438                expected.as_ref(),
14439                Some(&binding),
14440                &mut child,
14441                &tx,
14442                &mut ActorEventDisplay::default(),
14443            )
14444            .await
14445            .unwrap();
14446            rx.recv().await.unwrap();
14447            assert!(
14448                bamboo_domain::PermissionAuditSnapshot::from_metadata(&child.metadata)
14449                    .unwrap()
14450                    .audit_revision
14451                    > first_audit
14452            );
14453            let reopened = bamboo_storage::SessionStoreV2::new(temp.path().into())
14454                .await
14455                .unwrap();
14456            let cold = reopened.load_session(&child.id).await.unwrap().unwrap();
14457            assert_eq!(
14458                cold.messages
14459                    .iter()
14460                    .filter(|m| m.id == envelope.id.as_str())
14461                    .count(),
14462                1
14463            );
14464            assert!(cold
14465                .session_inbox_admission()
14466                .unwrap()
14467                .contains(&envelope.id));
14468        }
14469    }
14470
14471    fn admission_confirmation(
14472        session_id: &str,
14473        claim: &SessionInboxClaim,
14474        run_id: &str,
14475    ) -> bamboo_subagent::proto::SessionMessageAdmissionConfirmation {
14476        bamboo_subagent::proto::SessionMessageAdmissionConfirmation {
14477            target_session_id: session_id.to_string(),
14478            envelope_id: claim.envelope.id.to_string(),
14479            canonical_claim_generation: claim.generation,
14480            activation_run_id: run_id.to_string(),
14481        }
14482    }
14483
14484    fn expected_default_permission_posture(policy_revision: u64) -> ExpectedPermissionPosture {
14485        ExpectedPermissionPosture {
14486            policy_revision,
14487            resolution: bamboo_domain::PermissionModeResolution {
14488                requested: bamboo_domain::SessionPermissionMode::Default,
14489                effective: bamboo_domain::PermissionMode::Default,
14490            },
14491            expected_audit_revision: None,
14492            executor_mapping: "test_actor:permission_mode=default".to_string(),
14493        }
14494    }
14495
14496    fn permission_posture_frame(session_id: &str, policy_revision: u64) -> ChildFrame {
14497        ChildFrame::Event {
14498            event: serde_json::to_value(AgentEvent::PermissionPostureActivated {
14499                session_id: session_id.to_string(),
14500                policy_revision,
14501                requested_mode: "default".to_string(),
14502                effective_mode: "default".to_string(),
14503                executor_mapping: "test_actor:permission_mode=default".to_string(),
14504            })
14505            .expect("serialize permission posture event"),
14506        }
14507    }
14508
14509    fn actor_event_frame(event: AgentEvent) -> ChildFrame {
14510        ChildFrame::Event {
14511            event: serde_json::to_value(event).expect("serialize actor event"),
14512        }
14513    }
14514
14515    #[test]
14516    fn actor_event_batch_is_fenced_by_session_activation_and_epoch() {
14517        let session = Session::new_child("logical-child", "logical-parent", "model", "child");
14518        let mut batch = ActorEventBatch {
14519            logical_session: Some(LogicalSessionIdentity {
14520                creation: None,
14521                session_id: session.id.clone(),
14522                parent_session_id: session.parent_session_id.clone(),
14523                root_session_id: session.root_session_id.clone(),
14524            }),
14525            activation_id: Some("activation-7".into()),
14526            execution_epoch: 11,
14527            source_node_id: Some("node-a".into()),
14528            source_actor_id: Some("worker-a".into()),
14529            first_seq: 1,
14530            last_seq: 1,
14531            qos: bamboo_subagent::ActorEventQos::Ephemeral,
14532            events: vec![serde_json::json!({"type":"token","content":"ok"})],
14533        };
14534        validate_actor_event_batch(
14535            &batch,
14536            &session,
14537            "logical-parent",
14538            Some("activation-7"),
14539            11,
14540            "worker-a",
14541            None,
14542        )
14543        .unwrap();
14544
14545        let creation = bamboo_subagent::proto::ChildCreationIdentity {
14546            created_at: session.created_at,
14547            spawn_depth: session.spawn_depth,
14548        };
14549        assert!(validate_actor_event_batch(
14550            &batch,
14551            &session,
14552            "logical-parent",
14553            Some("activation-7"),
14554            11,
14555            "worker-a",
14556            Some(&creation)
14557        )
14558        .is_err());
14559        batch.logical_session.as_mut().unwrap().creation = Some(creation.clone());
14560        validate_actor_event_batch(
14561            &batch,
14562            &session,
14563            "logical-parent",
14564            Some("activation-7"),
14565            11,
14566            "worker-a",
14567            Some(&creation),
14568        )
14569        .unwrap();
14570        batch
14571            .logical_session
14572            .as_mut()
14573            .unwrap()
14574            .creation
14575            .as_mut()
14576            .unwrap()
14577            .created_at += chrono::Duration::nanoseconds(1);
14578        assert!(validate_actor_event_batch(
14579            &batch,
14580            &session,
14581            "logical-parent",
14582            Some("activation-7"),
14583            11,
14584            "worker-a",
14585            Some(&creation)
14586        )
14587        .is_err());
14588        batch.logical_session.as_mut().unwrap().creation = None;
14589
14590        batch.execution_epoch = 10;
14591        assert!(validate_actor_event_batch(
14592            &batch,
14593            &session,
14594            "logical-parent",
14595            Some("activation-7"),
14596            11,
14597            "worker-a",
14598            None,
14599        )
14600        .unwrap_err()
14601        .to_string()
14602        .contains("execution epoch"));
14603        batch.execution_epoch = 11;
14604        batch.activation_id = Some("replaced-activation".into());
14605        assert!(validate_actor_event_batch(
14606            &batch,
14607            &session,
14608            "logical-parent",
14609            Some("activation-7"),
14610            11,
14611            "worker-a",
14612            None,
14613        )
14614        .unwrap_err()
14615        .to_string()
14616        .contains("stale activation"));
14617        batch.activation_id = Some("activation-7".into());
14618        batch
14619            .logical_session
14620            .as_mut()
14621            .expect("logical identity")
14622            .session_id = "foreign-child".into();
14623        assert!(validate_actor_event_batch(
14624            &batch,
14625            &session,
14626            "logical-parent",
14627            Some("activation-7"),
14628            11,
14629            "worker-a",
14630            None,
14631        )
14632        .unwrap_err()
14633        .to_string()
14634        .contains("different logical session"));
14635        batch
14636            .logical_session
14637            .as_mut()
14638            .expect("logical identity")
14639            .session_id = session.id.clone();
14640        batch.source_actor_id = Some("worker-b".into());
14641        assert!(validate_actor_event_batch(
14642            &batch,
14643            &session,
14644            "logical-parent",
14645            Some("activation-7"),
14646            11,
14647            "worker-a",
14648            None,
14649        )
14650        .unwrap_err()
14651        .to_string()
14652        .contains("different physical actor"));
14653    }
14654
14655    #[tokio::test]
14656    async fn directory_event_ingress_rejects_old_attempt_and_forged_identity() {
14657        use bamboo_domain::Storage;
14658
14659        let temp = tempfile::tempdir().unwrap();
14660        let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
14661            .await
14662            .unwrap();
14663        let parent = Session::new("event-parent", "model");
14664        let child = Session::new_child_of("event-child", &parent, "model", "task");
14665        store.save_session(&parent).await.unwrap();
14666        store.save_session(&child).await.unwrap();
14667        store.ensure_actor(&child.id).await.unwrap();
14668        let now = chrono::Utc::now();
14669        let claim = |run_id: &str| ActorActivationClaim {
14670            actor_id: child.id.clone(),
14671            run_id: run_id.into(),
14672            lease_owner: "event-owner".into(),
14673            lease_expires_at: now + chrono::Duration::minutes(2),
14674            inbox_generation: 0,
14675            placement_ref: None,
14676            now,
14677        };
14678        let first = store.claim_activation(&claim("run-one")).await.unwrap();
14679        store.start_activation(&first.fence(), now).await.unwrap();
14680        let first_fence = first.fence();
14681        validate_directory_actor_event(&store, &first_fence, &child, Some("run-one"))
14682            .await
14683            .unwrap();
14684        validate_directory_actor_event(&store, &first_fence, &child, None)
14685            .await
14686            .unwrap(); // rolling-upgrade frame uses the same owner
14687        assert!(
14688            validate_directory_actor_event(&store, &first_fence, &child, Some("forged-run"))
14689                .await
14690                .is_err()
14691        );
14692        let mut forged = child.clone();
14693        forged.root_session_id = "foreign-root".into();
14694        assert!(
14695            validate_directory_actor_event(&store, &first_fence, &forged, Some("run-one"))
14696                .await
14697                .is_err()
14698        );
14699
14700        store
14701            .finish_activation(&first_fence, now, ActorActivationFinish::Failed)
14702            .await
14703            .unwrap();
14704        let second = store.claim_activation(&claim("run-two")).await.unwrap();
14705        store.start_activation(&second.fence(), now).await.unwrap();
14706        assert!(
14707            validate_directory_actor_event(&store, &first_fence, &child, Some("run-one"))
14708                .await
14709                .is_err()
14710        );
14711        assert!(
14712            validate_directory_actor_event(&store, &first_fence, &child, None)
14713                .await
14714                .is_err()
14715        );
14716        validate_directory_actor_event(&store, &second.fence(), &child, Some("run-two"))
14717            .await
14718            .unwrap();
14719    }
14720
14721    fn completed_actor_frame() -> ChildFrame {
14722        ChildFrame::Terminal {
14723            status: TerminalStatus::Completed,
14724            result: Some("done".to_string()),
14725            error: None,
14726            final_event_watermark: None,
14727            transcript: Vec::new(),
14728        }
14729    }
14730
14731    async fn drive_permission_handshake_frames(
14732        session_id: &str,
14733        frames: impl IntoIterator<Item = ChildFrame>,
14734        expected: ExpectedPermissionPosture,
14735    ) -> (
14736        crate::runtime::runner::Result<Option<String>>,
14737        Session,
14738        Vec<AgentEvent>,
14739        Vec<ParentFrame>,
14740    ) {
14741        let mut link = ConfirmationSequenceLink {
14742            frames: frames.into_iter().collect(),
14743            sent: Vec::new(),
14744        };
14745        let (event_tx, mut event_rx) = mpsc::channel(128);
14746        let cancel = CancellationToken::new();
14747        let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
14748        let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
14749        let mut session = Session::new(session_id, "model");
14750        let result = drive(ActorDriveContext {
14751            expected_creation: None,
14752            client: &mut link,
14753            parent_session_id: "permission-parent",
14754            child_session_id: session_id,
14755            child_attempt: 0,
14756            approval_registry: None,
14757            approval_decider: None,
14758            approval_reviewer: None,
14759            escalation_bridge: None,
14760            event_tx: &event_tx,
14761            cancel_token: &cancel,
14762            live_rx: &mut live_rx,
14763            delivery_rx: &mut delivery_rx,
14764            logical_session: &mut session,
14765            expected_permission_posture: Some(expected),
14766            session_inbox_runtime: None,
14767            actor_directory_store: None,
14768            canonical_subagent_tool: None,
14769            ticket_service: None,
14770            activation_run_id: None,
14771            execution_epoch: 0,
14772            expected_source_actor_id: session_id,
14773            initial_inflight_claims: VecDeque::new(),
14774            plain_actor: false,
14775            remote_environment_lease: false,
14776            readonly_output: None,
14777            local_history_tools: None,
14778            local_history_read_only: false,
14779            strict_history_output: None,
14780            plain_input: None,
14781            canonical_activation: None,
14782            canonical_placement_ref: None,
14783            actor_event_observer: None,
14784            plain_run: None,
14785            first_frame_timeout: Some(Duration::from_secs(1)),
14786        })
14787        .await;
14788        let events = std::iter::from_fn(|| event_rx.try_recv().ok()).collect();
14789        (result, session, events, link.sent)
14790    }
14791
14792    #[tokio::test]
14793    async fn actor_permission_handshake_rejects_terminal_without_posture() {
14794        let session_id = "permission-missing";
14795        let (result, session, events, _) = drive_permission_handshake_frames(
14796            session_id,
14797            [completed_actor_frame()],
14798            expected_default_permission_posture(7),
14799        )
14800        .await;
14801
14802        assert!(result
14803            .unwrap_err()
14804            .to_string()
14805            .contains("terminated before permission posture confirmation"));
14806        assert!(events.is_empty());
14807        assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
14808    }
14809
14810    #[tokio::test]
14811    async fn remote_environment_rejection_accepts_only_allowlisted_durable_terminal() {
14812        struct TerminalProofProbe {
14813            frame: Option<ChildFrame>,
14814            accepted: bool,
14815        }
14816
14817        #[async_trait]
14818        impl bamboo_subagent::ChildLink for TerminalProofProbe {
14819            async fn send(&mut self, _frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
14820                Ok(())
14821            }
14822
14823            async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
14824                Ok(self.frame.take())
14825            }
14826
14827            fn accept_durable_terminal(&mut self, status: TerminalStatus) {
14828                self.accepted = status == TerminalStatus::Error;
14829            }
14830
14831            fn has_pending_durable_terminal(&self) -> bool {
14832                self.accepted
14833            }
14834        }
14835
14836        for (status, error, expected_proof) in [
14837            (
14838                TerminalStatus::Error,
14839                Some("remote_environment_checkout_not_clean"),
14840                true,
14841            ),
14842            (TerminalStatus::Error, Some("untrusted worker error"), false),
14843            (
14844                TerminalStatus::Cancelled,
14845                Some("remote_environment_checkout_not_clean"),
14846                false,
14847            ),
14848        ] {
14849            let mut link = TerminalProofProbe {
14850                frame: Some(ChildFrame::Terminal {
14851                    status,
14852                    result: None,
14853                    error: error.map(str::to_owned),
14854                    final_event_watermark: None,
14855                    transcript: Vec::new(),
14856                }),
14857                accepted: false,
14858            };
14859            let (event_tx, _event_rx) = mpsc::channel(8);
14860            let cancel = CancellationToken::new();
14861            let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
14862            let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
14863            let mut session = Session::new("remote-terminal-proof", "model");
14864            let result = drive(ActorDriveContext {
14865                client: &mut link,
14866                parent_session_id: "proof-parent",
14867                child_session_id: "remote-terminal-proof",
14868                child_attempt: 0,
14869                approval_registry: None,
14870                approval_decider: None,
14871                approval_reviewer: None,
14872                escalation_bridge: None,
14873                event_tx: &event_tx,
14874                cancel_token: &cancel,
14875                live_rx: &mut live_rx,
14876                delivery_rx: &mut delivery_rx,
14877                logical_session: &mut session,
14878                expected_permission_posture: Some(expected_default_permission_posture(7)),
14879                expected_creation: None,
14880                session_inbox_runtime: None,
14881                actor_directory_store: None,
14882                canonical_subagent_tool: None,
14883                ticket_service: None,
14884                activation_run_id: None,
14885                execution_epoch: 0,
14886                expected_source_actor_id: "remote-terminal-proof",
14887                initial_inflight_claims: VecDeque::new(),
14888                plain_actor: false,
14889                remote_environment_lease: true,
14890                readonly_output: None,
14891                local_history_tools: None,
14892                local_history_read_only: false,
14893                strict_history_output: None,
14894                plain_input: None,
14895                canonical_activation: None,
14896                canonical_placement_ref: None,
14897                actor_event_observer: None,
14898                plain_run: None,
14899                first_frame_timeout: Some(Duration::from_secs(1)),
14900            })
14901            .await;
14902            assert!(result.is_err());
14903            assert_eq!(
14904                bamboo_subagent::ChildLink::has_pending_durable_terminal(&link),
14905                expected_proof
14906            );
14907        }
14908    }
14909
14910    #[tokio::test(start_paused = true)]
14911    async fn remote_cancel_requires_selected_worker_terminal_for_release_proof() {
14912        struct CancelOutcomeLink {
14913            cancel_sent: bool,
14914            settle: bool,
14915            accepted: bool,
14916        }
14917
14918        #[async_trait]
14919        impl bamboo_subagent::ChildLink for CancelOutcomeLink {
14920            async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
14921                self.cancel_sent = matches!(frame, ParentFrame::Cancel);
14922                Ok(())
14923            }
14924
14925            async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
14926                if !self.cancel_sent || !self.settle {
14927                    std::future::pending::<bamboo_subagent::TransportResult<Option<ChildFrame>>>()
14928                        .await
14929                } else {
14930                    self.cancel_sent = false;
14931                    Ok(Some(ChildFrame::Terminal {
14932                        status: TerminalStatus::Cancelled,
14933                        result: None,
14934                        error: None,
14935                        final_event_watermark: None,
14936                        transcript: Vec::new(),
14937                    }))
14938                }
14939            }
14940
14941            fn accept_durable_terminal(&mut self, status: TerminalStatus) {
14942                self.accepted = status == TerminalStatus::Cancelled;
14943            }
14944
14945            fn has_pending_durable_terminal(&self) -> bool {
14946                self.accepted
14947            }
14948        }
14949
14950        let temp = tempfile::tempdir().unwrap();
14951        let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
14952            .await
14953            .unwrap();
14954        let fence = ActorActivationFence {
14955            schema_version: 2,
14956            actor_id: "cancelled-remote-child".into(),
14957            activation_id: "cancelled-remote-activation".into(),
14958            attempt: 1,
14959            run_id: "cancelled-remote-run".into(),
14960            lease_owner: "cancelled-remote-host".into(),
14961            lease_epoch: 1,
14962        };
14963        for settle in [true, false] {
14964            let mut link = CancelOutcomeLink {
14965                cancel_sent: false,
14966                settle,
14967                accepted: false,
14968            };
14969            let (event_tx, _event_rx) = mpsc::channel(8);
14970            let cancel = CancellationToken::new();
14971            cancel.cancel();
14972            let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
14973            let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
14974            let mut session = Session::new("cancelled-remote-child", "model");
14975            let result = drive(ActorDriveContext {
14976                expected_creation: None,
14977                client: &mut link,
14978                parent_session_id: "cancelled-remote-parent",
14979                child_session_id: "cancelled-remote-child",
14980                child_attempt: 1,
14981                approval_registry: None,
14982                approval_decider: None,
14983                approval_reviewer: None,
14984                escalation_bridge: None,
14985                event_tx: &event_tx,
14986                cancel_token: &cancel,
14987                live_rx: &mut live_rx,
14988                delivery_rx: &mut delivery_rx,
14989                logical_session: &mut session,
14990                expected_permission_posture: Some(expected_default_permission_posture(7)),
14991                session_inbox_runtime: None,
14992                actor_directory_store: Some(&store),
14993                canonical_subagent_tool: None,
14994                ticket_service: None,
14995                activation_run_id: Some("cancelled-remote-run"),
14996                execution_epoch: 0,
14997                expected_source_actor_id: "cancelled-remote-worker",
14998                initial_inflight_claims: VecDeque::new(),
14999                plain_actor: false,
15000                remote_environment_lease: true,
15001                readonly_output: None,
15002                local_history_tools: None,
15003                local_history_read_only: false,
15004                strict_history_output: None,
15005                plain_input: None,
15006                canonical_activation: Some(&fence),
15007                canonical_placement_ref: None,
15008                actor_event_observer: None,
15009                plain_run: None,
15010                first_frame_timeout: None,
15011            })
15012            .await;
15013            assert!(matches!(result, Err(AgentError::Cancelled)));
15014            assert_eq!(
15015                link.accepted, settle,
15016                "only a consumed exact terminal can release the remote fence"
15017            );
15018            assert_eq!(
15019                bamboo_subagent::ChildLink::has_pending_durable_terminal(&link),
15020                settle
15021            );
15022        }
15023    }
15024
15025    #[tokio::test]
15026    async fn actor_permission_handshake_rejects_malformed_agent_event() {
15027        let session_id = "permission-malformed";
15028        let (result, session, events, _) = drive_permission_handshake_frames(
15029            session_id,
15030            [ChildFrame::Event {
15031                event: serde_json::json!({"type": "token", "content": 42}),
15032            }],
15033            expected_default_permission_posture(7),
15034        )
15035        .await;
15036
15037        assert!(result
15038            .unwrap_err()
15039            .to_string()
15040            .contains("malformed AgentEvent"));
15041        assert!(events.is_empty());
15042        assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
15043
15044        let (result, _, events, _) = drive_permission_handshake_frames(
15045            session_id,
15046            [
15047                permission_posture_frame(session_id, 7),
15048                ChildFrame::Event {
15049                    event: serde_json::json!({"type":"private-download-secret"}),
15050                },
15051            ],
15052            expected_default_permission_posture(7),
15053        )
15054        .await;
15055        assert!(!result
15056            .unwrap_err()
15057            .to_string()
15058            .contains("private-download-secret"));
15059        assert_eq!(
15060            events.len(),
15061            1,
15062            "only the confirmed posture may be forwarded"
15063        );
15064    }
15065
15066    #[tokio::test]
15067    async fn actor_permission_handshake_rejects_execution_event_before_posture() {
15068        let session_id = "permission-early-event";
15069        let early_events = [
15070            (
15071                "progress",
15072                AgentEvent::RunnerProgress {
15073                    session_id: session_id.to_string(),
15074                    round_count: 1,
15075                },
15076            ),
15077            (
15078                "token",
15079                AgentEvent::Token {
15080                    content: "must-not-forward".to_string(),
15081                },
15082            ),
15083            (
15084                "tool",
15085                AgentEvent::ToolStart {
15086                    tool_call_id: "early-tool".to_string(),
15087                    tool_name: "Read".to_string(),
15088                    arguments: serde_json::json!({"file_path": "README.md"}),
15089                },
15090            ),
15091        ];
15092        for (kind, event) in early_events {
15093            let (result, session, events, _) = drive_permission_handshake_frames(
15094                session_id,
15095                [
15096                    actor_event_frame(event),
15097                    permission_posture_frame(session_id, 7),
15098                    completed_actor_frame(),
15099                ],
15100                expected_default_permission_posture(7),
15101            )
15102            .await;
15103
15104            assert!(
15105                result
15106                    .unwrap_err()
15107                    .to_string()
15108                    .contains("execution event before permission posture confirmation"),
15109                "{kind} must fail closed before posture"
15110            );
15111            assert!(events.is_empty(), "{kind} must not be forwarded");
15112            assert!(
15113                bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none(),
15114                "{kind} must not advance the permission audit"
15115            );
15116        }
15117    }
15118
15119    #[tokio::test]
15120    async fn actor_permission_handshake_rejects_approval_before_posture() {
15121        let session_id = "permission-early-approval";
15122        let (result, session, events, sent) = drive_permission_handshake_frames(
15123            session_id,
15124            [ChildFrame::ApprovalRequest {
15125                id: "approval-before-posture".to_string(),
15126                body: serde_json::json!({
15127                    "tool_name": "Bash",
15128                    "permission": "execute",
15129                    "resource": "echo must-not-run"
15130                }),
15131            }],
15132            expected_default_permission_posture(7),
15133        )
15134        .await;
15135
15136        assert!(result
15137            .unwrap_err()
15138            .to_string()
15139            .contains("requested approval before permission posture confirmation"));
15140        assert!(events.is_empty());
15141        assert!(
15142            sent.is_empty(),
15143            "an unconfirmed actor must receive no approval reply"
15144        );
15145        assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
15146    }
15147
15148    #[tokio::test]
15149    async fn actor_permission_handshake_rejects_mismatched_posture() {
15150        let session_id = "permission-mismatch";
15151        let (result, session, events, _) = drive_permission_handshake_frames(
15152            session_id,
15153            [permission_posture_frame(session_id, 8)],
15154            expected_default_permission_posture(7),
15155        )
15156        .await;
15157
15158        assert!(result
15159            .unwrap_err()
15160            .to_string()
15161            .contains("does not match the host-dispatched policy"));
15162        assert!(events.is_empty());
15163        assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
15164    }
15165
15166    #[tokio::test]
15167    async fn actor_permission_handshake_rejects_untrusted_executor_mapping() {
15168        let session_id = "permission-hostile-mapping";
15169        for hostile_mapping in [
15170            "wrong_executor:permission_mode=default",
15171            "test_actor:permission_mode=default;credential=must-not-persist",
15172        ] {
15173            let frame = ChildFrame::Event {
15174                event: serde_json::to_value(AgentEvent::PermissionPostureActivated {
15175                    session_id: session_id.to_string(),
15176                    policy_revision: 7,
15177                    requested_mode: "default".to_string(),
15178                    effective_mode: "default".to_string(),
15179                    executor_mapping: hostile_mapping.to_string(),
15180                })
15181                .expect("serialize hostile posture fixture"),
15182            };
15183            let (result, session, events, _) = drive_permission_handshake_frames(
15184                session_id,
15185                [frame],
15186                expected_default_permission_posture(7),
15187            )
15188            .await;
15189
15190            let error = result.unwrap_err().to_string();
15191            assert!(error.contains("host-dispatched executor mapping"));
15192            assert!(
15193                !error.contains(hostile_mapping),
15194                "untrusted mapping must not be reflected in host errors"
15195            );
15196            assert!(events.is_empty());
15197            assert!(
15198                bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none(),
15199                "untrusted mapping must not reach durable or in-memory audit state"
15200            );
15201        }
15202    }
15203
15204    #[tokio::test]
15205    async fn actor_permission_handshake_rejects_duplicate_posture() {
15206        let session_id = "permission-duplicate";
15207        let (result, session, events, _) = drive_permission_handshake_frames(
15208            session_id,
15209            [
15210                permission_posture_frame(session_id, 7),
15211                permission_posture_frame(session_id, 7),
15212                completed_actor_frame(),
15213            ],
15214            expected_default_permission_posture(7),
15215        )
15216        .await;
15217
15218        assert!(result
15219            .unwrap_err()
15220            .to_string()
15221            .contains("duplicate permission posture activation"));
15222        assert_eq!(
15223            events.len(),
15224            1,
15225            "only the confirmed posture may be forwarded"
15226        );
15227        assert!(matches!(
15228            events[0],
15229            AgentEvent::PermissionPostureActivated { .. }
15230        ));
15231        let audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
15232            .expect("the first matching posture must be recorded");
15233        assert_eq!(audit.policy_revision, 7);
15234    }
15235
15236    #[tokio::test]
15237    async fn actor_permission_handshake_happy_path_persists_before_forwarding_execution() {
15238        let session_id = "permission-happy";
15239        let (result, session, events, _) = drive_permission_handshake_frames(
15240            session_id,
15241            [
15242                permission_posture_frame(session_id, 7),
15243                actor_event_frame(AgentEvent::RunnerProgress {
15244                    session_id: session_id.to_string(),
15245                    round_count: 1,
15246                }),
15247                actor_event_frame(AgentEvent::Token {
15248                    content: "working".to_string(),
15249                }),
15250                actor_event_frame(AgentEvent::ToolStart {
15251                    tool_call_id: "tool-1".to_string(),
15252                    tool_name: "Read".to_string(),
15253                    arguments: serde_json::json!({"file_path": "README.md"}),
15254                }),
15255                completed_actor_frame(),
15256            ],
15257            expected_default_permission_posture(7),
15258        )
15259        .await;
15260
15261        assert_eq!(result.unwrap().as_deref(), Some("done"));
15262        assert!(matches!(
15263            events.as_slice(),
15264            [
15265                AgentEvent::PermissionPostureActivated { .. },
15266                AgentEvent::RunnerProgress { .. },
15267                AgentEvent::Token { .. },
15268                AgentEvent::ToolStart { .. }
15269            ]
15270        ));
15271        let audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
15272            .expect("matching posture must be recorded before execution events are accepted");
15273        assert_eq!(audit.policy_revision, 7);
15274        assert_eq!(audit.executor_mapping, "test_actor:permission_mode=default");
15275    }
15276
15277    #[tokio::test]
15278    async fn actor_download_tool_start_is_projected_before_parent_forwarding() {
15279        for (index, tool_name) in ["browser", "default::browser", "private-namespace::browser"]
15280            .into_iter()
15281            .enumerate()
15282        {
15283            let session_id = format!("actor-download-{index}");
15284            let args = serde_json::json!({
15285                "action":"download",
15286                "selector":"a[data-secret='private-selector']",
15287                "expected_epoch":17,
15288                "data_base64":"private-extra-bytes",
15289                "extra":{"url":"https://private.test/file"},
15290            });
15291            let original = args.clone();
15292            let read_args = serde_json::json!({"file_path":"README.md"});
15293            let (result, _session, events, _) = drive_permission_handshake_frames(
15294                &session_id,
15295                [
15296                    permission_posture_frame(&session_id, 7),
15297                    actor_event_frame(AgentEvent::ToolStart {
15298                        tool_call_id: "download-call".to_string(),
15299                        tool_name: tool_name.to_string(),
15300                        arguments: args.clone(),
15301                    }),
15302                    actor_event_frame(AgentEvent::ToolStart {
15303                        tool_call_id: "read-call".to_string(),
15304                        tool_name: "Read".to_string(),
15305                        arguments: read_args.clone(),
15306                    }),
15307                    completed_actor_frame(),
15308                ],
15309                expected_default_permission_posture(7),
15310            )
15311            .await;
15312            assert_eq!(result.expect("actor completed").as_deref(), Some("done"));
15313            assert_eq!(args, original);
15314            let starts: Vec<_> = events
15315                .iter()
15316                .filter_map(|event| match event {
15317                    AgentEvent::ToolStart {
15318                        tool_name,
15319                        arguments,
15320                        ..
15321                    } => Some((tool_name.as_str(), arguments)),
15322                    _ => None,
15323                })
15324                .collect();
15325            assert_eq!(starts.len(), 2);
15326            assert_eq!(starts[0].0, "browser");
15327            assert_eq!(
15328                starts[0].1,
15329                &serde_json::json!({"action":"download","expected_epoch":17})
15330            );
15331            assert_eq!(starts[1], ("Read", &read_args));
15332            assert!(!format!("{events:?}").contains("private"));
15333        }
15334    }
15335
15336    #[tokio::test]
15337    async fn actor_hook_lifecycle_hides_every_decision_text_across_event_batch_and_nested_stream() {
15338        let session_id = "actor-hook-display";
15339        let private = "private-selector-url-filename-response";
15340        let hook_name = format!("audit-{private}");
15341        let hook = |duration_ms, phase: &str, decision| AgentEvent::HookLifecycle {
15342            hook_name: hook_name.clone(),
15343            point: bamboo_domain::AgentHookPoint::BeforeToolExecution,
15344            phase: phase.into(),
15345            duration_ms,
15346            decision,
15347        };
15348        let decisions = [
15349            HookResult::Deny {
15350                reason: private.into(),
15351            },
15352            HookResult::InjectContext {
15353                text: private.into(),
15354            },
15355            HookResult::WithContext {
15356                result: Box::new(HookResult::WithContext {
15357                    result: Box::new(HookResult::Deny {
15358                        reason: private.into(),
15359                    }),
15360                    text: private.into(),
15361                }),
15362                text: private.into(),
15363            },
15364            HookResult::Suspend {
15365                reason: private.into(),
15366            },
15367            HookResult::Abort {
15368                reason: private.into(),
15369            },
15370        ];
15371        let originals = decisions.to_vec();
15372        let batch_events = [
15373            hook(2, "completed", decisions[1].clone()),
15374            hook(3, "completed", decisions[2].clone()),
15375            hook(4, "completed", decisions[3].clone()),
15376        ]
15377        .into_iter()
15378        .map(|event| serde_json::to_value(event).unwrap())
15379        .collect::<Vec<_>>();
15380        let batch = ActorEventBatch {
15381            logical_session: Some(LogicalSessionIdentity {
15382                creation: None,
15383                session_id: session_id.into(),
15384                parent_session_id: Some("permission-parent".into()),
15385                root_session_id: session_id.into(),
15386            }),
15387            activation_id: None,
15388            execution_epoch: 0,
15389            source_node_id: None,
15390            source_actor_id: Some(session_id.into()),
15391            first_seq: 1,
15392            last_seq: batch_events.len() as u64,
15393            qos: bamboo_subagent::ActorEventQos::classify(&batch_events[0]),
15394            events: batch_events,
15395        };
15396        let nested = AgentEvent::SubAgentEvent {
15397            parent_session_id: "parent".into(),
15398            child_session_id: "child".into(),
15399            event: Box::new(hook(5, "completed", decisions[4].clone())),
15400        };
15401        let raw = serde_json::to_string(&originals).unwrap();
15402        assert!(raw.contains(private));
15403
15404        let (outcome, _session, forwarded, _) = drive_permission_handshake_frames(
15405            session_id,
15406            [
15407                permission_posture_frame(session_id, 7),
15408                actor_event_frame(hook(1, private, decisions[0].clone())),
15409                ChildFrame::EventBatch { batch },
15410                actor_event_frame(nested),
15411                completed_actor_frame(),
15412            ],
15413            expected_default_permission_posture(7),
15414        )
15415        .await;
15416        assert_eq!(outcome.unwrap().as_deref(), Some("done"));
15417        let wire = serde_json::to_string(&forwarded).unwrap();
15418        assert!(
15419            !wire.contains(private),
15420            "private hook text reached SSE: {wire}"
15421        );
15422
15423        let hooks = forwarded
15424            .iter()
15425            .filter_map(|event| match event {
15426                AgentEvent::HookLifecycle {
15427                    hook_name,
15428                    point,
15429                    phase,
15430                    duration_ms,
15431                    decision,
15432                } => Some((hook_name, point, phase, duration_ms, decision)),
15433                _ => None,
15434            })
15435            .collect::<Vec<_>>();
15436        assert_eq!(hooks.len(), 4);
15437        assert!(hooks.iter().all(|(name, point, _, _, _)| {
15438            name == &&"actor-hook-1".to_string()
15439                && **point == bamboo_domain::AgentHookPoint::BeforeToolExecution
15440        }));
15441        assert_eq!(hooks[0].2, "event");
15442        assert_eq!(hooks[1].2, "completed");
15443        assert_eq!(*hooks[0].3, 1);
15444        assert_eq!(*hooks[3].3, 4);
15445        assert!(matches!(hooks[0].4, HookResult::Deny { reason } if reason == "Hook denied"));
15446        assert!(
15447            matches!(hooks[1].4, HookResult::InjectContext { text } if text == "Hook context hidden")
15448        );
15449        assert!(
15450            matches!(hooks[2].4, HookResult::WithContext { result, text }
15451            if text == "Hook context hidden" && matches!(result.as_ref(),
15452                HookResult::WithContext { result, text }
15453                    if text == "Hook context hidden" && matches!(result.as_ref(),
15454                        HookResult::Deny { reason } if reason == "Hook denied")))
15455        );
15456        assert!(matches!(hooks[3].4, HookResult::Suspend { reason } if reason == "Hook suspended"));
15457        assert!(forwarded.iter().any(|event| matches!(event,
15458            AgentEvent::SubAgentEvent { event, .. }
15459                if matches!(event.as_ref(), AgentEvent::HookLifecycle {
15460                    hook_name, duration_ms: 5,
15461                    decision: HookResult::Abort { reason }, ..
15462                } if hook_name == "actor-hook-1" && reason == "Hook aborted")
15463        )));
15464    }
15465
15466    #[test]
15467    fn actor_hook_display_depth_and_identity_limits_fail_closed() {
15468        let mut display = ActorEventDisplay::default();
15469        let mut deep = HookResult::Allow;
15470        for _ in 0..=MAX_DISPLAY_HOOK_RESULT_DEPTH {
15471            deep = HookResult::WithContext {
15472                result: Box::new(deep),
15473                text: "private-download-bytes".into(),
15474            };
15475        }
15476        let event = |hook_name: String, decision| AgentEvent::HookLifecycle {
15477            hook_name,
15478            point: bamboo_domain::AgentHookPoint::BeforeToolExecution,
15479            phase: "completed".into(),
15480            duration_ms: 7,
15481            decision,
15482        };
15483        assert!(display.project(event("deep".into(), deep), 0).is_none());
15484        assert!(display.overflowed);
15485        assert!(display
15486            .project(
15487                event(
15488                    "private-download-bytes".into(),
15489                    HookResult::Deny {
15490                        reason: "private-download-bytes".into(),
15491                    },
15492                ),
15493                0,
15494            )
15495            .is_none());
15496
15497        let mut oversized = ActorEventDisplay::default();
15498        assert!(oversized
15499            .project(
15500                event("x".repeat(MAX_DISPLAY_ID_BYTES + 1), HookResult::Allow),
15501                0,
15502            )
15503            .is_none());
15504        assert!(oversized.overflowed);
15505
15506        // A new actor connection gets a fresh opaque alias, never the raw
15507        // hook name. Repeated names keep the same alias until the shared
15508        // identity budget is exhausted, then the stream fails closed.
15509        let mut reconnected = ActorEventDisplay::default();
15510        let private = "private-download-bytes";
15511        for _ in 0..2 {
15512            let projected = reconnected
15513                .project(event(private.into(), HookResult::Allow), 0)
15514                .unwrap();
15515            assert!(
15516                matches!(projected, AgentEvent::HookLifecycle { hook_name, .. }
15517                if hook_name == "actor-hook-1")
15518            );
15519        }
15520        assert_eq!(reconnected.identities.load(Ordering::Relaxed), 1);
15521        for index in 1..MAX_DISPLAY_CALLS {
15522            assert!(reconnected
15523                .project(event(format!("hook-{index}"), HookResult::Allow), 0)
15524                .is_some());
15525        }
15526        assert!(reconnected
15527            .project(event("overflow-private".into(), HookResult::Allow), 0)
15528            .is_none());
15529        assert!(reconnected.overflowed);
15530        assert!(reconnected.hook_names.is_empty());
15531    }
15532
15533    #[test]
15534    fn actor_hook_display_preserves_nontext_decision_kinds() {
15535        for result in [
15536            HookResult::Continue,
15537            HookResult::Mutated,
15538            HookResult::Allow,
15539            HookResult::Ask,
15540        ] {
15541            assert_eq!(hook_result_for_display(result.clone(), 0), Some(result));
15542        }
15543    }
15544
15545    #[tokio::test]
15546    async fn malformed_actor_hook_events_never_fall_back_to_raw_display() {
15547        let session_id = "actor-malformed-hook";
15548        let private = "private-download-selector";
15549        let original = AgentEvent::HookLifecycle {
15550            hook_name: "audit".into(),
15551            point: bamboo_domain::AgentHookPoint::BeforeToolExecution,
15552            phase: "completed".into(),
15553            duration_ms: 9,
15554            decision: HookResult::Deny {
15555                reason: private.into(),
15556            },
15557        };
15558        let mut invalid = serde_json::to_value(&original).unwrap();
15559        invalid["decision"]["type"] = serde_json::json!("unknown_decision");
15560        let nested = AgentEvent::SubAgentEvent {
15561            parent_session_id: "parent".into(),
15562            child_session_id: "child".into(),
15563            event: Box::new(original),
15564        };
15565        let mut invalid_nested = serde_json::to_value(nested).unwrap();
15566        invalid_nested["event"]["decision"]["type"] = serde_json::json!("unknown_decision");
15567        let batch = ActorEventBatch {
15568            logical_session: Some(LogicalSessionIdentity {
15569                creation: None,
15570                session_id: session_id.into(),
15571                parent_session_id: Some("permission-parent".into()),
15572                root_session_id: session_id.into(),
15573            }),
15574            activation_id: None,
15575            execution_epoch: 0,
15576            source_node_id: None,
15577            source_actor_id: Some(session_id.into()),
15578            first_seq: 1,
15579            last_seq: 1,
15580            qos: bamboo_subagent::ActorEventQos::classify(&invalid),
15581            events: vec![invalid.clone()],
15582        };
15583        for frame in [
15584            ChildFrame::Event {
15585                event: invalid.clone(),
15586            },
15587            ChildFrame::EventBatch { batch },
15588            ChildFrame::Event {
15589                event: invalid_nested,
15590            },
15591        ] {
15592            let (outcome, _session, forwarded, _) = drive_permission_handshake_frames(
15593                session_id,
15594                [permission_posture_frame(session_id, 7), frame],
15595                expected_default_permission_posture(7),
15596            )
15597            .await;
15598            let error = outcome.unwrap_err().to_string();
15599            assert!(error.contains("malformed AgentEvent"), "{error}");
15600            assert!(!error.contains(private));
15601            assert!(!serde_json::to_string(&forwarded).unwrap().contains(private));
15602        }
15603
15604        let (event_tx, mut event_rx) = mpsc::channel(8);
15605        let mut session = Session::new("legacy-hook", "model");
15606        let mut handshake = PermissionPostureHandshake::NotRequired;
15607        let mut display = ActorEventDisplay::default();
15608        process_actor_event(
15609            invalid,
15610            false,
15611            &mut handshake,
15612            None,
15613            None,
15614            &mut session,
15615            &event_tx,
15616            &mut display,
15617        )
15618        .await
15619        .unwrap();
15620        assert!(display.overflowed);
15621        assert!(event_rx.try_recv().is_err());
15622    }
15623
15624    #[tokio::test]
15625    async fn actor_download_event_batch_hides_output_and_preserves_read_output() {
15626        let session_id = "actor-download-batch";
15627        let private = "private-download-bytes-and-url";
15628        let events = vec![
15629            AgentEvent::ToolStart {
15630                tool_call_id: "download-call".into(),
15631                tool_name: "default::browser".into(),
15632                arguments: serde_json::json!({
15633                    "action":"download", "selector": private, "expected_epoch": 17,
15634                }),
15635            },
15636            AgentEvent::ToolApprovalRequested {
15637                tool_call_id: "download-call".into(),
15638                tool_name: format!("{private}::browser"),
15639                parameters: serde_json::json!({
15640                    "action":"download", "selector": private, "expected_epoch": 17,
15641                }),
15642            },
15643            AgentEvent::ToolStart {
15644                tool_call_id: "read-call".into(),
15645                tool_name: "Read".into(),
15646                arguments: serde_json::json!({"file_path":"README.md"}),
15647            },
15648        ];
15649        let batch = ActorEventBatch {
15650            logical_session: Some(LogicalSessionIdentity {
15651                creation: None,
15652                session_id: session_id.into(),
15653                parent_session_id: Some("permission-parent".into()),
15654                root_session_id: session_id.into(),
15655            }),
15656            activation_id: None,
15657            execution_epoch: 0,
15658            source_node_id: None,
15659            source_actor_id: Some(session_id.into()),
15660            first_seq: 1,
15661            last_seq: events.len() as u64,
15662            qos: bamboo_subagent::ActorEventQos::Durable,
15663            events: events
15664                .into_iter()
15665                .map(|event| serde_json::to_value(event).unwrap())
15666                .collect(),
15667        };
15668        let result = bamboo_agent_core::tools::ToolResult {
15669            success: true,
15670            result: format!("{{\"data_base64\":\"{private}\"}}"),
15671            display_preference: Some(private.into()),
15672            images: vec![bamboo_agent_core::tools::ToolResultImage {
15673                mime_type: "image/png".into(),
15674                data: private.into(),
15675            }],
15676        };
15677        let (outcome, _, forwarded, _) = drive_permission_handshake_frames(
15678            session_id,
15679            [
15680                permission_posture_frame(session_id, 7),
15681                ChildFrame::EventBatch { batch },
15682                actor_event_frame(AgentEvent::ToolToken {
15683                    tool_call_id: "download-call".into(),
15684                    content: private.into(),
15685                }),
15686                actor_event_frame(AgentEvent::ToolToken {
15687                    tool_call_id: "read-call".into(),
15688                    content: "public live output".into(),
15689                }),
15690                actor_event_frame(AgentEvent::ToolComplete {
15691                    tool_call_id: "download-call".into(),
15692                    result,
15693                }),
15694                actor_event_frame(AgentEvent::ToolLifecycle {
15695                    tool_call_id: "download-call".into(),
15696                    tool_name: "browser".into(),
15697                    phase: "finished".into(),
15698                    elapsed_ms: Some(1),
15699                    is_mutating: false,
15700                    auto_approved: false,
15701                    summary: Some(private.into()),
15702                    error: Some(private.into()),
15703                }),
15704                actor_event_frame(AgentEvent::ToolError {
15705                    tool_call_id: "download-call".into(),
15706                    error: private.into(),
15707                }),
15708                actor_event_frame(AgentEvent::ToolComplete {
15709                    tool_call_id: "read-call".into(),
15710                    result: bamboo_agent_core::tools::ToolResult::text(true, "public README text"),
15711                }),
15712                completed_actor_frame(),
15713            ],
15714            expected_default_permission_posture(7),
15715        )
15716        .await;
15717        assert_eq!(outcome.unwrap().as_deref(), Some("done"));
15718        let wire = serde_json::to_string(&forwarded).unwrap();
15719        assert!(
15720            !wire.contains(private),
15721            "private content reached actor SSE: {wire}"
15722        );
15723        assert!(wire.contains("public README text"));
15724        assert!(wire.contains("public live output"));
15725        assert!(forwarded.iter().any(|event| matches!(event,
15726            AgentEvent::ToolComplete { tool_call_id, result }
15727                if tool_call_id == "download-call"
15728                && result.result == "Browser tool result hidden"
15729                && result.images.is_empty() && result.display_preference.is_none()
15730        )));
15731    }
15732
15733    #[tokio::test]
15734    async fn actor_missing_reused_and_nested_tool_identity_fails_closed() {
15735        let private = "private-download-content";
15736        let nested = |event| AgentEvent::SubAgentEvent {
15737            parent_session_id: "parent".into(),
15738            child_session_id: "nested-child".into(),
15739            event: Box::new(event),
15740        };
15741        let (outcome, _, forwarded, _) = drive_permission_handshake_frames(
15742            "actor-reorder",
15743            [
15744                permission_posture_frame("actor-reorder", 7),
15745                actor_event_frame(AgentEvent::ToolComplete {
15746                    tool_call_id: "missing".into(),
15747                    result: bamboo_agent_core::tools::ToolResult::text(true, private),
15748                }),
15749                actor_event_frame(AgentEvent::ToolLifecycle {
15750                    tool_call_id: "missing".into(),
15751                    tool_name: "browser".into(),
15752                    phase: private.into(),
15753                    elapsed_ms: None,
15754                    is_mutating: false,
15755                    auto_approved: false,
15756                    summary: Some(private.into()),
15757                    error: Some(private.into()),
15758                }),
15759                actor_event_frame(AgentEvent::ToolApprovalRequested {
15760                    tool_call_id: "approval-only".into(),
15761                    tool_name: "browser".into(),
15762                    parameters: serde_json::json!({"action":"download","selector":private}),
15763                }),
15764                actor_event_frame(AgentEvent::ToolToken {
15765                    tool_call_id: "approval-only".into(),
15766                    content: private.into(),
15767                }),
15768                actor_event_frame(AgentEvent::ToolStart {
15769                    tool_call_id: "malformed-browser".into(),
15770                    tool_name: format!("{private}::browser"),
15771                    arguments: serde_json::json!({"selector":private}),
15772                }),
15773                actor_event_frame(AgentEvent::ToolComplete {
15774                    tool_call_id: "malformed-browser".into(),
15775                    result: bamboo_agent_core::tools::ToolResult::text(true, private),
15776                }),
15777                actor_event_frame(AgentEvent::ToolStart {
15778                    tool_call_id: "reused".into(),
15779                    tool_name: "Read".into(),
15780                    arguments: serde_json::json!({"file_path":"README.md"}),
15781                }),
15782                actor_event_frame(AgentEvent::ToolStart {
15783                    tool_call_id: "reused".into(),
15784                    tool_name: "browser".into(),
15785                    arguments: serde_json::json!({"action":"download","selector":private}),
15786                }),
15787                actor_event_frame(AgentEvent::ToolToken {
15788                    tool_call_id: "reused".into(),
15789                    content: private.into(),
15790                }),
15791                actor_event_frame(nested(AgentEvent::ToolStart {
15792                    tool_call_id: "nested".into(),
15793                    tool_name: "browser".into(),
15794                    arguments: serde_json::json!({"action":"download","selector":private}),
15795                })),
15796                actor_event_frame(nested(AgentEvent::ToolComplete {
15797                    tool_call_id: "nested".into(),
15798                    result: bamboo_agent_core::tools::ToolResult::text(true, private),
15799                })),
15800                actor_event_frame(AgentEvent::ChildApprovalRequested {
15801                    child_session_id: "nested-child".into(),
15802                    request_id: "approval".into(),
15803                    tool_name: format!("{private}::browser"),
15804                    permission: private.into(),
15805                    resource: private.into(),
15806                }),
15807                actor_event_frame(AgentEvent::ChildApprovalChanged {
15808                    parent_session_id: "parent".into(),
15809                    child_session_id: "nested-child".into(),
15810                    child_attempt: 1,
15811                    request_id: "approval".into(),
15812                    version: 1,
15813                    status: "pending".into(),
15814                    reason: Some(private.into()),
15815                    tool_name: "browser".into(),
15816                    permission: private.into(),
15817                    resource: private.into(),
15818                    created_at: "2026-01-01T00:00:00Z".into(),
15819                    resolved_at: None,
15820                }),
15821                completed_actor_frame(),
15822            ],
15823            expected_default_permission_posture(7),
15824        )
15825        .await;
15826        assert_eq!(outcome.unwrap().as_deref(), Some("done"));
15827        let wire = serde_json::to_string(&forwarded).unwrap();
15828        assert!(
15829            !wire.contains(private),
15830            "private content reached actor SSE: {wire}"
15831        );
15832        assert!(wire.contains("Tool result hidden"));
15833        assert!(wire.contains("Browser tool result hidden"));
15834        assert!(wire.contains("Browser interaction approval"));
15835        assert!(!forwarded
15836            .iter()
15837            .any(|event| matches!(event, AgentEvent::ChildApprovalChanged { .. })));
15838    }
15839
15840    #[test]
15841    fn actor_display_tracking_overflow_never_reclassifies_old_private_id() {
15842        let mut display = ActorEventDisplay::default();
15843        display
15844            .project(
15845                AgentEvent::ToolStart {
15846                    tool_call_id: "private-call".into(),
15847                    tool_name: "browser".into(),
15848                    arguments: serde_json::json!({"action":"download","selector":"private"}),
15849                },
15850                0,
15851            )
15852            .unwrap();
15853        for index in 0..MAX_DISPLAY_CALLS {
15854            display
15855                .project(
15856                    AgentEvent::ToolStart {
15857                        tool_call_id: format!("ordinary-{index}"),
15858                        tool_name: "Read".into(),
15859                        arguments: serde_json::json!({"file_path":"README.md"}),
15860                    },
15861                    0,
15862                )
15863                .unwrap();
15864        }
15865        assert!(display.overflowed);
15866        let forwarded = display
15867            .project(
15868                AgentEvent::ToolComplete {
15869                    tool_call_id: "private-call".into(),
15870                    result: bamboo_agent_core::tools::ToolResult::text(true, "private-bytes"),
15871                },
15872                0,
15873            )
15874            .unwrap();
15875        assert!(!serde_json::to_string(&forwarded)
15876            .unwrap()
15877            .contains("private-bytes"));
15878    }
15879
15880    #[test]
15881    fn nested_actor_display_uses_one_identity_budget_and_bounded_keys() {
15882        let mut display = ActorEventDisplay::default();
15883        let nested = |index: usize| AgentEvent::SubAgentEvent {
15884            parent_session_id: "parent".into(),
15885            child_session_id: format!("child-{index}"),
15886            event: Box::new(AgentEvent::ToolStart {
15887                tool_call_id: format!("read-{index}"),
15888                tool_name: "Read".into(),
15889                arguments: serde_json::json!({"file_path":"README.md"}),
15890            }),
15891        };
15892        for index in 0..(MAX_DISPLAY_CALLS / 2) {
15893            assert!(display.project(nested(index), 0).is_some());
15894        }
15895        assert_eq!(
15896            display.identities.load(Ordering::Relaxed),
15897            MAX_DISPLAY_CALLS
15898        );
15899        assert!(display.project(nested(MAX_DISPLAY_CALLS / 2), 0).is_none());
15900        assert!(display.overflowed);
15901        assert!(display.children.is_empty());
15902
15903        let mut oversized = ActorEventDisplay::default();
15904        assert!(oversized
15905            .project(
15906                AgentEvent::SubAgentEvent {
15907                    parent_session_id: "parent".into(),
15908                    child_session_id: "x".repeat(MAX_DISPLAY_ID_BYTES + 1),
15909                    event: Box::new(AgentEvent::ToolComplete {
15910                        tool_call_id: "call".into(),
15911                        result: bamboo_agent_core::tools::ToolResult::text(
15912                            true,
15913                            "private-download-bytes",
15914                        ),
15915                    }),
15916                },
15917                0,
15918            )
15919            .is_none());
15920        assert!(oversized.overflowed);
15921        assert!(oversized
15922            .project(
15923                AgentEvent::ToolStart {
15924                    tool_call_id: "x".repeat(MAX_DISPLAY_ID_BYTES + 1),
15925                    tool_name: "Read".into(),
15926                    arguments: serde_json::json!({"file_path":"README.md"}),
15927                },
15928                0,
15929            )
15930            .is_none());
15931    }
15932
15933    #[tokio::test]
15934    async fn actor_batch_sequence_gap_invalidates_prior_tool_identity() {
15935        let session_id = "actor-gap";
15936        let batch = |seq, event: AgentEvent| {
15937            let event = serde_json::to_value(event).unwrap();
15938            ChildFrame::EventBatch {
15939                batch: ActorEventBatch {
15940                    logical_session: Some(LogicalSessionIdentity {
15941                        creation: None,
15942                        session_id: session_id.into(),
15943                        parent_session_id: Some("permission-parent".into()),
15944                        root_session_id: session_id.into(),
15945                    }),
15946                    activation_id: None,
15947                    execution_epoch: 0,
15948                    source_node_id: None,
15949                    source_actor_id: Some(session_id.into()),
15950                    first_seq: seq,
15951                    last_seq: seq,
15952                    qos: bamboo_subagent::ActorEventQos::classify(&event),
15953                    events: vec![event],
15954                },
15955            }
15956        };
15957        let (outcome, _, forwarded, _) = drive_permission_handshake_frames(
15958            session_id,
15959            [
15960                permission_posture_frame(session_id, 7),
15961                batch(
15962                    1,
15963                    AgentEvent::ToolStart {
15964                        tool_call_id: "reused-call".into(),
15965                        tool_name: "Read".into(),
15966                        arguments: serde_json::json!({"file_path":"README.md"}),
15967                    },
15968                ),
15969                // Sequence 2 may have replaced this ID with a private browser
15970                // call. The actor's next result must not inherit Read display.
15971                batch(
15972                    3,
15973                    AgentEvent::ToolComplete {
15974                        tool_call_id: "reused-call".into(),
15975                        result: bamboo_agent_core::tools::ToolResult::text(
15976                            true,
15977                            "private-download-bytes",
15978                        ),
15979                    },
15980                ),
15981                completed_actor_frame(),
15982            ],
15983            expected_default_permission_posture(7),
15984        )
15985        .await;
15986        assert_eq!(outcome.unwrap().as_deref(), Some("done"));
15987        let wire = serde_json::to_string(&forwarded).unwrap();
15988        assert!(!wire.contains("private-download-bytes"), "{wire}");
15989        assert!(wire.contains("Tool result hidden"));
15990    }
15991
15992    #[tokio::test]
15993    async fn legacy_malformed_actor_event_invalidates_prior_tool_identity() {
15994        let (event_tx, mut event_rx) = mpsc::channel(8);
15995        let mut session = Session::new("legacy-malformed-private", "model");
15996        let mut handshake = PermissionPostureHandshake::NotRequired;
15997        let mut display = ActorEventDisplay::default();
15998        let read_start = serde_json::to_value(AgentEvent::ToolStart {
15999            tool_call_id: "reused-call".into(),
16000            tool_name: "Read".into(),
16001            arguments: serde_json::json!({"file_path":"README.md"}),
16002        })
16003        .unwrap();
16004        process_actor_event(
16005            read_start,
16006            false,
16007            &mut handshake,
16008            None,
16009            None,
16010            &mut session,
16011            &event_tx,
16012            &mut display,
16013        )
16014        .await
16015        .unwrap();
16016
16017        // A mixed-version actor may send a browser ToolStart whose name has
16018        // the wrong wire type. Serde drops it; the reused ID is no longer safe.
16019        let mut malformed = serde_json::to_value(AgentEvent::ToolStart {
16020            tool_call_id: "reused-call".into(),
16021            tool_name: "browser".into(),
16022            arguments: serde_json::json!({
16023                "action":"download", "selector":"private-download-selector"
16024            }),
16025        })
16026        .unwrap();
16027        malformed["tool_name"] = serde_json::json!(7);
16028        process_actor_event(
16029            malformed,
16030            false,
16031            &mut handshake,
16032            None,
16033            None,
16034            &mut session,
16035            &event_tx,
16036            &mut display,
16037        )
16038        .await
16039        .unwrap();
16040        process_actor_event(
16041            serde_json::to_value(AgentEvent::ToolComplete {
16042                tool_call_id: "reused-call".into(),
16043                result: bamboo_agent_core::tools::ToolResult::text(true, "private-download-bytes"),
16044            })
16045            .unwrap(),
16046            false,
16047            &mut handshake,
16048            None,
16049            None,
16050            &mut session,
16051            &event_tx,
16052            &mut display,
16053        )
16054        .await
16055        .unwrap();
16056        let forwarded = std::iter::from_fn(|| event_rx.try_recv().ok()).collect::<Vec<_>>();
16057        assert_eq!(forwarded.len(), 2);
16058        let wire = serde_json::to_string(&forwarded).unwrap();
16059        assert!(!wire.contains("private-download"), "{wire}");
16060        assert!(wire.contains("Tool result hidden"));
16061    }
16062
16063    #[tokio::test]
16064    async fn actor_initial_batch_acks_in_order_and_rejects_stale_confirmation() {
16065        let temp = tempfile::tempdir().unwrap();
16066        let store = Arc::new(
16067            bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
16068                .await
16069                .unwrap(),
16070        );
16071        let storage: Arc<dyn Storage> = store.clone();
16072        let locked = Arc::new(bamboo_storage::LockedSessionStore::new(storage.clone()));
16073        let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
16074            store.clone(),
16075            bamboo_domain::SessionInboxLimits::default(),
16076        ));
16077        let session_id = "actor-confirmation-order";
16078        let run_id = "actor-run-current";
16079        let mut session = Session::new(session_id, "model");
16080        store.save_session(&session).await.unwrap();
16081        for (id, text) in [("actor-first", "first"), ("actor-second", "second")] {
16082            let mut envelope = bamboo_domain::SessionMessageEnvelope::user_input(session_id, text);
16083            envelope.id = bamboo_domain::SessionMessageId::parse(id).unwrap();
16084            inbox.deliver(&envelope).await.unwrap();
16085        }
16086        inbox
16087            .mark_activation_eligible(
16088                session_id,
16089                2,
16090                bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
16091            )
16092            .await
16093            .unwrap();
16094        let router = SessionActivationRouter::new();
16095        let mut owner_registration = router.register_run(session_id, run_id).await.unwrap();
16096        let binding = SessionInboxRuntimeBinding {
16097            router,
16098            inbox: inbox.clone(),
16099            storage,
16100            persistence: locked,
16101            parent_question_lock: None,
16102        };
16103        let pairs = claim_canonical_deliveries(&binding, &mut session, run_id, usize::MAX, None)
16104            .await
16105            .unwrap();
16106        assert_eq!(
16107            pairs
16108                .iter()
16109                .map(|(claim, _)| claim.envelope.id.as_str())
16110                .collect::<Vec<_>>(),
16111            vec!["actor-first", "actor-second"]
16112        );
16113        let seeded = store.load_session(session_id).await.unwrap().unwrap();
16114        assert_eq!(
16115            seeded
16116                .messages
16117                .iter()
16118                .filter(|message| matches!(message.id.as_str(), "actor-first" | "actor-second"))
16119                .map(|message| message.id.as_str())
16120                .collect::<Vec<_>>(),
16121            vec!["actor-first", "actor-second"],
16122            "host context must be durable before actor dispatch"
16123        );
16124        for (claim, _) in &pairs {
16125            assert_eq!(
16126                seeded
16127                    .messages
16128                    .iter()
16129                    .filter(|message| bamboo_domain::is_matching_session_message(
16130                        message,
16131                        &claim.envelope
16132                    ))
16133                    .count(),
16134                1,
16135                "pre-dispatch host checkpoint must contain exactly one canonical marker for {}",
16136                claim.envelope.id
16137            );
16138        }
16139        assert!(
16140            seeded.session_inbox_admission().is_none_or(|cursor| {
16141                !cursor.contains(&pairs[0].0.envelope.id)
16142                    && !cursor.contains(&pairs[1].0.envelope.id)
16143            }),
16144            "pre-dispatch transcript seeding must not forge worker confirmation"
16145        );
16146        assert_eq!(inbox.inspect(session_id).await.unwrap().claimed, 2);
16147        let claims = pairs
16148            .into_iter()
16149            .map(|(claim, _)| claim)
16150            .collect::<VecDeque<_>>();
16151        let first = claims[0].clone();
16152        let second = claims[1].clone();
16153        let mut stale = admission_confirmation(session_id, &first, "stale-run");
16154        stale.canonical_claim_generation = second.generation;
16155        let mut link = ConfirmationSequenceLink {
16156            frames: VecDeque::from([
16157                ChildFrame::SessionMessageAdmitted {
16158                    confirmation: stale,
16159                },
16160                ChildFrame::SessionMessageAdmitted {
16161                    confirmation: admission_confirmation(session_id, &first, run_id),
16162                },
16163                ChildFrame::SessionMessageAdmitted {
16164                    confirmation: admission_confirmation(session_id, &second, run_id),
16165                },
16166                ChildFrame::Terminal {
16167                    status: TerminalStatus::Completed,
16168                    result: Some("done".to_string()),
16169                    error: None,
16170                    final_event_watermark: None,
16171                    transcript: Vec::new(),
16172                },
16173            ]),
16174            sent: Vec::new(),
16175        };
16176        let (event_tx, _event_rx) = mpsc::channel(8);
16177        let cancel = CancellationToken::new();
16178        let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
16179        let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
16180        let result = drive(ActorDriveContext {
16181            expected_creation: None,
16182            client: &mut link,
16183            parent_session_id: "parent",
16184            child_session_id: session_id,
16185            child_attempt: 0,
16186            approval_registry: None,
16187            approval_decider: None,
16188            approval_reviewer: None,
16189            escalation_bridge: None,
16190            event_tx: &event_tx,
16191            cancel_token: &cancel,
16192            live_rx: &mut live_rx,
16193            delivery_rx: &mut delivery_rx,
16194            logical_session: &mut session,
16195            expected_permission_posture: None,
16196            session_inbox_runtime: Some(&binding),
16197            actor_directory_store: None,
16198            canonical_subagent_tool: None,
16199            ticket_service: None,
16200            activation_run_id: Some(run_id),
16201            execution_epoch: 0,
16202            expected_source_actor_id: session_id,
16203            initial_inflight_claims: claims,
16204            plain_actor: false,
16205            remote_environment_lease: false,
16206            readonly_output: None,
16207            local_history_tools: None,
16208            local_history_read_only: false,
16209            strict_history_output: None,
16210            plain_input: None,
16211            canonical_activation: None,
16212            canonical_placement_ref: None,
16213            actor_event_observer: None,
16214            plain_run: None,
16215            first_frame_timeout: Some(Duration::from_secs(1)),
16216        })
16217        .await
16218        .unwrap();
16219        assert_eq!(result.as_deref(), Some("done"));
16220        assert_eq!(
16221            session
16222                .messages
16223                .iter()
16224                .filter(|message| matches!(message.id.as_str(), "actor-first" | "actor-second"))
16225                .map(|message| message.id.as_str())
16226                .collect::<Vec<_>>(),
16227            vec!["actor-first", "actor-second"]
16228        );
16229        let backlog = inbox.inspect(session_id).await.unwrap();
16230        assert_eq!(backlog.pending + backlog.claimed, 0);
16231        let confirmed = store.load_session(session_id).await.unwrap().unwrap();
16232        let cursor = confirmed
16233            .session_inbox_admission()
16234            .expect("exact worker confirmation must checkpoint the admission cursor");
16235        assert!(cursor.contains(&first.envelope.id));
16236        assert!(cursor.contains(&second.envelope.id));
16237        for claim in [&first, &second] {
16238            assert_eq!(
16239                confirmed
16240                    .messages
16241                    .iter()
16242                    .filter(|message| bamboo_domain::is_matching_session_message(
16243                        message,
16244                        &claim.envelope
16245                    ))
16246                    .count(),
16247                1,
16248                "confirmation must retain one exact canonical marker for {}",
16249                claim.envelope.id
16250            );
16251        }
16252        assert!(inbox
16253            .was_admitted(session_id, &first.envelope.id)
16254            .await
16255            .unwrap());
16256        assert!(inbox
16257            .was_admitted(session_id, &second.envelope.id)
16258            .await
16259            .unwrap());
16260        owner_registration.begin_finalization().await;
16261        owner_registration.finish(2).await.unwrap();
16262    }
16263
16264    #[derive(Default)]
16265    struct RecordingCodexTokenAuthority {
16266        issued_for: std::sync::Mutex<Vec<String>>,
16267        revoked: std::sync::Mutex<Vec<String>>,
16268    }
16269
16270    impl CodexRunTokenAuthority for RecordingCodexTokenAuthority {
16271        fn issue(&self, session_id: &str) -> Result<IssuedCodexRunToken, String> {
16272            self.issued_for
16273                .lock()
16274                .expect("issued fixture lock")
16275                .push(session_id.to_string());
16276            Ok(IssuedCodexRunToken {
16277                token_id: format!("id-{session_id}"),
16278                token: format!("bcx1_secret-{session_id}"),
16279            })
16280        }
16281
16282        fn revoke(&self, token_id: &str) {
16283            self.revoked
16284                .lock()
16285                .expect("revoked fixture lock")
16286                .push(token_id.to_string());
16287        }
16288    }
16289
16290    fn codex_executor(auth_mode: Option<&str>, inherit_user_config: Option<bool>) -> ExecutorSpec {
16291        let bamboo_mode = auth_mode == Some("bamboo")
16292            || (auth_mode.is_none() && !inherit_user_config.unwrap_or(false));
16293        ExecutorSpec::Codex {
16294            binary: None,
16295            model: None,
16296            mode: None,
16297            sandbox: None,
16298            inherit_user_config,
16299            auth_mode: auth_mode.map(str::to_string),
16300            base_url: bamboo_mode.then(|| "http://127.0.0.1:9562/openai/v1".to_string()),
16301            wire_api: Some("responses".to_string()),
16302            provider_key_ref: None,
16303            forward_env: None,
16304            approval_policy: None,
16305            network_access: None,
16306            allow_danger_bypass: None,
16307            permission_profile: None,
16308            workspace_owned: None,
16309        }
16310    }
16311
16312    fn permission_resolution(
16313        requested: bamboo_domain::SessionPermissionMode,
16314        effective: bamboo_domain::PermissionMode,
16315    ) -> bamboo_domain::PermissionModeResolution {
16316        bamboo_domain::PermissionModeResolution {
16317            requested,
16318            effective,
16319        }
16320    }
16321
16322    #[test]
16323    fn permission_posture_mapping_contract_is_exact_for_supported_executors() {
16324        use bamboo_domain::{PermissionMode, SessionPermissionMode};
16325
16326        let default =
16327            permission_resolution(SessionPermissionMode::Default, PermissionMode::Default);
16328        assert_eq!(
16329            expected_permission_executor_mapping(&ExecutorSpec::BambooRuntime, default, false)
16330                .unwrap()
16331                .as_deref(),
16332            Some("bamboo_runtime:default")
16333        );
16334        assert_eq!(
16335            expected_permission_executor_mapping(&ExecutorSpec::Echo, default, false).unwrap(),
16336            None,
16337            "transport-only Echo must not claim the typed permission contract"
16338        );
16339        assert_eq!(
16340            expected_permission_executor_mapping(
16341                &ExecutorSpec::CliAdapter {
16342                    command: "must-not-appear-in-contract".to_string(),
16343                    args: vec!["credential-like-argument".to_string()],
16344                },
16345                default,
16346                false,
16347            )
16348            .unwrap(),
16349            None,
16350            "unimplemented CliAdapter must not leak command data into a contract"
16351        );
16352
16353        let claude = ExecutorSpec::ClaudeCode {
16354            binary: None,
16355            model: None,
16356            permission_mode: Some("default".to_string()),
16357            inherit_user_config: None,
16358            forward_env: None,
16359        };
16360        for (resolution, mapping) in [
16361            (
16362                permission_resolution(SessionPermissionMode::Default, PermissionMode::Plan),
16363                "claude_code:permission_mode=plan",
16364            ),
16365            (
16366                permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
16367                "claude_code:permission_mode=bypassPermissions",
16368            ),
16369            (
16370                permission_resolution(SessionPermissionMode::Default, PermissionMode::AcceptEdits),
16371                "claude_code:permission_mode=acceptEdits",
16372            ),
16373            (
16374                permission_resolution(SessionPermissionMode::Default, PermissionMode::DontAsk),
16375                "claude_code:permission_mode=dontAsk",
16376            ),
16377            (
16378                permission_resolution(
16379                    SessionPermissionMode::Bypass,
16380                    PermissionMode::BypassPermissions,
16381                ),
16382                "claude_code:permission_mode=default",
16383            ),
16384        ] {
16385            assert_eq!(
16386                expected_permission_executor_mapping(&claude, resolution, false)
16387                    .unwrap()
16388                    .as_deref(),
16389                Some(mapping)
16390            );
16391        }
16392        assert_eq!(
16393            expected_permission_executor_mapping(&claude, default, true)
16394                .unwrap()
16395                .as_deref(),
16396            Some("claude_code:blocked_explicit_deny")
16397        );
16398
16399        let mut codex_exec = codex_executor(Some("inherit"), Some(true));
16400        if let ExecutorSpec::Codex {
16401            approval_policy, ..
16402        } = &mut codex_exec
16403        {
16404            *approval_policy = Some("on-failure".to_string());
16405        }
16406        assert_eq!(
16407            expected_permission_executor_mapping(&codex_exec, default, false)
16408                .unwrap()
16409                .as_deref(),
16410            Some("codex_exec:approval_policy=on-failure")
16411        );
16412        assert_eq!(
16413            expected_permission_executor_mapping(
16414                &codex_exec,
16415                permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
16416                false,
16417            )
16418            .unwrap()
16419            .as_deref(),
16420            Some("codex_exec:approval_policy=never")
16421        );
16422        assert_eq!(
16423            expected_permission_executor_mapping(&codex_exec, default, true)
16424                .unwrap()
16425                .as_deref(),
16426            Some("codex_exec:blocked_explicit_deny")
16427        );
16428
16429        let mut codex_app_server = codex_executor(Some("inherit"), Some(true));
16430        if let ExecutorSpec::Codex {
16431            mode,
16432            approval_policy,
16433            ..
16434        } = &mut codex_app_server
16435        {
16436            *mode = Some("app_server".to_string());
16437            *approval_policy = Some("on-request".to_string());
16438        }
16439        assert_eq!(
16440            expected_permission_executor_mapping(&codex_app_server, default, false)
16441                .unwrap()
16442                .as_deref(),
16443            Some("codex_app_server:approvalPolicy=on-request")
16444        );
16445        assert_eq!(
16446            expected_permission_executor_mapping(
16447                &codex_app_server,
16448                permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
16449                false,
16450            )
16451            .unwrap()
16452            .as_deref(),
16453            Some("codex_app_server:approvalPolicy=never")
16454        );
16455        assert_eq!(
16456            expected_permission_executor_mapping(&codex_app_server, default, true)
16457                .unwrap()
16458                .as_deref(),
16459            Some("codex_app_server:blocked_explicit_deny")
16460        );
16461    }
16462
16463    #[test]
16464    fn only_bamboo_managed_non_git_workspaces_are_marked_owned() {
16465        let project = tempfile::tempdir().unwrap();
16466        let managed = project.path().join(".bamboo/worktree/child-571");
16467        std::fs::create_dir_all(&managed).unwrap();
16468        assert!(!workspace_is_bamboo_owned(managed.to_str().unwrap()));
16469        let marker = project
16470            .path()
16471            .join(".bamboo/worktree/.bamboo-owned/child-571");
16472        std::fs::create_dir_all(marker.parent().unwrap()).unwrap();
16473        std::fs::write(&marker, "bamboo/child-571").unwrap();
16474        assert!(workspace_is_bamboo_owned(managed.to_str().unwrap()));
16475        let nested = managed.join("nested/path");
16476        std::fs::create_dir_all(&nested).unwrap();
16477        assert!(workspace_is_bamboo_owned(nested.to_str().unwrap()));
16478
16479        let arbitrary = tempfile::tempdir().unwrap();
16480        assert!(!workspace_is_bamboo_owned(
16481            arbitrary.path().to_str().unwrap()
16482        ));
16483    }
16484
16485    #[test]
16486    fn bamboo_codex_token_is_per_run_redacted_and_revoked_on_guard_drop() {
16487        let authority = Arc::new(RecordingCodexTokenAuthority::default());
16488        let authority_dyn: Arc<dyn CodexRunTokenAuthority> = authority.clone();
16489
16490        let (secrets, guard) = build_codex_run_secrets(
16491            &codex_executor(Some("bamboo"), None),
16492            Some(authority_dyn),
16493            "child-570",
16494        )
16495        .unwrap();
16496
16497        let token = secrets
16498            .codex_provider_token
16499            .as_ref()
16500            .expect("bamboo mode mints a token");
16501        assert_eq!(token.expose(), "bcx1_secret-child-570");
16502        assert!(!format!("{token:?}").contains("secret-child-570"));
16503        assert_eq!(
16504            authority.issued_for.lock().unwrap().as_slice(),
16505            ["child-570"]
16506        );
16507        assert!(authority.revoked.lock().unwrap().is_empty());
16508
16509        drop(guard);
16510        assert_eq!(
16511            authority.revoked.lock().unwrap().as_slice(),
16512            ["id-child-570"]
16513        );
16514    }
16515
16516    #[test]
16517    fn non_bamboo_codex_never_mints_and_bamboo_fails_closed_without_authority() {
16518        let authority = Arc::new(RecordingCodexTokenAuthority::default());
16519        let authority_dyn: Arc<dyn CodexRunTokenAuthority> = authority.clone();
16520        let (secrets, guard) = build_codex_run_secrets(
16521            &codex_executor(Some("custom"), None),
16522            Some(authority_dyn),
16523            "child-custom",
16524        )
16525        .unwrap();
16526        assert!(secrets.codex_provider_token.is_none());
16527        assert!(guard.is_none());
16528        assert!(authority.issued_for.lock().unwrap().is_empty());
16529
16530        let error = build_codex_run_secrets(
16531            &codex_executor(Some("bamboo"), None),
16532            None,
16533            "child-no-authority",
16534        )
16535        .err()
16536        .expect("bamboo mode without an authority must fail closed");
16537        assert!(error.to_string().contains("per-run token authority"));
16538    }
16539
16540    #[test]
16541    fn codex_provisioning_never_leaks_the_session_provider_credential() {
16542        let credentials = vec![ScopedCredential {
16543            provider: "openai".to_string(),
16544            api_key: "upstream-secret-must-not-cross".to_string(),
16545            base_url: None,
16546            provider_type: Some("openai".to_string()),
16547            credential_ref: Some("provider.openai.api_key".to_string()),
16548        }];
16549
16550        for (mode, label) in [
16551            (Some("inherit"), "inherit"),
16552            (Some("api_key"), "api_key"),
16553            (Some("bamboo"), "bamboo"),
16554            (None, "default-bamboo"),
16555        ] {
16556            let runner = ActorChildRunner::new(
16557                format!("codex-{label}-test"),
16558                PathBuf::from("/bin/false"),
16559                Vec::new(),
16560                std::env::temp_dir().join(format!("bamboo-codex-{label}-570")),
16561                codex_executor(mode, None),
16562                credentials.clone(),
16563                "openai".to_string(),
16564                1,
16565            );
16566            let mut session = Session::new(format!("child-{label}"), "model");
16567            session.add_message(bamboo_agent_core::Message::user("test"));
16568            let spec = runner.build_spec(
16569                &session,
16570                &crate::runtime::execution::SpawnJob {
16571                    parent_session_id: "parent".to_string(),
16572                    child_session_id: format!("child-{label}"),
16573                    model: "gpt-5.4".to_string(),
16574                    disabled_tools: None,
16575                },
16576            );
16577            assert!(
16578                spec.secrets.provider_credentials.is_empty(),
16579                "{label} Codex must not receive the session provider key"
16580            );
16581        }
16582    }
16583
16584    #[test]
16585    fn non_codex_provisioning_still_receives_only_its_selected_provider_credential() {
16586        let credentials = vec![
16587            ScopedCredential {
16588                provider: "openai".to_string(),
16589                api_key: "selected-openai-secret".to_string(),
16590                base_url: None,
16591                provider_type: Some("openai".to_string()),
16592                credential_ref: Some("provider.openai.api_key".to_string()),
16593            },
16594            ScopedCredential {
16595                provider: "other".to_string(),
16596                api_key: "unrelated-secret".to_string(),
16597                base_url: None,
16598                provider_type: Some("openai".to_string()),
16599                credential_ref: Some("provider.other.api_key".to_string()),
16600            },
16601        ];
16602        let runner = ActorChildRunner::new(
16603            "echo-test".to_string(),
16604            PathBuf::from("/bin/false"),
16605            Vec::new(),
16606            std::env::temp_dir().join("bamboo-echo-provider-570"),
16607            ExecutorSpec::Echo,
16608            credentials,
16609            "openai".to_string(),
16610            1,
16611        );
16612        let spec = runner.build_spec(
16613            &Session::new("child-echo", "model"),
16614            &crate::runtime::execution::SpawnJob {
16615                parent_session_id: "parent".to_string(),
16616                child_session_id: "child-echo".to_string(),
16617                model: "gpt-5.4".to_string(),
16618                disabled_tools: None,
16619            },
16620        );
16621
16622        assert_eq!(spec.secrets.provider_credentials.len(), 1);
16623        assert_eq!(
16624            spec.secrets.provider_credentials[0].api_key,
16625            "selected-openai-secret"
16626        );
16627    }
16628
16629    #[tokio::test]
16630    async fn live_provider_config_refreshes_credentials_and_default_per_activation() {
16631        fn provider_instance(
16632            provider_type: &str,
16633            api_key: &str,
16634        ) -> bamboo_config::ProviderInstanceConfig {
16635            bamboo_config::ProviderInstanceConfig {
16636                provider_type: provider_type.to_string(),
16637                label: None,
16638                api_key: api_key.to_string(),
16639                api_key_encrypted: None,
16640                credential_ref: None,
16641                base_url: None,
16642                model: None,
16643                fast_model: None,
16644                vision_model: None,
16645                reasoning_effort: None,
16646                responses_only_models: Vec::new(),
16647                request_overrides: None,
16648                enabled: true,
16649                extra: Default::default(),
16650            }
16651        }
16652
16653        let mut initial = bamboo_llm::Config::default();
16654        initial.provider_instances.insert(
16655            "old-provider".to_string(),
16656            provider_instance("openai", "old-key"),
16657        );
16658        initial.default_provider_instance = Some("old-provider".to_string());
16659        let live = Arc::new(tokio::sync::RwLock::new(initial));
16660
16661        let runner = ActorChildRunner::new(
16662            "echo-live-config-test".to_string(),
16663            PathBuf::from("/bin/false"),
16664            Vec::new(),
16665            std::env::temp_dir().join("bamboo-echo-live-config"),
16666            ExecutorSpec::Echo,
16667            Vec::new(),
16668            "stale-provider".to_string(),
16669            1,
16670        )
16671        .with_live_provider_config(live.clone());
16672        let job = crate::runtime::execution::SpawnJob {
16673            parent_session_id: "parent".to_string(),
16674            child_session_id: "child".to_string(),
16675            model: "model".to_string(),
16676            disabled_tools: None,
16677        };
16678
16679        let first = runner
16680            .build_live_spec(&Session::new("child", "model"), &job)
16681            .await;
16682        assert_eq!(
16683            first.model.as_ref().map(|model| model.provider.as_str()),
16684            Some("old-provider")
16685        );
16686        assert_eq!(first.secrets.provider_credentials.len(), 1);
16687        assert_eq!(first.secrets.provider_credentials[0].api_key, "old-key");
16688
16689        let mut reloaded = bamboo_llm::Config::default();
16690        reloaded.provider_instances.insert(
16691            "new-provider".to_string(),
16692            provider_instance("openai", "new-key"),
16693        );
16694        reloaded.default_provider_instance = Some("new-provider".to_string());
16695        *live.write().await = reloaded;
16696
16697        let second = runner
16698            .build_live_spec(&Session::new("child", "model"), &job)
16699            .await;
16700        assert_eq!(
16701            second.model.as_ref().map(|model| model.provider.as_str()),
16702            Some("new-provider")
16703        );
16704        assert_eq!(second.secrets.provider_credentials.len(), 1);
16705        assert_eq!(second.secrets.provider_credentials[0].api_key, "new-key");
16706    }
16707
16708    #[test]
16709    fn custom_codex_provisioning_scopes_only_the_referenced_credential() {
16710        let mut executor = codex_executor(Some("custom"), None);
16711        if let ExecutorSpec::Codex {
16712            base_url,
16713            provider_key_ref,
16714            ..
16715        } = &mut executor
16716        {
16717            *base_url = Some("https://provider.example/v1".to_string());
16718            *provider_key_ref = Some("provider.custom.api_key".to_string());
16719        }
16720        let credentials = vec![
16721            ScopedCredential {
16722                provider: "openai".to_string(),
16723                api_key: "session-provider-secret".to_string(),
16724                base_url: None,
16725                provider_type: Some("openai".to_string()),
16726                credential_ref: Some("provider.openai.api_key".to_string()),
16727            },
16728            ScopedCredential {
16729                provider: "custom".to_string(),
16730                api_key: "selected-secret".to_string(),
16731                base_url: None,
16732                provider_type: Some("openai".to_string()),
16733                credential_ref: Some("provider.custom.api_key".to_string()),
16734            },
16735            ScopedCredential {
16736                provider: "other".to_string(),
16737                api_key: "unrelated-secret".to_string(),
16738                base_url: None,
16739                provider_type: Some("openai".to_string()),
16740                credential_ref: Some("provider.other.api_key".to_string()),
16741            },
16742        ];
16743        let runner = ActorChildRunner::new(
16744            "codex-test".to_string(),
16745            PathBuf::from("/bin/false"),
16746            Vec::new(),
16747            std::env::temp_dir().join("bamboo-codex-570"),
16748            executor,
16749            credentials,
16750            "openai".to_string(),
16751            1,
16752        );
16753        let mut session = Session::new("child-custom", "model");
16754        session.add_message(bamboo_agent_core::Message::user("test"));
16755        let spec = runner.build_spec(
16756            &session,
16757            &crate::runtime::execution::SpawnJob {
16758                parent_session_id: "parent".to_string(),
16759                child_session_id: "child-custom".to_string(),
16760                model: "gpt-5.4".to_string(),
16761                disabled_tools: None,
16762            },
16763        );
16764
16765        assert_eq!(spec.secrets.provider_credentials.len(), 1);
16766        assert_eq!(
16767            spec.secrets.provider_credentials[0]
16768                .credential_ref
16769                .as_deref(),
16770            Some("provider.custom.api_key")
16771        );
16772        assert_eq!(
16773            spec.secrets.provider_credentials[0].api_key,
16774            "selected-secret"
16775        );
16776    }
16777
16778    fn spec_with(
16779        role: &str,
16780        provider: &str,
16781        model: &str,
16782        workspace: Option<&str>,
16783        disabled: Option<Vec<&str>>,
16784    ) -> ProvisionSpec {
16785        let mut spec = ProvisionSpec::new(
16786            ChildIdentity {
16787                child_id: "c".into(),
16788                parent_id: None,
16789                project_key: None,
16790                role: role.into(),
16791                depth: 0,
16792            },
16793            ExecutorSpec::Echo,
16794            "/tmp/fab".into(),
16795        );
16796        spec.workspace = workspace.map(|w| w.to_string());
16797        spec.model = Some(ModelRefSpec {
16798            provider: provider.into(),
16799            model: model.into(),
16800        });
16801        spec.disabled_tools = disabled.map(|d| d.into_iter().map(String::from).collect());
16802        spec
16803    }
16804
16805    #[test]
16806    fn fingerprint_matches_interchangeable_children() {
16807        // Same role/provider/model/workspace and equal tool sets (order-insensitive)
16808        // are interchangeable on one warm worker — and differ only in child_id.
16809        let a = spec_with(
16810            "explorer",
16811            "p",
16812            "m",
16813            Some("/ws"),
16814            Some(vec!["Bash", "Edit"]),
16815        );
16816        let mut b = spec_with(
16817            "explorer",
16818            "p",
16819            "m",
16820            Some("/ws"),
16821            Some(vec!["Edit", "Bash"]),
16822        );
16823        b.identity.child_id = "other".into();
16824        assert_eq!(
16825            ActorChildRunner::fingerprint(&a),
16826            ActorChildRunner::fingerprint(&b)
16827        );
16828    }
16829
16830    #[test]
16831    fn logical_identity_is_invariant_across_local_remote_scheduled_and_warm_reuse() {
16832        let mut session =
16833            Session::new_child("logical-child-681", "logical-parent-681", "model", "child");
16834        session.root_session_id = "logical-root-681".to_string();
16835        let job = SpawnJob {
16836            parent_session_id: "logical-parent-681".to_string(),
16837            child_session_id: "logical-child-681".to_string(),
16838            model: "model".to_string(),
16839            disabled_tools: None,
16840        };
16841        let expected = LogicalSessionIdentity {
16842            creation: None,
16843            session_id: "logical-child-681".to_string(),
16844            parent_session_id: Some("logical-parent-681".to_string()),
16845            root_session_id: "logical-root-681".to_string(),
16846        };
16847
16848        let placements_and_transport_ids = [
16849            (Placement::Local, "local-mailbox-first"),
16850            (
16851                Placement::Remote {
16852                    endpoint: "wss://remote.example/actor".to_string(),
16853                },
16854                "remote-process-44",
16855            ),
16856            (
16857                Placement::Schedulable {
16858                    pool: "gpu-pool".to_string(),
16859                },
16860                "scheduled-mailbox-9",
16861            ),
16862            // Same logical child reactivated on a different pooled mailbox.
16863            (Placement::Local, "warm-mailbox-reused-77"),
16864        ];
16865        for (placement, transport_id) in placements_and_transport_ids {
16866            let mut provision = spec_with("worker", "provider", "model", None, None);
16867            provision.placement = placement;
16868            provision.identity.child_id = transport_id.to_string();
16869            assert_eq!(logical_identity_for_actor_run(&session, &job), expected);
16870            assert_ne!(
16871                provision.identity.child_id, expected.session_id,
16872                "test fixture must prove transport identity is independent"
16873            );
16874        }
16875        let mut original = spec_with("worker", "provider", "model", Some("/ws"), None);
16876        original.executor = ExecutorSpec::BambooRuntime;
16877        original.identity.depth = session.spawn_depth;
16878        original.capabilities.required_child_context = true;
16879        original.capabilities.child_creation_identity = true;
16880        original.capabilities.enforce_permissions = true;
16881        original.capabilities.native_tool_ceiling_required = true;
16882        original.capabilities.native_tool_ceiling =
16883            Some(bamboo_subagent::proto::NativeToolCeiling {
16884                version: 1,
16885                child_session_id: session.id.clone(),
16886                parent_session_id: job.parent_session_id.clone(),
16887                root_session_id: session.root_session_id.clone(),
16888                created_at: session.created_at,
16889                spawn_depth: session.spawn_depth,
16890                project_id: None,
16891                tools: vec![],
16892            });
16893        original.validate().unwrap();
16894        let first = ActorChildRunner::fresh_required_worker_spec(&original);
16895        let second = ActorChildRunner::fresh_required_worker_spec(&original);
16896        assert_ne!(first.identity.child_id, second.identity.child_id);
16897        assert_ne!(first.identity.child_id, original.identity.child_id);
16898        for physical in [first, second] {
16899            physical.validate().unwrap();
16900            let mut actual = serde_json::to_value(&physical).unwrap();
16901            actual["identity"]["child_id"] = serde_json::json!(original.identity.child_id);
16902            assert_eq!(actual, serde_json::to_value(&original).unwrap());
16903            assert_eq!(logical_identity_for_actor_run(&session, &job), expected);
16904        }
16905    }
16906
16907    #[test]
16908    fn fingerprint_separates_distinct_runtimes() {
16909        let base = spec_with("explorer", "p", "m", Some("/ws"), None);
16910        let base_fp = ActorChildRunner::fingerprint(&base);
16911        // Each axis that is baked into the worker must split the pool bucket.
16912        assert_ne!(
16913            base_fp,
16914            ActorChildRunner::fingerprint(&spec_with("writer", "p", "m", Some("/ws"), None))
16915        );
16916        assert_ne!(
16917            base_fp,
16918            ActorChildRunner::fingerprint(&spec_with("explorer", "p2", "m", Some("/ws"), None))
16919        );
16920        assert_ne!(
16921            base_fp,
16922            ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m2", Some("/ws"), None))
16923        );
16924        assert_ne!(
16925            base_fp,
16926            ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m", Some("/ws2"), None))
16927        );
16928        assert_ne!(
16929            base_fp,
16930            ActorChildRunner::fingerprint(&spec_with(
16931                "explorer",
16932                "p",
16933                "m",
16934                Some("/ws"),
16935                Some(vec!["Bash"])
16936            ))
16937        );
16938    }
16939
16940    #[test]
16941    fn fingerprint_splits_rotated_provider_credentials_without_exposing_them() {
16942        let mut before = spec_with("explorer", "provider", "model", Some("/ws"), None);
16943        before.secrets.provider_credentials.push(ScopedCredential {
16944            provider: "provider".to_string(),
16945            api_key: "old-secret-key".to_string(),
16946            base_url: Some("https://provider.example/v1".to_string()),
16947            provider_type: Some("openai".to_string()),
16948            credential_ref: None,
16949        });
16950        let mut after = before.clone();
16951        after.secrets.provider_credentials[0].api_key = "new-secret-key".to_string();
16952
16953        let before_fingerprint = ActorChildRunner::fingerprint(&before);
16954        let after_fingerprint = ActorChildRunner::fingerprint(&after);
16955        assert_ne!(before_fingerprint, after_fingerprint);
16956        assert!(!before_fingerprint.contains("old-secret-key"));
16957        assert!(!after_fingerprint.contains("new-secret-key"));
16958    }
16959
16960    #[test]
16961    fn fingerprint_splits_on_baked_capabilities() {
16962        // Every capability baked once at provision time must split the pool
16963        // bucket, else a worker baked for one posture gets reused for another
16964        // (e.g. a depth-1 worker re-stamping spawn_depth onto a depth-4 child,
16965        // breaking the depth cap; or a bypass worker reused for a non-bypass one).
16966        let base_fp =
16967            ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m", Some("/ws"), None));
16968
16969        let mut creation = spec_with("explorer", "p", "m", Some("/ws"), None);
16970        creation.capabilities.child_creation_identity = true;
16971        let typed = ActorChildRunner::fingerprint(&creation);
16972        assert_ne!(
16973            base_fp, typed,
16974            "legacy worker pool cannot satisfy birth-required Run"
16975        );
16976        creation.identity.child_id = "different-logical-child".into();
16977        assert_eq!(
16978            typed,
16979            ActorChildRunner::fingerprint(&creation),
16980            "birth-required mode retains cross-Child pool reuse"
16981        );
16982
16983        let mut depth = spec_with("explorer", "p", "m", Some("/ws"), None);
16984        depth.identity.depth = 2;
16985        assert_ne!(
16986            base_fp,
16987            ActorChildRunner::fingerprint(&depth),
16988            "depth must split"
16989        );
16990
16991        let mut nested = spec_with("explorer", "p", "m", Some("/ws"), None);
16992        nested.capabilities.nested_spawn = true;
16993        assert_ne!(
16994            base_fp,
16995            ActorChildRunner::fingerprint(&nested),
16996            "nested_spawn must split"
16997        );
16998
16999        let mut bypass = spec_with("explorer", "p", "m", Some("/ws"), None);
17000        bypass.capabilities.bypass = true;
17001        assert_ne!(
17002            base_fp,
17003            ActorChildRunner::fingerprint(&bypass),
17004            "bypass must split"
17005        );
17006
17007        let mut auto = spec_with("explorer", "p", "m", Some("/ws"), None);
17008        auto.capabilities.auto_approve_permissions = true;
17009        assert_ne!(
17010            base_fp,
17011            ActorChildRunner::fingerprint(&auto),
17012            "auto_approve_permissions must split"
17013        );
17014
17015        let mut global_auto = spec_with("explorer", "p", "m", Some("/ws"), None);
17016        global_auto.capabilities.permission_requested_mode = "default".to_string();
17017        global_auto.capabilities.permission_effective_mode = "auto".to_string();
17018        global_auto.capabilities.auto_approve_permissions = true;
17019        let mut explicit_auto = global_auto.clone();
17020        explicit_auto.capabilities.permission_requested_mode = "auto".to_string();
17021        assert_ne!(
17022            ActorChildRunner::fingerprint(&global_auto),
17023            ActorChildRunner::fingerprint(&explicit_auto),
17024            "permission_requested_mode must split global and explicit Auto"
17025        );
17026
17027        let mut plan_overlay = explicit_auto.clone();
17028        plan_overlay.capabilities.permission_effective_mode = "plan".to_string();
17029        assert_ne!(
17030            ActorChildRunner::fingerprint(&explicit_auto),
17031            ActorChildRunner::fingerprint(&plan_overlay),
17032            "permission_effective_mode must split Plan overlay from Auto"
17033        );
17034
17035        let mut enforce = spec_with("explorer", "p", "m", Some("/ws"), None);
17036        enforce.capabilities.enforce_permissions = true;
17037        assert_ne!(
17038            base_fp,
17039            ActorChildRunner::fingerprint(&enforce),
17040            "enforce_permissions must split"
17041        );
17042
17043        let mut cap = spec_with("explorer", "p", "m", Some("/ws"), None);
17044        cap.capabilities.max_spawn_depth = Some(8);
17045        assert_ne!(
17046            base_fp,
17047            ActorChildRunner::fingerprint(&cap),
17048            "max_spawn_depth must split"
17049        );
17050
17051        // #73 (P1): the worker bakes `no_human_review` from this flag once at
17052        // build(), so it MUST split the pool or a worker baked for one approval
17053        // posture is reused for the opposite one.
17054        let mut nha = spec_with("explorer", "p", "m", Some("/ws"), None);
17055        nha.capabilities.no_human_approver = true;
17056        assert_ne!(
17057            base_fp,
17058            ActorChildRunner::fingerprint(&nha),
17059            "no_human_approver must split"
17060        );
17061
17062        // The generic read-only no-shell checker is baked once at build(), so a
17063        // planner/Guardian worker must not be reused for an ordinary child.
17064        let mut read_only = spec_with("explorer", "p", "m", Some("/ws"), None);
17065        read_only.capabilities.read_only = true;
17066        assert_ne!(
17067            base_fp,
17068            ActorChildRunner::fingerprint(&read_only),
17069            "read_only must split"
17070        );
17071
17072        // Preserve the legacy Guardian bit as its own rolling-upgrade axis.
17073        let mut gro = spec_with("explorer", "p", "m", Some("/ws"), None);
17074        gro.capabilities.guardian_read_only = true;
17075        assert_ne!(
17076            base_fp,
17077            ActorChildRunner::fingerprint(&gro),
17078            "guardian_read_only must split"
17079        );
17080    }
17081
17082    #[test]
17083    fn fingerprint_splits_codex_exec_and_app_server_workers() {
17084        let mut exec = spec_with("explorer", "p", "m", Some("/ws"), None);
17085        exec.executor = codex_executor(Some("inherit"), None);
17086        let mut app_server = exec.clone();
17087        if let ExecutorSpec::Codex { mode, .. } = &mut app_server.executor {
17088            *mode = Some("app_server".to_string());
17089        }
17090        assert_ne!(
17091            ActorChildRunner::fingerprint(&exec),
17092            ActorChildRunner::fingerprint(&app_server)
17093        );
17094    }
17095
17096    struct StaticDecider(bool);
17097
17098    #[async_trait]
17099    impl ChildApprovalDecider for StaticDecider {
17100        async fn decide(&self, _child: &str, _req: &serde_json::Value) -> bool {
17101            self.0
17102        }
17103    }
17104
17105    struct RecordingReviewer {
17106        reviewed: mpsc::UnboundedSender<(String, String, serde_json::Value)>,
17107    }
17108
17109    #[async_trait]
17110    impl ChildApprovalReviewer for RecordingReviewer {
17111        async fn review(&self, parent: &str, child: &str, request: &serde_json::Value) -> bool {
17112            let _ = self
17113                .reviewed
17114                .send((parent.to_string(), child.to_string(), request.clone()));
17115            true
17116        }
17117    }
17118
17119    // ---- first-frame watchdog (dead-pooled-worker recovery) -----------------
17120
17121    /// A link that never yields a frame — models a worker that died (or never
17122    /// subscribed) so its Run sits queued with no server.
17123    struct SilentLink;
17124    #[async_trait]
17125    impl bamboo_subagent::ChildLink for SilentLink {
17126        async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
17127            Ok(())
17128        }
17129        async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
17130            std::future::pending().await
17131        }
17132    }
17133
17134    /// A link that immediately yields one terminal frame (a healthy fast worker).
17135    struct InstantTerminalLink {
17136        done: bool,
17137    }
17138
17139    struct ApprovalRoundTripLink {
17140        step: u8,
17141        approval_reply: Option<(String, bool)>,
17142    }
17143
17144    #[async_trait]
17145    impl bamboo_subagent::ChildLink for ApprovalRoundTripLink {
17146        async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
17147            if let ParentFrame::ApprovalReply { id, approved } = frame {
17148                self.approval_reply = Some((id, approved));
17149                self.step = 2;
17150            }
17151            Ok(())
17152        }
17153
17154        async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
17155            match self.step {
17156                0 => {
17157                    self.step = 1;
17158                    Ok(Some(ChildFrame::ApprovalRequest {
17159                        id: "approval-1".into(),
17160                        body: serde_json::json!({
17161                            "tool_name": "Bash",
17162                            "permission": "execute",
17163                            "resource": "rm -rf target",
17164                            "permission_request": {"reason_code": "hard_dangerous"}
17165                        }),
17166                    }))
17167                }
17168                1 => std::future::pending().await,
17169                2 => {
17170                    self.step = 3;
17171                    Ok(Some(ChildFrame::Terminal {
17172                        status: TerminalStatus::Completed,
17173                        result: Some("done".into()),
17174                        error: None,
17175                        final_event_watermark: None,
17176                        transcript: vec![],
17177                    }))
17178                }
17179                _ => std::future::pending().await,
17180            }
17181        }
17182    }
17183
17184    #[tokio::test]
17185    async fn drive_routes_forced_ask_to_parent_reviewer_without_human_event() {
17186        let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(8);
17187        let (review_tx, mut review_rx) = mpsc::unbounded_channel();
17188        let reviewer: Arc<dyn ChildApprovalReviewer> = Arc::new(RecordingReviewer {
17189            reviewed: review_tx,
17190        });
17191        let cancel = CancellationToken::new();
17192        let (live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
17193        let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
17194        let mut logical_session = Session::new("child-reviewer", "model");
17195        let live_guard = crate::external_agents::live::register("child-reviewer", live_tx, 0, None);
17196        let mut link = ApprovalRoundTripLink {
17197            step: 0,
17198            approval_reply: None,
17199        };
17200
17201        let result = tokio::time::timeout(
17202            Duration::from_secs(1),
17203            drive(ActorDriveContext {
17204                expected_creation: None,
17205                client: &mut link,
17206                parent_session_id: "parent-reviewer",
17207                child_session_id: "child-reviewer",
17208                child_attempt: 0,
17209                approval_registry: None,
17210                approval_decider: None,
17211                approval_reviewer: Some(&reviewer),
17212                escalation_bridge: None,
17213                event_tx: &event_tx,
17214                cancel_token: &cancel,
17215                live_rx: &mut live_rx,
17216                delivery_rx: &mut delivery_rx,
17217                logical_session: &mut logical_session,
17218                expected_permission_posture: None,
17219                session_inbox_runtime: None,
17220                actor_directory_store: None,
17221                canonical_subagent_tool: None,
17222                ticket_service: None,
17223                activation_run_id: None,
17224                execution_epoch: 0,
17225                expected_source_actor_id: "child-reviewer",
17226                initial_inflight_claims: VecDeque::new(),
17227                plain_actor: false,
17228                remote_environment_lease: false,
17229                readonly_output: None,
17230                local_history_tools: None,
17231                local_history_read_only: false,
17232                strict_history_output: None,
17233                plain_input: None,
17234                canonical_activation: None,
17235                canonical_placement_ref: None,
17236                actor_event_observer: None,
17237                plain_run: None,
17238                first_frame_timeout: None,
17239            }),
17240        )
17241        .await
17242        .expect("worker must receive the reviewer verdict before terminating");
17243
17244        assert_eq!(result.ok().flatten().as_deref(), Some("done"));
17245        assert_eq!(
17246            link.approval_reply,
17247            Some(("approval-1".to_string(), true)),
17248            "reviewer verdict must traverse the live route back to the worker"
17249        );
17250        let (parent, child, body) = tokio::time::timeout(Duration::from_secs(1), review_rx.recv())
17251            .await
17252            .expect("reviewer should be invoked off-loop")
17253            .expect("review channel should remain open");
17254        assert_eq!(parent, "parent-reviewer");
17255        assert_eq!(child, "child-reviewer");
17256        assert_eq!(
17257            body.pointer("/permission_request/reason_code")
17258                .and_then(serde_json::Value::as_str),
17259            Some("hard_dangerous")
17260        );
17261        assert!(
17262            event_rx.try_recv().is_err(),
17263            "must not emit a human-review event"
17264        );
17265        drop(live_guard);
17266    }
17267
17268    #[tokio::test]
17269    async fn drive_denies_forced_ask_without_parent_reviewer_or_manual_event() {
17270        let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(8);
17271        let cancel = CancellationToken::new();
17272        let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
17273        let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
17274        let mut logical_session = Session::new("child-no-reviewer", "model");
17275        let mut link = ApprovalRoundTripLink {
17276            step: 0,
17277            approval_reply: None,
17278        };
17279
17280        let result = tokio::time::timeout(
17281            Duration::from_secs(1),
17282            drive(ActorDriveContext {
17283                expected_creation: None,
17284                client: &mut link,
17285                parent_session_id: "parent-no-reviewer",
17286                child_session_id: "child-no-reviewer",
17287                child_attempt: 0,
17288                approval_registry: None,
17289                approval_decider: None,
17290                approval_reviewer: None,
17291                escalation_bridge: None,
17292                event_tx: &event_tx,
17293                cancel_token: &cancel,
17294                live_rx: &mut live_rx,
17295                delivery_rx: &mut delivery_rx,
17296                logical_session: &mut logical_session,
17297                expected_permission_posture: None,
17298                session_inbox_runtime: None,
17299                actor_directory_store: None,
17300                canonical_subagent_tool: None,
17301                ticket_service: None,
17302                activation_run_id: None,
17303                execution_epoch: 0,
17304                expected_source_actor_id: "child-no-reviewer",
17305                initial_inflight_claims: VecDeque::new(),
17306                plain_actor: false,
17307                remote_environment_lease: false,
17308                readonly_output: None,
17309                local_history_tools: None,
17310                local_history_read_only: false,
17311                strict_history_output: None,
17312                plain_input: None,
17313                canonical_activation: None,
17314                canonical_placement_ref: None,
17315                actor_event_observer: None,
17316                plain_run: None,
17317                first_frame_timeout: None,
17318            }),
17319        )
17320        .await
17321        .expect("fail-closed reply must unblock the child immediately");
17322
17323        assert_eq!(result.ok().flatten().as_deref(), Some("done"));
17324        assert_eq!(link.approval_reply, Some(("approval-1".to_string(), false)));
17325        assert!(
17326            event_rx.try_recv().is_err(),
17327            "missing parent review must not surface a manual approval event"
17328        );
17329    }
17330    #[async_trait]
17331    impl bamboo_subagent::ChildLink for InstantTerminalLink {
17332        async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
17333            Ok(())
17334        }
17335        async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
17336            if self.done {
17337                std::future::pending().await
17338            } else {
17339                self.done = true;
17340                Ok(Some(ChildFrame::Terminal {
17341                    status: TerminalStatus::Completed,
17342                    result: Some("done".into()),
17343                    error: None,
17344                    final_event_watermark: None,
17345                    transcript: vec![],
17346                }))
17347            }
17348        }
17349    }
17350
17351    #[tokio::test]
17352    async fn drive_trips_first_frame_watchdog_on_a_silent_worker() {
17353        let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
17354        let cancel = CancellationToken::new();
17355        let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
17356        let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
17357        let mut logical_session = Session::new("child-x", "model");
17358        let mut link = SilentLink;
17359        let r = drive(ActorDriveContext {
17360            expected_creation: None,
17361            client: &mut link,
17362            parent_session_id: "parent-x",
17363            child_session_id: "child-x",
17364            child_attempt: 0,
17365            approval_registry: None,
17366            approval_decider: None,
17367            approval_reviewer: None,
17368            escalation_bridge: None,
17369            event_tx: &event_tx,
17370            cancel_token: &cancel,
17371            live_rx: &mut live_rx,
17372            delivery_rx: &mut delivery_rx,
17373            logical_session: &mut logical_session,
17374            expected_permission_posture: None,
17375            session_inbox_runtime: None,
17376            actor_directory_store: None,
17377            canonical_subagent_tool: None,
17378            ticket_service: None,
17379            activation_run_id: None,
17380            execution_epoch: 0,
17381            expected_source_actor_id: "child-x",
17382            initial_inflight_claims: VecDeque::new(),
17383            plain_actor: false,
17384            remote_environment_lease: false,
17385            readonly_output: None,
17386            local_history_tools: None,
17387            local_history_read_only: false,
17388            strict_history_output: None,
17389            plain_input: None,
17390            canonical_activation: None,
17391            canonical_placement_ref: None,
17392            actor_event_observer: None,
17393            plain_run: None,
17394            first_frame_timeout: Some(Duration::from_millis(100)),
17395        })
17396        .await;
17397        assert!(
17398            matches!(r, Err(AgentError::WorkerUnresponsive(_))),
17399            "a silent worker must trip the first-frame watchdog, got {r:?}"
17400        );
17401    }
17402
17403    #[tokio::test]
17404    async fn drive_does_not_trip_when_a_frame_arrives() {
17405        let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
17406        let cancel = CancellationToken::new();
17407        let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
17408        let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
17409        let mut logical_session = Session::new("child-y", "model");
17410        let mut link = InstantTerminalLink { done: false };
17411        // Even a tiny timeout must NOT trip: the terminal frame arrives first and
17412        // disarms the watchdog.
17413        let r = drive(ActorDriveContext {
17414            expected_creation: None,
17415            client: &mut link,
17416            parent_session_id: "parent-y",
17417            child_session_id: "child-y",
17418            child_attempt: 0,
17419            approval_registry: None,
17420            approval_decider: None,
17421            approval_reviewer: None,
17422            escalation_bridge: None,
17423            event_tx: &event_tx,
17424            cancel_token: &cancel,
17425            live_rx: &mut live_rx,
17426            delivery_rx: &mut delivery_rx,
17427            logical_session: &mut logical_session,
17428            expected_permission_posture: None,
17429            session_inbox_runtime: None,
17430            actor_directory_store: None,
17431            canonical_subagent_tool: None,
17432            ticket_service: None,
17433            activation_run_id: None,
17434            execution_epoch: 0,
17435            expected_source_actor_id: "child-y",
17436            initial_inflight_claims: VecDeque::new(),
17437            plain_actor: false,
17438            remote_environment_lease: false,
17439            readonly_output: None,
17440            local_history_tools: None,
17441            local_history_read_only: false,
17442            strict_history_output: None,
17443            plain_input: None,
17444            canonical_activation: None,
17445            canonical_placement_ref: None,
17446            actor_event_observer: None,
17447            plain_run: None,
17448            first_frame_timeout: Some(Duration::from_millis(50)),
17449        })
17450        .await;
17451        assert_eq!(r.ok().flatten().as_deref(), Some("done"));
17452    }
17453
17454    #[tokio::test]
17455    async fn child_approval_fails_closed_without_decider() {
17456        // No decider wired ⇒ the host denies (safe default), unchanged behavior.
17457        let body = serde_json::json!({"tool_name":"Bash","permission":"run","resource":"rm -rf /"});
17458        assert!(!decide_child_approval(None, "child-1", &body).await);
17459    }
17460
17461    #[tokio::test]
17462    async fn child_approval_honors_wired_decider() {
17463        let body =
17464            serde_json::json!({"tool_name":"Write","permission":"write","resource":"/tmp/x"});
17465        let approve: Arc<dyn ChildApprovalDecider> = Arc::new(StaticDecider(true));
17466        let deny: Arc<dyn ChildApprovalDecider> = Arc::new(StaticDecider(false));
17467        assert!(decide_child_approval(Some(&approve), "child-1", &body).await);
17468        assert!(!decide_child_approval(Some(&deny), "child-1", &body).await);
17469    }
17470
17471    // ---- #193: remote placement routing -------------------------------------
17472
17473    use crate::runtime::execution::SpawnJob;
17474    use bamboo_agent_core::Session;
17475
17476    /// A runner with a BOGUS worker_bin (`/bin/false`): a local spawn here would
17477    /// FAIL, so a passing remote test proves the remote path never spawns.
17478    fn bogus_runner(placements: HashMap<String, ResolvedRemotePlacement>) -> ActorChildRunner {
17479        ActorChildRunner::new(
17480            "test-actor".into(),
17481            PathBuf::from("/bin/false"),
17482            vec![],
17483            std::env::temp_dir().join("bamboo-test-fab-193"),
17484            ExecutorSpec::Echo,
17485            vec![],
17486            "anthropic".into(),
17487            4,
17488        )
17489        .with_remote_placements(placements)
17490    }
17491
17492    /// A child session of the given role (the role rides `subagent_type`, the
17493    /// path build_spec + the remote lookup both read).
17494    fn session_of_role(role: &str, assignment: &str) -> Session {
17495        let mut s = Session::new("child-1", "test-model");
17496        s.metadata
17497            .insert("subagent_type".to_string(), role.to_string());
17498        s.add_message(bamboo_agent_core::Message::user(assignment));
17499        s
17500    }
17501
17502    fn job_for(child: &str) -> SpawnJob {
17503        SpawnJob {
17504            parent_session_id: "parent-1".into(),
17505            child_session_id: child.into(),
17506            model: String::new(),
17507            disabled_tools: None,
17508        }
17509    }
17510
17511    #[tokio::test]
17512    async fn spawn_depth_provisioning_uses_live_policy_for_local_and_remote_workers() {
17513        let live = Arc::new(tokio::sync::RwLock::new(bamboo_llm::Config::default()));
17514        live.write().await.subagents_mut().max_spawn_depth = Some(1);
17515        let mut remote = HashMap::new();
17516        remote.insert(
17517            "remote-role".into(),
17518            ResolvedRemotePlacement {
17519                endpoint: "wss://worker.example:8443".into(),
17520                token: None,
17521                ca_cert_file: None,
17522                host_label: None,
17523                broker_peer: None,
17524                requirements: None,
17525            },
17526        );
17527        let runner = bogus_runner(remote).with_live_provider_config(live.clone());
17528        let root = Session::new("provision-root", "model");
17529        for role in ["local-role", "remote-role"] {
17530            let mut child =
17531                Session::new_child_of(format!("provision-{role}"), &root, "model", "child");
17532            child.metadata.insert("subagent_type".into(), role.into());
17533            let first = runner.build_live_spec(&child, &job_for(&child.id)).await;
17534            assert_eq!(first.capabilities.max_spawn_depth, Some(1));
17535            assert!(!first.capabilities.nested_spawn);
17536            assert_eq!(
17537                matches!(first.placement, Placement::Remote { .. }),
17538                role == "remote-role"
17539            );
17540            live.write().await.subagents_mut().max_spawn_depth = Some(7);
17541            let second = runner.build_live_spec(&child, &job_for(&child.id)).await;
17542            assert_eq!(second.capabilities.max_spawn_depth, Some(7));
17543            assert!(second.capabilities.nested_spawn);
17544            assert_ne!(
17545                ActorChildRunner::fingerprint(&first),
17546                ActorChildRunner::fingerprint(&second)
17547            );
17548            assert_eq!(second.identity.depth, 1);
17549            live.write().await.subagents_mut().max_spawn_depth = Some(1);
17550        }
17551        let fallback = bogus_runner(HashMap::new()).with_max_spawn_depth(0);
17552        let spec = fallback.build_spec(&root, &job_for(&root.id));
17553        assert_eq!(spec.capabilities.max_spawn_depth, Some(0));
17554        assert!(!spec.capabilities.nested_spawn);
17555    }
17556
17557    struct RecordingChildSessionPort {
17558        parent: Session,
17559        saved: std::sync::Mutex<Option<Session>>,
17560    }
17561
17562    impl RecordingChildSessionPort {
17563        fn saved_child(&self) -> Session {
17564            self.saved
17565                .lock()
17566                .expect("saved-child fixture lock")
17567                .clone()
17568                .expect("create_child_action must save the child")
17569        }
17570    }
17571
17572    #[async_trait]
17573    impl crate::session_app::child_session::ChildSessionPort for RecordingChildSessionPort {
17574        async fn load_root_session(
17575            &self,
17576            root_id: &str,
17577        ) -> Result<Session, crate::session_app::child_session::ChildSessionError> {
17578            assert_eq!(root_id, self.parent.id);
17579            Ok(self.parent.clone())
17580        }
17581
17582        async fn load_child_for_parent(
17583            &self,
17584            _parent_id: &str,
17585            _child_id: &str,
17586        ) -> Result<Session, crate::session_app::child_session::ChildSessionError> {
17587            unreachable!("create_child_action does not reload the child")
17588        }
17589
17590        async fn save_child_session(
17591            &self,
17592            child: &mut Session,
17593        ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17594            *self.saved.lock().expect("saved-child fixture lock") = Some(child.clone());
17595            Ok(())
17596        }
17597
17598        async fn save_child_session_authoritative_flags(
17599            &self,
17600            _child: &mut Session,
17601        ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17602            unreachable!("new-child creation uses the ordinary save")
17603        }
17604
17605        async fn is_child_running(&self, _child_id: &str) -> bool {
17606            false
17607        }
17608
17609        async fn list_children(
17610            &self,
17611            _parent_id: &str,
17612        ) -> Vec<crate::session_app::child_session::ChildSessionEntry> {
17613            Vec::new()
17614        }
17615
17616        async fn enqueue_child_run(
17617            &self,
17618            _parent: &Session,
17619            _child: &Session,
17620        ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17621            unreachable!("fixture creates the child with auto_run=false")
17622        }
17623
17624        async fn cancel_child_run_and_wait(
17625            &self,
17626            _child_id: &str,
17627        ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17628            unreachable!("create_child_action does not cancel")
17629        }
17630
17631        async fn delete_child_session(
17632            &self,
17633            _parent_id: &str,
17634            _child_id: &str,
17635        ) -> Result<
17636            crate::session_app::child_session::DeleteChildResult,
17637            crate::session_app::child_session::ChildSessionError,
17638        > {
17639            unreachable!("create_child_action does not delete")
17640        }
17641
17642        async fn get_child_runner_info(
17643            &self,
17644            _child_id: &str,
17645        ) -> Option<crate::session_app::child_session::ChildRunnerInfo> {
17646            None
17647        }
17648
17649        async fn register_parent_wait_for_child(
17650            &self,
17651            _parent_session_id: &str,
17652            _child_session_id: &str,
17653            _tool_call_id: Option<&str>,
17654        ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17655            unreachable!("create_child_action does not register a wait")
17656        }
17657
17658        async fn register_parent_wait_for_children(
17659            &self,
17660            _parent_session_id: &str,
17661            _child_session_ids: &[String],
17662            _policy: bamboo_domain::session::runtime_state::ChildWaitPolicy,
17663        ) -> Result<usize, crate::session_app::child_session::ChildSessionError> {
17664            unreachable!("create_child_action does not register a wait")
17665        }
17666
17667        async fn active_child_ids(&self, _parent_session_id: &str) -> Vec<String> {
17668            Vec::new()
17669        }
17670
17671        async fn find_resident_child(
17672            &self,
17673            _root_session_id: &str,
17674            _resident_name: &str,
17675        ) -> Option<String> {
17676            None
17677        }
17678
17679        async fn ensure_child_indexed(&self, _child_session_id: &str) {}
17680    }
17681
17682    #[test]
17683    fn build_spec_sets_remote_placement_for_matching_role() {
17684        let mut placements = HashMap::new();
17685        placements.insert(
17686            "explorer".to_string(),
17687            ResolvedRemotePlacement {
17688                endpoint: "wss://gpu-host:8443".into(),
17689                token: Some("T-secret".into()),
17690                ca_cert_file: None,
17691                host_label: None,
17692                broker_peer: None,
17693                requirements: None,
17694            },
17695        );
17696        let runner = bogus_runner(placements);
17697
17698        // The broker credential never rides the child RunSpec secrets envelope.
17699        let s = session_of_role("explorer", "do the thing");
17700        let spec = runner.build_spec(&s, &job_for("child-1"));
17701        match &spec.placement {
17702            Placement::Remote { endpoint } => assert_eq!(endpoint, "wss://gpu-host:8443"),
17703            other => panic!("expected Remote, got {other:?}"),
17704        }
17705        assert!(spec.secrets.worker_auth_token.is_none());
17706    }
17707
17708    #[test]
17709    fn build_spec_leaves_local_for_unmatched_role() {
17710        let mut placements = HashMap::new();
17711        placements.insert(
17712            "explorer".to_string(),
17713            ResolvedRemotePlacement {
17714                endpoint: "wss://gpu-host:8443".into(),
17715                token: Some("T".into()),
17716                ca_cert_file: None,
17717                host_label: None,
17718                broker_peer: None,
17719                requirements: None,
17720            },
17721        );
17722        let runner = bogus_runner(placements);
17723
17724        // A DIFFERENT role keeps the default Local placement + no bearer.
17725        let s = session_of_role("writer", "do the thing");
17726        let spec = runner.build_spec(&s, &job_for("child-1"));
17727        assert_eq!(spec.placement, Placement::Local);
17728        assert!(spec.secrets.worker_auth_token.is_none());
17729    }
17730
17731    #[test]
17732    fn build_spec_local_when_no_placements() {
17733        let runner = bogus_runner(HashMap::new());
17734        let s = session_of_role("explorer", "do the thing");
17735        let spec = runner.build_spec(&s, &job_for("child-1"));
17736        assert_eq!(spec.placement, Placement::Local);
17737        assert!(spec.secrets.worker_auth_token.is_none());
17738    }
17739
17740    #[test]
17741    fn typed_child_state_is_the_read_only_authority_under_auto_and_bypass() {
17742        for mode in [
17743            bamboo_domain::SessionPermissionMode::Auto,
17744            bamboo_domain::SessionPermissionMode::Bypass,
17745        ] {
17746            let runner = bogus_runner(HashMap::new());
17747            let mut child = session_of_role("planner", "inspect and plan");
17748            let runtime = child
17749                .agent_runtime_state
17750                .get_or_insert_with(bamboo_domain::AgentRuntimeState::default);
17751            runtime.set_permission_mode(mode);
17752            runtime.read_only = true;
17753
17754            let spec = runner.build_spec(&child, &job_for("read-only-child"));
17755
17756            assert!(spec.capabilities.read_only);
17757            assert!(spec.capabilities.read_only_enforced());
17758            assert!(
17759                spec.capabilities.guardian_read_only,
17760                "the compatibility alias must protect planners on older workers"
17761            );
17762            assert_eq!(spec.capabilities.permission_requested_mode, mode.as_str());
17763            assert_eq!(spec.capabilities.permission_effective_mode, "plan");
17764            assert!(
17765                !spec.capabilities.bypass,
17766                "read-only must win over requested {mode:?}"
17767            );
17768            assert_eq!(
17769                spec.capabilities.auto_approve_permissions,
17770                mode == bamboo_domain::SessionPermissionMode::Auto,
17771                "Auto may suppress prompts but must not widen Plan authorization"
17772            );
17773            let disabled = spec
17774                .disabled_tools
17775                .as_ref()
17776                .expect("read-only state must provision a host-owned denylist");
17777            for tool_name in [
17778                "Edit",
17779                "Write",
17780                "Plan",
17781                "SubAgent",
17782                "session_control",
17783                "request_permissions",
17784            ] {
17785                assert!(
17786                    disabled.iter().any(|name| name == tool_name),
17787                    "read-only provisioning must disable {tool_name}"
17788                );
17789            }
17790        }
17791
17792        let runner = bogus_runner(HashMap::new());
17793        let cosmetic_planner = runner.build_spec(
17794            &session_of_role("planner", "label only"),
17795            &job_for("cosmetic"),
17796        );
17797        assert!(
17798            !cosmetic_planner.capabilities.read_only_enforced(),
17799            "a cosmetic role label must not change authority"
17800        );
17801        assert!(cosmetic_planner.disabled_tools.is_none());
17802
17803        let cosmetic_guardian = runner.build_spec(
17804            &session_of_role("guardian", "label only"),
17805            &job_for("cosmetic-guardian"),
17806        );
17807        assert!(
17808            !cosmetic_guardian.capabilities.read_only_enforced(),
17809            "a Guardian label without typed state or the legacy denylist is cosmetic"
17810        );
17811
17812        let legacy_guardian = session_of_role("guardian", "pre-upgrade reviewer");
17813        let mut legacy_job = job_for("legacy-guardian");
17814        legacy_job.disabled_tools = Some(vec![
17815            "Edit".to_string(),
17816            "Write".to_string(),
17817            "SubAgent".to_string(),
17818            "WebFetch".to_string(),
17819        ]);
17820        let legacy_spec = runner.build_spec(&legacy_guardian, &legacy_job);
17821        assert!(
17822            legacy_spec.capabilities.read_only_enforced(),
17823            "an in-flight pre-upgrade Guardian keeps its read-only authority"
17824        );
17825
17826        let mut differently_named = session_of_role("explorer", "typed authority");
17827        differently_named
17828            .agent_runtime_state
17829            .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
17830            .read_only = true;
17831        let typed = runner.build_spec(&differently_named, &job_for("typed"));
17832        assert!(
17833            typed.capabilities.read_only_enforced(),
17834            "typed state must enforce read-only independently of role"
17835        );
17836    }
17837
17838    #[test]
17839    fn typed_read_only_forces_native_external_executor_profiles() {
17840        let mut planner = session_of_role("planner", "inspect and plan");
17841        planner
17842            .agent_runtime_state
17843            .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
17844            .read_only = true;
17845
17846        let claude_runner = ActorChildRunner::new(
17847            "test-actor".into(),
17848            PathBuf::from("/bin/false"),
17849            vec![],
17850            std::env::temp_dir().join("bamboo-test-plan-claude"),
17851            ExecutorSpec::ClaudeCode {
17852                binary: None,
17853                model: None,
17854                permission_mode: Some("bypassPermissions".to_string()),
17855                inherit_user_config: Some(true),
17856                forward_env: None,
17857            },
17858            vec![],
17859            "anthropic".into(),
17860            4,
17861        );
17862        let claude_spec = claude_runner.build_spec(&planner, &job_for("planner-claude"));
17863        let disabled = claude_spec
17864            .disabled_tools
17865            .as_ref()
17866            .expect("typed read-only child must receive a host-owned denylist");
17867        for tool in ["Bash", "BashInput", "BashOutput", "KillShell"] {
17868            assert!(
17869                disabled.iter().any(|name| name == tool),
17870                "typed read-only child must not advertise {tool}"
17871            );
17872        }
17873        assert!(matches!(
17874            claude_spec.executor,
17875            ExecutorSpec::ClaudeCode {
17876                permission_mode: Some(ref mode),
17877                inherit_user_config: Some(false),
17878                ..
17879            } if mode == "plan"
17880        ));
17881        assert_eq!(claude_spec.capabilities.permission_effective_mode, "plan");
17882
17883        let mut codex_exec = codex_executor(Some("inherit"), Some(true));
17884        if let ExecutorSpec::Codex {
17885            sandbox,
17886            approval_policy,
17887            network_access,
17888            allow_danger_bypass,
17889            permission_profile,
17890            ..
17891        } = &mut codex_exec
17892        {
17893            *sandbox = Some("danger-full-access".to_string());
17894            *approval_policy = Some("on-failure".to_string());
17895            *network_access = Some(true);
17896            *allow_danger_bypass = Some(true);
17897            *permission_profile = Some("full-access".to_string());
17898        }
17899        let codex_runner = ActorChildRunner::new(
17900            "test-actor".into(),
17901            PathBuf::from("/bin/false"),
17902            vec![],
17903            std::env::temp_dir().join("bamboo-test-plan-codex"),
17904            codex_exec,
17905            vec![],
17906            "openai".into(),
17907            4,
17908        );
17909        let codex_spec = codex_runner.build_spec(&planner, &job_for("planner-codex"));
17910        assert_eq!(
17911            expected_permission_executor_mapping(
17912                &codex_spec.executor,
17913                codex_spec.capabilities.permission_resolution().unwrap(),
17914                false,
17915            )
17916            .unwrap()
17917            .as_deref(),
17918            Some("codex_exec:approval_policy=never")
17919        );
17920        assert!(matches!(
17921            codex_spec.executor,
17922            ExecutorSpec::Codex {
17923                sandbox: Some(ref sandbox),
17924                inherit_user_config: Some(false),
17925                approval_policy: Some(ref approval_policy),
17926                network_access: Some(false),
17927                allow_danger_bypass: Some(false),
17928                permission_profile: Some(ref profile),
17929                ..
17930            } if sandbox == "read-only"
17931                && approval_policy == "never"
17932                && profile == "read-only"
17933        ));
17934
17935        let mut app_server = codex_executor(Some("inherit"), Some(true));
17936        if let ExecutorSpec::Codex {
17937            mode,
17938            sandbox,
17939            approval_policy,
17940            network_access,
17941            allow_danger_bypass,
17942            permission_profile,
17943            ..
17944        } = &mut app_server
17945        {
17946            *mode = Some("app_server".to_string());
17947            *sandbox = Some("danger-full-access".to_string());
17948            *approval_policy = Some("on-request".to_string());
17949            *network_access = Some(true);
17950            *allow_danger_bypass = Some(true);
17951            *permission_profile = Some("full-access".to_string());
17952        }
17953        let app_server_runner = ActorChildRunner::new(
17954            "test-actor".into(),
17955            PathBuf::from("/bin/false"),
17956            vec![],
17957            std::env::temp_dir().join("bamboo-test-plan-codex-app-server"),
17958            app_server,
17959            vec![],
17960            "openai".into(),
17961            4,
17962        );
17963        let app_server_spec =
17964            app_server_runner.build_spec(&planner, &job_for("planner-codex-app-server"));
17965        assert_eq!(
17966            expected_permission_executor_mapping(
17967                &app_server_spec.executor,
17968                app_server_spec
17969                    .capabilities
17970                    .permission_resolution()
17971                    .unwrap(),
17972                false,
17973            )
17974            .unwrap()
17975            .as_deref(),
17976            Some("codex_app_server:approvalPolicy=never")
17977        );
17978        assert!(matches!(
17979            app_server_spec.executor,
17980            ExecutorSpec::Codex {
17981                mode: Some(ref mode),
17982                sandbox: Some(ref sandbox),
17983                inherit_user_config: Some(false),
17984                approval_policy: Some(ref approval_policy),
17985                network_access: Some(false),
17986                allow_danger_bypass: Some(false),
17987                permission_profile: Some(ref profile),
17988                ..
17989            } if mode == "app_server"
17990                && sandbox == "read-only"
17991                && approval_policy == "on-request"
17992                && profile == "read-only"
17993        ));
17994    }
17995
17996    #[test]
17997    fn typed_read_only_rejects_both_codex_executor_modes() {
17998        let mut planner = session_of_role("planner", "inspect and plan");
17999        planner
18000            .agent_runtime_state
18001            .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18002            .read_only = true;
18003
18004        for mode in ["exec", "app_server"] {
18005            let mut executor = codex_executor(Some("inherit"), Some(true));
18006            if let ExecutorSpec::Codex {
18007                mode: configured_mode,
18008                ..
18009            } = &mut executor
18010            {
18011                *configured_mode = Some(mode.to_string());
18012            }
18013            let runner = ActorChildRunner::new(
18014                "test-actor".into(),
18015                PathBuf::from("/bin/false"),
18016                vec![],
18017                std::env::temp_dir().join(format!("bamboo-test-plan-codex-{mode}")),
18018                executor,
18019                vec![],
18020                "openai".into(),
18021                4,
18022            );
18023            let spec = runner.build_spec(&planner, &job_for(&format!("planner-codex-{mode}")));
18024            let error = ensure_read_only_activation_is_enforceable(&spec)
18025                .expect_err("Codex command execution must fail closed for read-only activations");
18026            let message = error.to_string();
18027            assert!(message.contains("Codex executor"), "{message}");
18028            assert!(message.contains("command execution"), "{message}");
18029        }
18030    }
18031
18032    #[tokio::test]
18033    async fn typed_read_only_codex_fails_before_worker_spawn_or_dispatch() {
18034        let mut planner = session_of_role("planner", "inspect and plan");
18035        planner
18036            .agent_runtime_state
18037            .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18038            .read_only = true;
18039
18040        let runner = ActorChildRunner::new(
18041            "test-actor".into(),
18042            PathBuf::from("/bin/false"),
18043            vec![],
18044            std::env::temp_dir().join("bamboo-test-plan-codex-no-spawn"),
18045            codex_executor(Some("inherit"), Some(true)),
18046            vec![],
18047            "openai".into(),
18048            4,
18049        );
18050        let (event_tx, _event_rx) = mpsc::channel::<AgentEvent>(4);
18051        let error = runner
18052            .execute_external_child(
18053                &mut planner,
18054                &job_for("planner-codex-no-spawn"),
18055                event_tx,
18056                CancellationToken::new(),
18057            )
18058            .await
18059            .expect_err("typed read-only Codex must fail before invoking /bin/false");
18060        let message = error.to_string();
18061        assert!(message.contains("Codex executor"), "{message}");
18062        assert!(message.contains("command execution"), "{message}");
18063    }
18064
18065    #[test]
18066    fn ticket_placement_keeps_local_and_ordinary_remote_execution_available() {
18067        let runner = bogus_runner(HashMap::new());
18068        let ordinary = session_of_role("worker", "ordinary work");
18069        for placement in [
18070            Placement::Local,
18071            Placement::Remote {
18072                endpoint: "wss://fixture.invalid/actor".into(),
18073            },
18074            Placement::Schedulable {
18075                pool: "fixture-pool".into(),
18076            },
18077        ] {
18078            let mut spec = runner.build_spec(&ordinary, &job_for("placement"));
18079            spec.placement = placement;
18080            ensure_ticket_placement_is_enforceable(&ordinary, &spec).unwrap();
18081            for marker in [
18082                crate::ticket_runtime::TICKET_DISPATCH_KEY,
18083                crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY,
18084            ] {
18085                let mut ticket = ordinary.clone();
18086                ticket.metadata.insert(marker.into(), "fixture".into());
18087                assert_eq!(
18088                    ensure_ticket_placement_is_enforceable(&ticket, &spec).is_ok(),
18089                    matches!(spec.placement, Placement::Local)
18090                );
18091            }
18092        }
18093    }
18094
18095    #[tokio::test]
18096    async fn ticket_placement_rejected_before_remote_route_claim_or_worker_spawn() {
18097        for marker in [
18098            crate::ticket_runtime::TICKET_DISPATCH_KEY,
18099            crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY,
18100        ] {
18101            for schedulable in [false, true] {
18102                let mut session = session_of_role("worker", "Ticket work");
18103                session.metadata.insert(marker.into(), "fixture".into());
18104                let before = serde_json::to_value(&session).unwrap();
18105                let remote = if schedulable {
18106                    HashMap::new()
18107                } else {
18108                    HashMap::from([(
18109                        "worker".into(),
18110                        ResolvedRemotePlacement {
18111                            endpoint: "wss://fixture.invalid/actor".into(),
18112                            ..Default::default()
18113                        },
18114                    )])
18115                };
18116                let sched = if schedulable {
18117                    HashMap::from([(
18118                        "worker".into(),
18119                        sched_placement("fixture-pool", "wss://fixture.invalid"),
18120                    )])
18121                } else {
18122                    HashMap::new()
18123                };
18124                let runner = bogus_sched_runner(remote, sched);
18125                let (events, mut observations) = mpsc::channel::<AgentEvent>(4);
18126                let error = runner
18127                    .execute_external_child(
18128                        &mut session,
18129                        &job_for("ticket-no-remote"),
18130                        events,
18131                        CancellationToken::new(),
18132                    )
18133                    .await
18134                    .unwrap_err();
18135                assert!(
18136                    error
18137                        .to_string()
18138                        .contains("Ticket execution requires local placement"),
18139                    "{error}"
18140                );
18141                assert_eq!(serde_json::to_value(&session).unwrap(), before);
18142                assert!(observations.try_recv().is_err());
18143            }
18144        }
18145    }
18146
18147    #[test]
18148    fn typed_read_only_rejects_resident_actor_placements() {
18149        let runner = bogus_runner(HashMap::new());
18150        let mut planner = session_of_role("planner", "inspect and plan");
18151        planner
18152            .agent_runtime_state
18153            .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18154            .read_only = true;
18155
18156        for placement in [
18157            Placement::Remote {
18158                endpoint: "wss://resident.example/actor".to_string(),
18159            },
18160            Placement::Schedulable {
18161                pool: "resident-pool".to_string(),
18162            },
18163        ] {
18164            let mut spec = runner.build_spec(&planner, &job_for("read-only-resident"));
18165            spec.placement = placement;
18166
18167            let error = ensure_read_only_activation_is_enforceable(&spec)
18168                .expect_err("resident placement must fail closed for read-only activation");
18169            let message = error.to_string();
18170            assert!(message.contains("requires local placement"), "{message}");
18171            assert!(message.contains("read-only tool boundary"), "{message}");
18172        }
18173
18174        let local = runner.build_spec(&planner, &job_for("read-only-local"));
18175        ensure_read_only_activation_is_enforceable(&local)
18176            .expect("a locally provisioned read-only worker is enforceable");
18177    }
18178
18179    #[tokio::test]
18180    async fn build_spec_preserves_exact_inherited_permission_mode_for_child_worker() {
18181        // Exercise the real creation path instead of pre-seeding the child by
18182        // hand: both legacy Bypass and zero-prompt Auto must survive child
18183        // creation and the actor provisioning boundary without collapsing.
18184        for (label, mode) in [
18185            ("bypass", bamboo_domain::SessionPermissionMode::Bypass),
18186            ("auto", bamboo_domain::SessionPermissionMode::Auto),
18187        ] {
18188            let runner = bogus_runner(HashMap::new());
18189            let mut parent = Session::new(format!("parent-{label}"), "test-model");
18190            parent
18191                .agent_runtime_state
18192                .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18193                .set_permission_mode(mode);
18194            let workspace = tempfile::tempdir().expect("workspace fixture");
18195            let port = RecordingChildSessionPort {
18196                parent: parent.clone(),
18197                saved: Default::default(),
18198            };
18199            let child_id = format!("child-{label}-{}", uuid::Uuid::new_v4());
18200            crate::session_app::child_session::create_child_action(
18201                &port,
18202                crate::session_app::child_session::CreateChildInput {
18203                    parent_session: parent,
18204                    child_id: child_id.clone(),
18205                    title: format!("{label} child"),
18206                    responsibility: "Run ordinary commands".to_string(),
18207                    assignment_prompt: "run an ordinary command".to_string(),
18208                    subagent_type: "explorer".to_string(),
18209                    workspace: workspace.path().to_string_lossy().into_owned(),
18210                    workspace_source: crate::project_context::WorkspaceSource::Explicit,
18211                    model_override: None,
18212                    model_ref_override: None,
18213                    runtime_metadata: HashMap::new(),
18214                    read_only: false,
18215                    auto_run: false,
18216                    reasoning_effort: None,
18217                    lifecycle: None,
18218                    resident_name: None,
18219                    resident_context: None,
18220                    disabled_tools: None,
18221                    context_fork: None,
18222                },
18223            )
18224            .await
18225            .expect("create inherited-permission child");
18226            let child = port.saved_child();
18227
18228            assert_eq!(
18229                child
18230                    .agent_runtime_state
18231                    .as_ref()
18232                    .map(bamboo_domain::AgentRuntimeState::effective_permission_mode),
18233                Some(mode),
18234                "create_child_action must inherit {label} from the parent"
18235            );
18236
18237            let spec = runner.build_spec(&child, &job_for(&child_id));
18238
18239            assert_eq!(
18240                spec.capabilities.bypass,
18241                mode == bamboo_domain::SessionPermissionMode::Bypass
18242            );
18243            assert_eq!(
18244                spec.capabilities.auto_approve_permissions,
18245                mode == bamboo_domain::SessionPermissionMode::Auto
18246            );
18247            assert!(
18248                spec.capabilities.enforce_permissions,
18249                "policy evaluation must remain active under {label}"
18250            );
18251        }
18252    }
18253
18254    #[tokio::test]
18255    async fn child_resident_and_guardian_inherit_project_through_actor_run_spec() {
18256        let project_id = bamboo_domain::ProjectId::parse("project-inherited").expect("Project id");
18257        let workspace = tempfile::tempdir().expect("workspace fixture");
18258
18259        for (role, lifecycle, resident_name) in [
18260            ("explorer", None, None),
18261            ("resident", Some("resident"), Some("stable-reviewer")),
18262            ("guardian", None, None),
18263        ] {
18264            let mut parent = Session::new(format!("parent-{role}"), "test-model");
18265            parent.set_project_id_meta(project_id.to_string());
18266            let port = RecordingChildSessionPort {
18267                parent: parent.clone(),
18268                saved: Default::default(),
18269            };
18270            let child_id = format!("child-{role}-{}", uuid::Uuid::new_v4());
18271            crate::session_app::child_session::create_child_action(
18272                &port,
18273                crate::session_app::child_session::CreateChildInput {
18274                    parent_session: parent,
18275                    child_id: child_id.clone(),
18276                    title: format!("{role} child"),
18277                    responsibility: "Review the assigned work".to_string(),
18278                    assignment_prompt: "inspect the change".to_string(),
18279                    subagent_type: role.to_string(),
18280                    workspace: workspace.path().to_string_lossy().into_owned(),
18281                    workspace_source: crate::project_context::WorkspaceSource::Explicit,
18282                    model_override: None,
18283                    model_ref_override: None,
18284                    runtime_metadata: HashMap::new(),
18285                    read_only: false,
18286                    auto_run: false,
18287                    reasoning_effort: None,
18288                    lifecycle: lifecycle.map(str::to_string),
18289                    resident_name: resident_name.map(str::to_string),
18290                    resident_context: None,
18291                    disabled_tools: None,
18292                    context_fork: None,
18293                },
18294            )
18295            .await
18296            .expect("create Project-inheriting child");
18297            let child = port.saved_child();
18298            assert_eq!(
18299                crate::project_context::ProjectContextResolver::project_id_from_session(&child),
18300                Some(project_id.clone()),
18301                "{role} child must inherit its parent's Project"
18302            );
18303
18304            assert_eq!(
18305                project_id_for_actor_run(&child).expect("valid actor Project identity"),
18306                Some(project_id.clone()),
18307                "{role} actor RunSpec must preserve inherited Project identity"
18308            );
18309        }
18310    }
18311
18312    #[test]
18313    fn placement_metadata_stamps_remote_and_schedulable_not_local() {
18314        // Local children carry no stamp — the DTO defaults them to the backend host.
18315        assert_eq!(placement_metadata(&Placement::Local, None), None);
18316
18317        // Remote, no node label → host derived from the endpoint.
18318        let r = placement_metadata(
18319            &Placement::Remote {
18320                endpoint: "wss://10.0.0.5:8443/stream".into(),
18321            },
18322            None,
18323        )
18324        .unwrap();
18325        assert!(r.contains(r#""kind":"remote""#), "{r}");
18326        assert!(r.contains(r#""host":"10.0.0.5""#), "{r}");
18327
18328        // A cluster node's label (its metadata) OVERRIDES the raw endpoint host.
18329        let labeled = placement_metadata(
18330            &Placement::Remote {
18331                endpoint: "ws://169.254.230.101:8899".into(),
18332            },
18333            Some("mini"),
18334        )
18335        .unwrap();
18336        assert!(labeled.contains(r#""host":"mini""#), "{labeled}");
18337
18338        // Schedulable → {kind:"remote", host:<node label, else pool>}.
18339        let s = placement_metadata(
18340            &Placement::Schedulable {
18341                pool: "explorers".into(),
18342            },
18343            Some("mini"),
18344        )
18345        .unwrap();
18346        assert!(s.contains(r#""kind":"remote""#), "{s}");
18347        assert!(s.contains(r#""host":"mini""#), "{s}");
18348
18349        // The stamp round-trips through the storage placement type.
18350        let p: bamboo_storage::SessionPlacement = serde_json::from_str(&labeled).unwrap();
18351        assert_eq!(p.kind, "remote");
18352        assert_eq!(p.host, "mini");
18353    }
18354
18355    /// A legacy direct worker endpoint cannot bypass the broker activation
18356    /// contract, even when the endpoint is reachable and has a bearer token.
18357    #[tokio::test]
18358    async fn execute_external_child_rejects_direct_remote_worker() {
18359        let mut placements = HashMap::new();
18360        placements.insert(
18361            "explorer".to_string(),
18362            ResolvedRemotePlacement {
18363                endpoint: "ws://127.0.0.1:1".into(),
18364                token: Some("remote-test-token".into()),
18365                ca_cert_file: None,
18366                host_label: Some("mini-e2e".into()),
18367                broker_peer: None,
18368                requirements: None,
18369            },
18370        );
18371        let runner = bogus_runner(placements);
18372        let mut session = session_of_role("explorer", "hello remote");
18373        let job = job_for("child-1");
18374        let (event_tx, _event_rx) = mpsc::channel::<AgentEvent>(64);
18375        let error = runner
18376            .execute_external_child(&mut session, &job, event_tx, CancellationToken::new())
18377            .await
18378            .err()
18379            .expect("direct remote transport must fail closed");
18380        assert!(error
18381            .to_string()
18382            .contains("remote_broker_activation_unavailable"));
18383        assert!(session
18384            .messages
18385            .iter()
18386            .all(|message| message.role != Role::Assistant));
18387    }
18388
18389    // ---- #181 (P2b): schedulable placement routing --------------------------
18390
18391    /// A bogus-worker_bin runner carrying SCHEDULABLE placements (and optionally
18392    /// remote ones, to test precedence). A local spawn here would fail on
18393    /// `/bin/false`, so a passing schedulable test proves no subprocess spawned.
18394    fn bogus_sched_runner(
18395        remote: HashMap<String, ResolvedRemotePlacement>,
18396        sched: HashMap<String, ResolvedSchedulablePlacement>,
18397    ) -> ActorChildRunner {
18398        ActorChildRunner::new(
18399            "test-actor".into(),
18400            PathBuf::from("/bin/false"),
18401            vec![],
18402            std::env::temp_dir().join("bamboo-test-fab-181"),
18403            ExecutorSpec::Echo,
18404            vec![],
18405            "anthropic".into(),
18406            4,
18407        )
18408        .with_remote_placements(remote)
18409        .with_schedulable_placements(sched)
18410    }
18411
18412    fn sched_placement(
18413        pool: &str,
18414        _registry_url: impl Into<String>,
18415    ) -> ResolvedSchedulablePlacement {
18416        ResolvedSchedulablePlacement {
18417            pool: pool.into(),
18418            host_label: None,
18419            requirements: None,
18420            ..Default::default()
18421        }
18422    }
18423
18424    #[test]
18425    fn build_spec_sets_schedulable_placement_for_matching_role() {
18426        let mut sched = HashMap::new();
18427        sched.insert(
18428            "explorer".to_string(),
18429            sched_placement("gpu-pool", "unused"),
18430        );
18431        let runner = bogus_sched_runner(HashMap::new(), sched);
18432
18433        let s = session_of_role("explorer", "do the thing");
18434        let spec = runner.build_spec(&s, &job_for("child-1"));
18435        match &spec.placement {
18436            Placement::Schedulable { pool } => assert_eq!(pool, "gpu-pool"),
18437            other => panic!("expected Schedulable, got {other:?}"),
18438        }
18439        // No per-placement bearer now — the bus connection carries the bus token.
18440        assert!(spec.secrets.worker_auth_token.is_none());
18441    }
18442
18443    #[test]
18444    fn build_spec_remote_wins_when_role_in_both_maps() {
18445        // A role present in BOTH remote_placements and schedulable_placements must
18446        // resolve to the FIXED remote placement (documented precedence).
18447        let mut remote = HashMap::new();
18448        remote.insert(
18449            "explorer".to_string(),
18450            ResolvedRemotePlacement {
18451                endpoint: "wss://fixed-host:8443".into(),
18452                token: Some("T-remote".into()),
18453                ca_cert_file: None,
18454                host_label: None,
18455                broker_peer: None,
18456                requirements: None,
18457            },
18458        );
18459        let mut sched = HashMap::new();
18460        sched.insert(
18461            "explorer".to_string(),
18462            sched_placement("gpu-pool", "https://control-plane:9562"),
18463        );
18464        let runner = bogus_sched_runner(remote, sched);
18465
18466        let s = session_of_role("explorer", "do the thing");
18467        let spec = runner.build_spec(&s, &job_for("child-1"));
18468        match &spec.placement {
18469            Placement::Remote { endpoint } => assert_eq!(endpoint, "wss://fixed-host:8443"),
18470            other => panic!("expected Remote (precedence), got {other:?}"),
18471        }
18472        assert!(spec.secrets.worker_auth_token.is_none());
18473    }
18474
18475    #[test]
18476    fn build_spec_local_for_unmatched_schedulable_role() {
18477        let mut sched = HashMap::new();
18478        sched.insert(
18479            "explorer".to_string(),
18480            sched_placement("gpu-pool", "https://control-plane:9562"),
18481        );
18482        let runner = bogus_sched_runner(HashMap::new(), sched);
18483        let s = session_of_role("writer", "do the thing");
18484        let spec = runner.build_spec(&s, &job_for("child-1"));
18485        assert_eq!(spec.placement, Placement::Local);
18486        assert!(spec.secrets.worker_auth_token.is_none());
18487    }
18488
18489    /// The full role → resolved-placement → badge-host chain: a child routed to a
18490    /// remote/schedulable placement carrying a cluster node's `host_label` stamps
18491    /// that label; without a label it falls back to the endpoint host / pool; a
18492    /// Local child gets no stamp (the DTO defaults it to the backend host).
18493    #[test]
18494    fn placement_stamp_uses_node_label_for_remote_and_schedulable() {
18495        // Remote WITH a node label → {remote, <label>}, overriding the raw IP.
18496        let mut remote = HashMap::new();
18497        remote.insert(
18498            "explorer".to_string(),
18499            ResolvedRemotePlacement {
18500                endpoint: "ws://169.254.230.101:8899".into(),
18501                token: None,
18502                ca_cert_file: None,
18503                host_label: Some("mini".into()),
18504                broker_peer: None,
18505                requirements: None,
18506            },
18507        );
18508        let runner = bogus_runner(remote);
18509        let spec = runner.build_spec(&session_of_role("explorer", "go"), &job_for("c1"));
18510        let stamp = runner
18511            .placement_stamp_for(&spec)
18512            .expect("remote child is stamped");
18513        assert!(stamp.contains(r#""kind":"remote""#), "{stamp}");
18514        assert!(stamp.contains(r#""host":"mini""#), "{stamp}");
18515
18516        // Remote WITHOUT a node label → falls back to the endpoint host.
18517        let mut remote_nolabel = HashMap::new();
18518        remote_nolabel.insert(
18519            "explorer".to_string(),
18520            ResolvedRemotePlacement {
18521                endpoint: "ws://169.254.230.101:8899".into(),
18522                token: None,
18523                ca_cert_file: None,
18524                host_label: None,
18525                broker_peer: None,
18526                requirements: None,
18527            },
18528        );
18529        let r2 = bogus_runner(remote_nolabel);
18530        let spec2 = r2.build_spec(&session_of_role("explorer", "go"), &job_for("c1"));
18531        assert!(r2
18532            .placement_stamp_for(&spec2)
18533            .unwrap()
18534            .contains(r#""host":"169.254.230.101""#));
18535
18536        // Schedulable WITH a node label → {remote, <label>} (a node, not a pool name).
18537        let mut sched = HashMap::new();
18538        sched.insert(
18539            "mac-mini-monitor".to_string(),
18540            ResolvedSchedulablePlacement {
18541                pool: "mac-mini-monitor".into(),
18542                host_label: Some("mini".into()),
18543                requirements: None,
18544                ..Default::default()
18545            },
18546        );
18547        let sr = bogus_sched_runner(HashMap::new(), sched);
18548        let spec3 = sr.build_spec(&session_of_role("mac-mini-monitor", "go"), &job_for("c1"));
18549        let stamp3 = sr
18550            .placement_stamp_for(&spec3)
18551            .expect("scheduled child is stamped");
18552        assert!(stamp3.contains(r#""kind":"remote""#), "{stamp3}");
18553        assert!(stamp3.contains(r#""host":"mini""#), "{stamp3}");
18554
18555        // A Local (unmatched) child gets NO stamp.
18556        let local = bogus_runner(HashMap::new());
18557        let spec4 = local.build_spec(&session_of_role("writer", "go"), &job_for("c1"));
18558        assert_eq!(local.placement_stamp_for(&spec4), None);
18559    }
18560
18561    #[tokio::test]
18562    async fn legacy_schedulable_route_fails_closed_without_local_fallback() {
18563        let mut sched = HashMap::new();
18564        sched.insert(
18565            "explorer".to_string(),
18566            sched_placement("gpu-pool", "unused"),
18567        );
18568        let runner = bogus_sched_runner(HashMap::new(), sched);
18569        let mut session = session_of_role("explorer", "do the thing");
18570        let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
18571        let error = runner
18572            .execute_external_child(
18573                &mut session,
18574                &job_for("child-1"),
18575                event_tx,
18576                CancellationToken::new(),
18577            )
18578            .await
18579            .expect_err("legacy bus presence cannot grant placement");
18580        assert!(error
18581            .to_string()
18582            .contains("remote_broker_activation_unavailable"));
18583    }
18584}