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/// Max nesting depth for direct nested execution (Phase 6). A worker whose
58/// session `spawn_depth` is below this gets its own spawn stack + the real
59/// SubAgent tool; at/over it, neither (and the tool itself refuses). Mirrors
60/// `bamboo_server_tools::DEFAULT_MAX_SPAWN_DEPTH` (kept in sync; engine can't
61/// depend on server-tools). Root orchestrator = 0 ⇒ 4 levels of sub-agents.
62pub const MAX_SPAWN_DEPTH: u32 = 4;
63
64/// Default cap on idle pooled (warm, reusable) workers kept per fingerprint.
65const DEFAULT_MAX_IDLE_PER_KEY: usize = 4;
66
67/// Process-wide-per-runner cap across every reuse fingerprint. Without this,
68/// workloads that continually change role/model/workspace can leave one parked
69/// worker in an unbounded number of otherwise-small buckets.
70const DEFAULT_MAX_IDLE_TOTAL: usize = 16;
71
72/// How long a pooled worker waits for its next assignment before reclaiming
73/// itself (must comfortably exceed the gap between sibling spawns).
74const POOLED_IDLE_TIMEOUT_SECS: u64 = 300;
75
76/// Sweep often enough to retire an expired/dead pool entry even when no later
77/// child checks out the same fingerprint. The worker's own idle deadline is the
78/// primary process-side guard; this task closes the parent handle and pool entry.
79const POOLED_REAPER_INTERVAL: Duration = Duration::from_secs(30);
80
81/// Deadline for a local worker's FIRST frame after a Run is dispatched. A warm
82/// worker answers in seconds; a cold spawn within tens. Total silence past this
83/// means the worker is dead (e.g. a pooled worker that exited right after its
84/// liveness check) and its Run is queued with nobody to serve it — trip it so the
85/// runner respawns once instead of hanging forever. Generous, to never false-trip
86/// a slow-but-healthy cold start.
87const WORKER_FIRST_FRAME_TIMEOUT: Duration = Duration::from_secs(60);
88
89fn active_scoped_session_deny_count(
90    config: &bamboo_tools::permission::PermissionConfig,
91    session_id: &str,
92) -> usize {
93    config
94        .temporary_grants()
95        .into_iter()
96        .filter(|grant| {
97            grant.scope == bamboo_tools::permission::TemporaryPermissionGrantScope::Session
98                && grant.effect == bamboo_tools::permission::TemporaryPermissionGrantEffect::Deny
99                && grant.session_id.as_deref() == Some(session_id)
100        })
101        .count()
102}
103
104fn ensure_no_active_scoped_session_denies(
105    config: &bamboo_tools::permission::PermissionConfig,
106    session_id: &str,
107) -> Result<(), AgentError> {
108    let count = active_scoped_session_deny_count(config, session_id);
109    if count == 0 {
110        Ok(())
111    } else {
112        Err(AgentError::LLM(format!(
113            "external executor activation blocked by {count} active session-scoped explicit deny rule(s)"
114        )))
115    }
116}
117
118/// Plaintext token returned once by the server authority for one Codex run.
119/// `token_id` is non-secret and is the handle used for guaranteed revocation.
120pub struct IssuedCodexRunToken {
121    pub token_id: String,
122    pub token: String,
123}
124
125/// Server-owned authority for Bamboo-as-provider Codex credentials. The engine
126/// only needs mint/revoke; verification remains inside the HTTP server.
127pub trait CodexRunTokenAuthority: Send + Sync + 'static {
128    fn issue(&self, session_id: &str) -> Result<IssuedCodexRunToken, String>;
129    fn revoke(&self, token_id: &str);
130}
131
132struct CodexRunTokenGuard {
133    authority: Arc<dyn CodexRunTokenAuthority>,
134    token_id: String,
135}
136
137impl Drop for CodexRunTokenGuard {
138    fn drop(&mut self) {
139        self.authority.revoke(&self.token_id);
140    }
141}
142
143fn executor_uses_bamboo_codex(executor: &ExecutorSpec) -> bool {
144    matches!(
145        executor,
146        ExecutorSpec::Codex {
147            auth_mode: Some(mode),
148            ..
149        } if mode == "bamboo"
150    ) || matches!(
151        executor,
152        ExecutorSpec::Codex {
153            auth_mode: None,
154            inherit_user_config,
155            ..
156        } if !inherit_user_config.unwrap_or(false)
157    )
158}
159
160fn executor_has_read_only_permission_profile(executor: &ExecutorSpec) -> bool {
161    match executor {
162        ExecutorSpec::ClaudeCode {
163            permission_mode, ..
164        } => permission_mode
165            .as_deref()
166            .is_some_and(|mode| mode.eq_ignore_ascii_case("plan")),
167        ExecutorSpec::Codex {
168            permission_profile,
169            sandbox,
170            ..
171        } => {
172            permission_profile
173                .as_deref()
174                .is_some_and(|profile| profile.eq_ignore_ascii_case("read-only"))
175                || sandbox
176                    .as_deref()
177                    .is_some_and(|value| value.eq_ignore_ascii_case("read-only"))
178        }
179        _ => false,
180    }
181}
182
183/// Exact non-secret executor posture the host expects for one typed activation.
184///
185/// Echo is intentionally a transport-only smoke executor and CliAdapter is not
186/// implemented by the production worker. Neither claims the typed permission
187/// contract, so legacy/custom frame handling remains available only for those
188/// two variants. Every executable permission-aware variant must prove the
189/// mapping derived from its provisioned spec before any execution event.
190fn expected_permission_executor_mapping(
191    executor: &ExecutorSpec,
192    resolution: bamboo_domain::PermissionModeResolution,
193    has_explicit_deny: bool,
194) -> Result<Option<String>, AgentError> {
195    let mapping = match executor {
196        ExecutorSpec::Echo | ExecutorSpec::CliAdapter { .. } => return Ok(None),
197        ExecutorSpec::BambooRuntime => {
198            format!("bamboo_runtime:{}", resolution.effective.as_str())
199        }
200        ExecutorSpec::ClaudeCode {
201            permission_mode, ..
202        } => {
203            if has_explicit_deny {
204                "claude_code:blocked_explicit_deny".to_string()
205            } else {
206                let mode = match resolution.effective {
207                    bamboo_domain::PermissionMode::Plan => "plan",
208                    bamboo_domain::PermissionMode::Auto => "bypassPermissions",
209                    bamboo_domain::PermissionMode::AcceptEdits => "acceptEdits",
210                    bamboo_domain::PermissionMode::DontAsk => "dontAsk",
211                    bamboo_domain::PermissionMode::Default
212                    | bamboo_domain::PermissionMode::BypassPermissions => {
213                        permission_mode.as_deref().unwrap_or("default")
214                    }
215                };
216                format!("claude_code:permission_mode={mode}")
217            }
218        }
219        ExecutorSpec::Codex {
220            mode,
221            sandbox,
222            approval_policy,
223            allow_danger_bypass,
224            ..
225        } => match mode.as_deref().unwrap_or("exec") {
226            "exec" => {
227                let approval_policy = expected_codex_exec_approval_policy(
228                    sandbox.as_deref(),
229                    approval_policy.as_deref(),
230                    allow_danger_bypass.unwrap_or(false),
231                    resolution,
232                )?;
233                if has_explicit_deny {
234                    "codex_exec:blocked_explicit_deny".to_string()
235                } else {
236                    format!("codex_exec:approval_policy={approval_policy}")
237                }
238            }
239            "app_server" => {
240                if !matches!(approval_policy.as_deref(), None | Some("on-request")) {
241                    return Err(AgentError::LLM(
242                        "invalid Codex app-server permission posture configuration".to_string(),
243                    ));
244                }
245                if has_explicit_deny {
246                    "codex_app_server:blocked_explicit_deny".to_string()
247                } else {
248                    let approval_policy = if resolution.suppress_approval_prompts()
249                        || resolution.effective == bamboo_domain::PermissionMode::Plan
250                    {
251                        "never"
252                    } else {
253                        "on-request"
254                    };
255                    format!("codex_app_server:approvalPolicy={approval_policy}")
256                }
257            }
258            _ => {
259                return Err(AgentError::LLM(
260                    "unsupported Codex executor mode for permission posture contract".to_string(),
261                ));
262            }
263        },
264    };
265    Ok(Some(mapping))
266}
267
268fn expected_codex_exec_approval_policy(
269    sandbox: Option<&str>,
270    approval_policy: Option<&str>,
271    allow_danger_bypass: bool,
272    resolution: bamboo_domain::PermissionModeResolution,
273) -> Result<&'static str, AgentError> {
274    let configured = match approval_policy {
275        None | Some("never") => "never",
276        Some("on-failure") => "on-failure",
277        Some(_) => {
278            return Err(AgentError::LLM(
279                "invalid Codex exec permission posture configuration".to_string(),
280            ));
281        }
282    };
283    if resolution.suppress_approval_prompts()
284        || resolution.effective == bamboo_domain::PermissionMode::Plan
285    {
286        return Ok("never");
287    }
288    match sandbox {
289        Some("danger-full-access") => Ok("never"),
290        Some("read-only") | Some("workspace-write") => Ok(configured),
291        None if allow_danger_bypass || resolution.bypass_permissions() => Ok("never"),
292        None => Ok(configured),
293        Some(_) => Err(AgentError::LLM(
294            "invalid Codex exec permission posture configuration".to_string(),
295        )),
296    }
297}
298
299fn workspace_is_bamboo_owned(raw: &str) -> bool {
300    let workspace = std::fs::canonicalize(raw).unwrap_or_else(|_| PathBuf::from(raw));
301    let configured_root = bamboo_config::paths::resolve_workspace_root();
302    let configured_root = std::fs::canonicalize(&configured_root).unwrap_or(configured_root);
303    if workspace.starts_with(&configured_root) {
304        return true;
305    }
306
307    // Project worktrees created by Bamboo live under
308    // `<project>/.bamboo/worktree/<name>` and carry the ownership marker used
309    // by the project-worktree lifecycle. A path that merely imitates the
310    // directory shape is not sufficient to bypass Codex's git guard.
311    workspace.ancestors().any(|candidate| {
312        let Some(name) = candidate.file_name().and_then(|name| name.to_str()) else {
313            return false;
314        };
315        let Some(worktree_root) = candidate.parent() else {
316            return false;
317        };
318        if worktree_root.file_name() != Some(std::ffi::OsStr::new("worktree"))
319            || worktree_root.parent().and_then(Path::file_name)
320                != Some(std::ffi::OsStr::new(".bamboo"))
321        {
322            return false;
323        }
324        let marker = worktree_root.join(".bamboo-owned").join(name);
325        std::fs::read_to_string(marker).is_ok_and(|branch| branch == format!("bamboo/{name}"))
326    })
327}
328
329fn build_codex_run_secrets(
330    executor: &ExecutorSpec,
331    authority: Option<Arc<dyn CodexRunTokenAuthority>>,
332    child_session_id: &str,
333) -> Result<
334    (
335        bamboo_subagent::proto::RunSecrets,
336        Option<CodexRunTokenGuard>,
337    ),
338    AgentError,
339> {
340    if !executor_uses_bamboo_codex(executor) {
341        return Ok((bamboo_subagent::proto::RunSecrets::default(), None));
342    }
343
344    let authority = authority.ok_or_else(|| {
345        AgentError::LLM(
346            "Codex auth mode 'bamboo' requires the server per-run token authority".to_string(),
347        )
348    })?;
349    let issued = authority
350        .issue(child_session_id)
351        .map_err(|error| AgentError::LLM(format!("mint Codex per-run provider token: {error}")))?;
352    let guard = CodexRunTokenGuard {
353        authority,
354        token_id: issued.token_id,
355    };
356    Ok((
357        bamboo_subagent::proto::RunSecrets {
358            codex_provider_token: Some(bamboo_subagent::proto::SecretValue::new(issued.token)),
359        },
360        Some(guard),
361    ))
362}
363
364/// A warm worker on the mailbox bus, parked for reuse between runs. It stays
365/// dialed-in + subscribed to `mailbox_id`; the next interchangeable child
366/// delivers its `Run` there instead of spawning a fresh process. Dropping it
367/// kills a local kill-on-drop subprocess; a remote / schedulable handle is
368/// process-less (`kill()` is a no-op — it self-manages via its idle timeout).
369struct PooledWorker {
370    worker: SpawnedChild,
371    /// The bus mailbox this worker subscribes to (where its `Run`s are delivered).
372    mailbox_id: String,
373    /// Set only while the worker is parked. Checked both at checkout and by the
374    /// background pool sweep; cleared before the worker is handed to a run.
375    parked_at: Option<Instant>,
376}
377
378type WorkerPool = HashMap<String, Vec<PooledWorker>>;
379
380#[derive(Debug, Clone, Copy, PartialEq, Eq)]
381enum PoolReapReason {
382    ProcessExited,
383    IdleTimeout,
384}
385
386impl PoolReapReason {
387    fn as_str(self) -> &'static str {
388        match self {
389            Self::ProcessExited => "process_exited_while_parked",
390            Self::IdleTimeout => "pool_idle_timeout",
391        }
392    }
393}
394
395fn parked_worker_expired(parked_at: Option<Instant>, now: Instant, timeout: Duration) -> bool {
396    parked_at.is_some_and(|parked_at| now.saturating_duration_since(parked_at) >= timeout)
397}
398
399fn idle_pool_limit_reason(
400    pool: &WorkerPool,
401    key: &str,
402    max_per_key: usize,
403    max_total: usize,
404) -> Option<&'static str> {
405    if pool.get(key).map_or(0, Vec::len) >= max_per_key {
406        Some("pool_per_key_limit")
407    } else if pool.values().map(Vec::len).sum::<usize>() >= max_total {
408        Some("pool_global_limit")
409    } else {
410        None
411    }
412}
413
414fn take_reapable_workers(
415    pool: &mut WorkerPool,
416    now: Instant,
417    timeout: Duration,
418) -> Vec<(String, PoolReapReason, PooledWorker)> {
419    let mut reaped = Vec::new();
420    for (key, bucket) in pool.iter_mut() {
421        let mut retained = Vec::with_capacity(bucket.len());
422        for mut worker in std::mem::take(bucket) {
423            let reason = if !worker.worker.is_alive() {
424                Some(PoolReapReason::ProcessExited)
425            } else if parked_worker_expired(worker.parked_at, now, timeout) {
426                Some(PoolReapReason::IdleTimeout)
427            } else {
428                None
429            };
430            if let Some(reason) = reason {
431                reaped.push((key.clone(), reason, worker));
432            } else {
433                retained.push(worker);
434            }
435        }
436        *bucket = retained;
437    }
438    pool.retain(|_, bucket| !bucket.is_empty());
439    reaped
440}
441
442async fn reap_worker_pool_once(pool: &Arc<tokio::sync::Mutex<WorkerPool>>) -> usize {
443    let now = Instant::now();
444    let timeout = Duration::from_secs(POOLED_IDLE_TIMEOUT_SECS);
445    let reaped = {
446        let mut pool = pool.lock().await;
447        take_reapable_workers(&mut pool, now, timeout)
448    };
449    let count = reaped.len();
450    for (pool_key, reason, worker) in reaped {
451        let idle_ms = worker
452            .parked_at
453            .map(|parked_at| now.saturating_duration_since(parked_at).as_millis() as u64);
454        tracing::info!(
455            pool_key,
456            mailbox_id = %worker.mailbox_id,
457            worker_pid = ?worker.worker.pid(),
458            worker_idle_ms = ?idle_ms,
459            shutdown_reason = reason.as_str(),
460            "reaping parked sub-agent worker"
461        );
462        worker.worker.kill().await;
463    }
464    count
465}
466
467/// A role pinned to a remote resident worker (remote-actor-plan §3.4 / P1.5,
468/// #193), resolved at runner-build time from `SubagentsConfig.remote_placements`:
469/// the env-named broker bearer is already READ into `token` here (the raw token
470/// never rides the config), and `ca_cert_file` pins the broker's TLS cert.
471#[derive(Clone, Default)]
472pub struct ResolvedRemotePlacement {
473    pub endpoint: String,
474    pub token: Option<String>,
475    pub ca_cert_file: Option<PathBuf>,
476    /// Display name for the machine this role runs on — the matching cluster
477    /// node's `label`/host, surfaced on the UI placement badge. `None` ⇒ derive
478    /// from the endpoint host.
479    pub host_label: Option<String>,
480    /// Some(Err) preserves explicit unavailable selection; never falls back.
481    pub broker_peer: Option<Result<bamboo_config::RemoteBrokerPeer, ()>>,
482    pub requirements: Option<bamboo_config::OperatorPlacementRequirements>,
483}
484impl std::fmt::Debug for ResolvedRemotePlacement {
485    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
486        f.debug_struct("ResolvedRemotePlacement")
487            .field("broker_peer", &self.broker_peer.is_some())
488            .finish_non_exhaustive()
489    }
490}
491
492/// A role routed to a SCHEDULED worker (remote-actor-plan §3.4 / P2b, #181),
493/// resolved at runner-build time from `SubagentsConfig.schedulable_placements`.
494/// Names the logical `pool` (= the bus role) whose LIVE connected workers are the
495/// scheduling candidates — the runner picks one via the bus presence query
496/// (`BrokerClient::list_connected`). Phase 3 retired the old HTTP registry, so a
497/// pool is now just a role on the bus.
498#[derive(Debug, Clone, Default)]
499pub struct ResolvedSchedulablePlacement {
500    pub pool: String,
501    /// Display name for the machine this pool's workers run on — the matching
502    /// cluster node's `label`/host, surfaced on the UI placement badge. `None` ⇒
503    /// fall back to the pool name.
504    pub host_label: Option<String>,
505    pub endpoint: String,
506    pub token: Option<String>,
507    pub ca_cert_file: Option<PathBuf>,
508    pub broker_parent: Option<bamboo_config::SchedulableBrokerParent>,
509    pub requirements: Option<bamboo_config::OperatorPlacementRequirements>,
510}
511
512#[derive(Clone)]
513struct ScopedPlacementRoute {
514    endpoint: String,
515    token: String,
516    ca_cert_file: PathBuf,
517    parent: bamboo_subagent::AgentRef,
518    worker_role: String,
519    pinned_mailbox: Option<String>,
520    requirements: bamboo_config::OperatorPlacementRequirements,
521}
522
523impl ScopedPlacementRoute {
524    async fn query(&self) -> Result<bamboo_broker::BrokerClient, AgentError> {
525        let tls = bamboo_broker::client_config_trusting_cert(&self.ca_cert_file)
526            .map_err(|_| remote_broker_unavailable())?;
527        bamboo_broker::BrokerClient::connect_with_tls(
528            &self.endpoint,
529            self.parent.clone(),
530            &self.token,
531            Some(tls),
532        )
533        .await
534        .map_err(|_| remote_broker_unavailable())
535    }
536}
537
538/// Holds one atomic HostRegistry slot for the entire remote Run. A fresh
539/// authenticated broker observation extends the Host lease before the slot is
540/// renewed. Losing either identity cancels the Run; Drop releases the exact
541/// generation/epoch and cannot free a replacement worker's slot.
542struct ActivePlacementLease {
543    registry: bamboo_storage::v2::FileHostRegistry,
544    actor_store: Arc<bamboo_storage::SessionStoreV2>,
545    route: ScopedPlacementRoute,
546    mailbox: String,
547    host_ref: String,
548    fence: ActorActivationFence,
549    placement_ref: bamboo_domain::ActorPlacementRef,
550    lease: Arc<tokio::sync::Mutex<WorkerSlotLease>>,
551    heartbeat: tokio::task::JoinHandle<()>,
552    run_cancel: CancellationToken,
553    lost: Arc<AtomicBool>,
554    release_on_drop: AtomicBool,
555}
556
557/// No Run has reached a worker yet, so a failed placement preflight may end
558/// only the activation it claimed. A replacement activation has a different
559/// fence and must remain untouched.
560async fn fail_pre_dispatch_activation(
561    actor_store: &bamboo_storage::SessionStoreV2,
562    fence: &ActorActivationFence,
563) -> Result<(), bamboo_domain::ActorDirectoryError> {
564    actor_store
565        .finish_activation(fence, chrono::Utc::now(), ActorActivationFinish::Failed)
566        .await
567        .map(|_| ())
568}
569
570/// Once the Run send is invoked, an error may mean that the broker persisted
571/// the FencedRun but its delivery receipt was lost. Only errors before this
572/// call can safely roll back the Actor activation and its reserved slot.
573async fn send_actor_run(
574    client: &mut dyn bamboo_subagent::ChildLink,
575    spec: RunSpec,
576    send_invoked: &mut bool,
577) -> Result<(), AgentError> {
578    *send_invoked = true;
579    client
580        .send(ParentFrame::Run(spec))
581        .await
582        .map_err(|error| AgentError::LLM(format!("actor run dispatch failed: {error}")))
583}
584
585fn remote_activation_finish(
586    result: &Result<Option<String>, AgentError>,
587    accepted_terminal: Option<TerminalStatus>,
588) -> ActorActivationFinish {
589    if result.is_ok() {
590        ActorActivationFinish::Succeeded
591    } else if matches!(result, Err(AgentError::Cancelled))
592        && accepted_terminal == Some(TerminalStatus::Cancelled)
593    {
594        ActorActivationFinish::Cancelled
595    } else {
596        ActorActivationFinish::Failed
597    }
598}
599
600/// Commit a remote reply through the current Actor fence before marking the
601/// activation terminal. The SDK's later status save may then carry exactly
602/// the canonical main transcript instead of trying an unfenced append.
603async fn append_fenced_remote_reply(
604    actor_store: &bamboo_storage::SessionStoreV2,
605    fence: &ActorActivationFence,
606    session: &mut Session,
607    text: &str,
608) -> Result<(), AgentError> {
609    let message = bamboo_agent_core::Message::assistant(text, None);
610    let committed = actor_store
611        .append_actor_transcript(bamboo_storage::ActorTranscriptAppend {
612            fence: fence.clone(),
613            expected_created_at: session.created_at,
614            expected_messages: session.messages.clone(),
615            expected_provider_transcript: session.provider_transcript.clone(),
616            messages: vec![message],
617            native_groups: Vec::new(),
618        })
619        .await
620        .map_err(|error| AgentError::LLM(format!("remote actor reply commit failed: {error}")))?;
621    session.messages = committed.messages;
622    session.provider_transcript = committed.provider_transcript;
623    session.updated_at = committed.updated_at;
624    Ok(())
625}
626
627impl ActivePlacementLease {
628    async fn reserve(
629        registry: bamboo_storage::v2::FileHostRegistry,
630        actor_store: Arc<bamboo_storage::SessionStoreV2>,
631        route: ScopedPlacementRoute,
632        mailbox: String,
633        actor_id: String,
634        run_id: String,
635        project_id: Option<bamboo_domain::ProjectId>,
636        required_tools: BTreeSet<String>,
637        parent_cancel: &CancellationToken,
638    ) -> Result<Self, AgentError> {
639        let scheduler = PlacementScheduler::new(registry.clone());
640        let mut query = route.query().await?;
641        let observed = query
642            .observe_host_capacity(&mailbox, &route.worker_role)
643            .await
644            .map_err(|_| remote_broker_unavailable())?
645            .ok_or_else(remote_broker_unavailable)?;
646        let host = scheduler
647            .observe_scoped_host(
648                &mut query,
649                &observed.host_ref,
650                &mailbox,
651                &route.worker_role,
652                chrono::Duration::seconds(110),
653            )
654            .await
655            .map_err(|_| remote_broker_unavailable())?;
656        if host.capabilities.placement_class != bamboo_domain::ActorPlacementClass::Remote
657            || host.connection_generation != observed.connection_generation
658            || route
659                .pinned_mailbox
660                .as_deref()
661                .is_some_and(|pinned| pinned != mailbox)
662        {
663            return Err(remote_broker_unavailable());
664        }
665        let now = chrono::Utc::now();
666        let request = HostPlacementRequest {
667            intent: HostPlacementIntent::PinnedHost {
668                host_ref: host.host_ref.clone(),
669            },
670            actor_id,
671            run_id,
672            project_id: project_id.clone(),
673            trust_zone: route.requirements.trust_zone.clone(),
674            workspace_label: route.requirements.workspace_label.clone(),
675            executor: "bamboo-runtime".into(),
676            required_tools,
677            network_zone: route.requirements.network_zone.clone(),
678            require_network_isolation: route.requirements.require_network_isolation,
679            preferred_host_ref: None,
680            now,
681            lease_expires_at: now + chrono::Duration::seconds(80),
682        };
683        if !request.eligible(&host) {
684            return Err(remote_broker_unavailable());
685        }
686        let lease = scheduler
687            .reserve(request)
688            .await
689            .map_err(|_| remote_broker_unavailable())?;
690        let placement_ref = bamboo_domain::ActorPlacementRef {
691            class: bamboo_domain::ActorPlacementClass::Remote,
692            lease_id: lease.lease_id.clone(),
693            slot_epoch: Some(lease.epoch),
694        };
695        let mut claimed_fence = None;
696        let actor_binding = async {
697            let actor = actor_store
698                .ensure_actor(&lease.actor_id)
699                .await
700                .map_err(|_| remote_broker_unavailable())?;
701            if actor.actor.project_id != project_id.as_ref().map(|id| id.to_string()) {
702                return Err(remote_broker_unavailable());
703            }
704            let placement_intent = if route.pinned_mailbox.is_some() {
705                bamboo_domain::ActorPlacementIntent::PinnedHost {
706                    host_ref: host.host_ref.clone(),
707                }
708            } else {
709                bamboo_domain::ActorPlacementIntent::Pool {
710                    pool_id: route.worker_role.clone(),
711                }
712            };
713            actor_store
714                .bind_control_plane(
715                    &lease.actor_id,
716                    bamboo_domain::ActorControlPlaneBinding {
717                        policy_revision: None,
718                        placement_intent: Some(placement_intent),
719                    },
720                )
721                .await
722                .map_err(|_| {
723                    tracing::warn!(stage = "actor_bind", "remote placement admission failed");
724                    remote_broker_unavailable()
725                })?;
726            let activation = actor_store
727                .claim_activation(&ActorActivationClaim {
728                    actor_id: lease.actor_id.clone(),
729                    run_id: lease.run_id.clone(),
730                    lease_owner: format!("worker-host-{}", uuid::Uuid::new_v4()),
731                    lease_expires_at: lease.expires_at,
732                    inbox_generation: 0,
733                    placement_ref: Some(placement_ref.clone()),
734                    now: chrono::Utc::now(),
735                })
736                .await
737                .map_err(|_| {
738                    tracing::warn!(stage = "actor_claim", "remote placement admission failed");
739                    remote_broker_unavailable()
740                })?;
741            let fence = activation.fence();
742            claimed_fence = Some(fence.clone());
743            actor_store
744                .start_activation(&fence, chrono::Utc::now())
745                .await
746                .map_err(|_| {
747                    tracing::warn!(stage = "actor_start", "remote placement admission failed");
748                    remote_broker_unavailable()
749                })?;
750            let reservation = registry
751                .validate_run_reservation(bamboo_domain::WorkerRunReservationCheck {
752                    placement_ref: placement_ref.clone(),
753                    actor_id: lease.actor_id.clone(),
754                    run_id: lease.run_id.clone(),
755                    observed_host_ref: host.host_ref.clone(),
756                    observed_connection_generation: host.connection_generation.clone(),
757                    now: chrono::Utc::now(),
758                })
759                .await
760                .map_err(|error| {
761                    tracing::warn!(stage = "slot_preflight", reason = %error, "remote placement admission failed");
762                    remote_broker_unavailable()
763                })?;
764            if reservation.mailbox != mailbox || reservation.lease != lease {
765                tracing::warn!(stage = "slot_binding", "remote placement admission failed");
766                return Err(remote_broker_unavailable());
767            }
768            Ok::<_, AgentError>(fence)
769        }
770        .await;
771        let fence = match actor_binding {
772            Ok(fence) => fence,
773            Err(error) => {
774                if let Some(fence) = claimed_fence.as_ref() {
775                    if let Err(rollback_error) =
776                        fail_pre_dispatch_activation(actor_store.as_ref(), fence).await
777                    {
778                        tracing::warn!(
779                            stage = "actor_pre_dispatch_rollback",
780                            reason = %rollback_error,
781                            "remote placement activation rollback failed"
782                        );
783                    }
784                }
785                let _ = registry.release_slot(&lease).await;
786                return Err(error);
787            }
788        };
789        let lease = Arc::new(tokio::sync::Mutex::new(lease));
790        let host_ref = host.host_ref.clone();
791        let lost = Arc::new(AtomicBool::new(false));
792        let run_cancel = parent_cancel.child_token();
793        let heartbeat = {
794            let registry = registry.clone();
795            let actor_store = actor_store.clone();
796            let route = route.clone();
797            let host_ref = host_ref.clone();
798            let mailbox = mailbox.clone();
799            let fence = fence.clone();
800            let placement_ref = placement_ref.clone();
801            let lease = lease.clone();
802            let lost = lost.clone();
803            let run_cancel = run_cancel.clone();
804            tokio::spawn(async move {
805                let mut interval = tokio::time::interval(Duration::from_secs(30));
806                interval.tick().await;
807                loop {
808                    tokio::select! {
809                        _ = run_cancel.cancelled() => break,
810                        _ = interval.tick() => {}
811                    }
812                    let refreshed = async {
813                        let mut query = route.query().await?;
814                        let host = PlacementScheduler::new(registry.clone())
815                            .observe_scoped_host(
816                                &mut query,
817                                &host_ref,
818                                &mailbox,
819                                &route.worker_role,
820                                chrono::Duration::seconds(110),
821                            )
822                            .await
823                            .map_err(|_| remote_broker_unavailable())?;
824                        let current = lease.lock().await.clone();
825                        if host.connection_generation != current.connection_generation {
826                            return Err(remote_broker_unavailable());
827                        }
828                        let entry = actor_store
829                            .inspect_actor(&current.actor_id)
830                            .await
831                            .map_err(|_| remote_broker_unavailable())?;
832                        if entry.activation.as_ref().is_none_or(|activation| {
833                            activation.fence() != fence
834                                || activation.placement_ref.as_ref() != Some(&placement_ref)
835                        }) {
836                            return Err(remote_broker_unavailable());
837                        }
838                        let now = chrono::Utc::now();
839                        registry
840                            .validate_run_reservation(bamboo_domain::WorkerRunReservationCheck {
841                                placement_ref: placement_ref.clone(),
842                                actor_id: current.actor_id.clone(),
843                                run_id: current.run_id.clone(),
844                                observed_host_ref: host.host_ref.clone(),
845                                observed_connection_generation: host.connection_generation.clone(),
846                                now,
847                            })
848                            .await
849                            .map_err(|_| remote_broker_unavailable())?;
850                        let renewed = registry
851                            .renew_slot(&current, now, now + chrono::Duration::seconds(80))
852                            .await
853                            .map_err(|_| remote_broker_unavailable())?;
854                        *lease.lock().await = renewed;
855                        let expiry = lease.lock().await.expires_at;
856                        actor_store
857                            .renew_activation(&fence, now, expiry)
858                            .await
859                            .map_err(|_| remote_broker_unavailable())?;
860                        Ok::<(), AgentError>(())
861                    }
862                    .await;
863                    if refreshed.is_err() {
864                        lost.store(true, Ordering::SeqCst);
865                        run_cancel.cancel();
866                        break;
867                    }
868                }
869            })
870        };
871        Ok(Self {
872            registry,
873            actor_store,
874            route,
875            mailbox,
876            host_ref,
877            fence,
878            placement_ref,
879            lease,
880            heartbeat,
881            run_cancel,
882            lost,
883            release_on_drop: AtomicBool::new(false),
884        })
885    }
886
887    fn lost(&self) -> bool {
888        self.lost.load(Ordering::SeqCst)
889    }
890
891    async fn abort_if_run_unsent(&mut self, send_invoked: bool) -> Result<bool, AgentError> {
892        if send_invoked {
893            return Ok(false);
894        }
895        self.abort_before_dispatch().await?;
896        Ok(true)
897    }
898
899    async fn abort_before_dispatch(&mut self) -> Result<(), AgentError> {
900        self.heartbeat.abort();
901        let _ = (&mut self.heartbeat).await;
902        let current = self.lease.lock().await.clone();
903        let failed = fail_pre_dispatch_activation(self.actor_store.as_ref(), &self.fence).await;
904        let released = self.registry.release_slot(&current).await;
905        if let Err(error) = failed {
906            tracing::warn!(stage = "actor_pre_dispatch_rollback", reason = %error, "remote activation failure could not be recorded");
907            return Err(remote_broker_unavailable());
908        }
909        if let Err(error) = released {
910            if !matches!(error, bamboo_domain::HostRegistryError::StaleLease) {
911                tracing::warn!(stage = "slot_pre_dispatch_rollback", reason = %error, "remote slot release failed");
912                return Err(remote_broker_unavailable());
913            }
914        }
915        Ok(())
916    }
917
918    async fn complete(
919        &mut self,
920        outcome: ActorActivationFinish,
921        terminal_proven: bool,
922    ) -> Result<(), AgentError> {
923        if !terminal_proven {
924            // Keep the activation live until its lease expires. A silent Worker
925            // may still be executing; this blocks an immediate ActorId retry.
926            return if outcome == ActorActivationFinish::Succeeded {
927                Err(remote_broker_unavailable())
928            } else {
929                Ok(())
930            };
931        }
932        // Wait for the heartbeat to stop before reading the exact lease. It
933        // must not renew the slot after terminal verification or release.
934        self.heartbeat.abort();
935        let _ = (&mut self.heartbeat).await;
936        let current = self.lease.lock().await.clone();
937        // A correlated Outcome from the selected mailbox alone cannot release
938        // a new generation's slot. Reobserve the scoped broker identity at
939        // terminal time, including fast reconnects between heartbeat ticks.
940        let mut query = self.route.query().await?;
941        let observed = query
942            .observe_host_capacity(&self.mailbox, &self.route.worker_role)
943            .await
944            .map_err(|_| remote_broker_unavailable())?
945            .ok_or_else(remote_broker_unavailable)?;
946        if observed.host_ref != self.host_ref
947            || observed.connection_generation != current.connection_generation
948            || observed.mailbox != self.mailbox
949            || observed.role.as_deref() != Some(self.route.worker_role.as_str())
950        {
951            return Err(remote_broker_unavailable());
952        }
953        let entry = self
954            .actor_store
955            .inspect_actor(&current.actor_id)
956            .await
957            .map_err(|_| remote_broker_unavailable())?;
958        if entry.activation.as_ref().is_none_or(|activation| {
959            activation.fence() != self.fence
960                || activation.placement_ref.as_ref() != Some(&self.placement_ref)
961        }) {
962            return Err(remote_broker_unavailable());
963        }
964        self.registry
965            .validate_slot(&current, chrono::Utc::now())
966            .await
967            .map_err(|_| remote_broker_unavailable())?;
968        self.actor_store
969            .finish_activation(&self.fence, chrono::Utc::now(), outcome)
970            .await
971            .map_err(|_| remote_broker_unavailable())?;
972        // A terminal ActorDirectory record permits an immediate retry. Free
973        // its exact slot before returning rather than racing a Drop task.
974        self.release_on_drop.store(true, Ordering::SeqCst);
975        match self.registry.release_slot(&current).await {
976            Ok(()) | Err(bamboo_domain::HostRegistryError::StaleLease) => {
977                self.release_on_drop.store(false, Ordering::SeqCst);
978                Ok(())
979            }
980            Err(error) => {
981                tracing::warn!(stage = "slot_terminal_release", reason = %error, "remote slot release failed");
982                Err(remote_broker_unavailable())
983            }
984        }
985    }
986}
987
988impl Drop for ActivePlacementLease {
989    fn drop(&mut self) {
990        self.run_cancel.cancel();
991        self.heartbeat.abort();
992        if !self.release_on_drop.load(Ordering::SeqCst) {
993            return;
994        }
995        let registry = self.registry.clone();
996        let actor_store = self.actor_store.clone();
997        let fence = self.fence.clone();
998        let placement_ref = self.placement_ref.clone();
999        let lease = self.lease.clone();
1000        if let Ok(runtime) = tokio::runtime::Handle::try_current() {
1001            runtime.spawn(async move {
1002                let current = lease.lock().await.clone();
1003                if actor_store
1004                    .inspect_actor(&current.actor_id)
1005                    .await
1006                    .ok()
1007                    .and_then(|entry| entry.activation)
1008                    .is_some_and(|activation| {
1009                        activation.fence() == fence
1010                            && activation.placement_ref.as_ref() == Some(&placement_ref)
1011                    })
1012                {
1013                    let _ = registry.release_slot(&current).await;
1014                }
1015            });
1016        }
1017    }
1018}
1019
1020/// Host-only receipt repair uses the same immutable boot configuration as the
1021/// actor runner. It never infers a broker route from Child display metadata or
1022/// a Worker claim. A role whose fixed remote route is ambiguous or incomplete
1023/// stays blocked until an operator restores a provable route.
1024pub struct BrokerTerminalReceiptReconciler {
1025    store: Arc<bamboo_storage::SessionStoreV2>,
1026    bus: Option<bamboo_subagent::BusEndpoint>,
1027    remote: HashMap<String, Option<ReceiptRemoteRoute>>,
1028    ticket_service: Option<Arc<bamboo_tickets::TicketService>>,
1029}
1030
1031struct ReceiptRemoteRoute {
1032    endpoint: String,
1033    token: String,
1034    ca_cert_file: PathBuf,
1035    parent: bamboo_subagent::AgentRef,
1036}
1037
1038#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
1039pub struct BrokerReceiptRepairReport {
1040    pub candidates: usize,
1041    pub repaired: usize,
1042    pub blocked: usize,
1043}
1044
1045impl BrokerTerminalReceiptReconciler {
1046    pub fn new(store: Arc<bamboo_storage::SessionStoreV2>, config: &bamboo_config::Config) -> Self {
1047        let subagents = config.subagents();
1048        let mut counts = HashMap::<&str, usize>::new();
1049        for placement in &subagents.remote_placements {
1050            *counts.entry(placement.role.as_str()).or_default() += 1;
1051        }
1052        let mut remote = HashMap::new();
1053        for placement in &subagents.remote_placements {
1054            let route = (counts.get(placement.role.as_str()) == Some(&1))
1055                .then(|| receipt_remote_route(placement))
1056                .flatten();
1057            remote.insert(placement.role.clone(), route);
1058        }
1059        Self {
1060            store,
1061            ticket_service: None,
1062            bus: subagents.broker.clone().and_then(|broker| {
1063                (!broker.endpoint.trim().is_empty() && !broker.token.is_empty()).then_some(
1064                    bamboo_subagent::BusEndpoint {
1065                        endpoint: broker.endpoint,
1066                        token: broker.token,
1067                    },
1068                )
1069            }),
1070            remote,
1071        }
1072    }
1073
1074    pub fn with_ticket_service(
1075        mut self,
1076        service: Option<Arc<bamboo_tickets::TicketService>>,
1077    ) -> Self {
1078        self.ticket_service = service;
1079        self
1080    }
1081
1082    /// One physical-tree scan. Every Child is independent; a failed route,
1083    /// broker, or ACK retains its Host receipt and never blocks another Child.
1084    pub async fn reconcile_once(&self) -> BrokerReceiptRepairReport {
1085        let candidates = match self
1086            .store
1087            .discover_unconfirmed_broker_terminal_children()
1088            .await
1089        {
1090            Ok(candidates) => candidates,
1091            Err(error) => {
1092                tracing::warn!(%error, "broker terminal receipt scan blocked; no ACK attempted");
1093                return BrokerReceiptRepairReport {
1094                    blocked: 1,
1095                    ..BrokerReceiptRepairReport::default()
1096                };
1097            }
1098        };
1099        let mut report = BrokerReceiptRepairReport {
1100            candidates: candidates.len(),
1101            ..BrokerReceiptRepairReport::default()
1102        };
1103        for child in candidates {
1104            let result =
1105                tokio::time::timeout(Duration::from_secs(15), self.reconcile_child(&child)).await;
1106            match result {
1107                Ok(Ok(())) => report.repaired += 1,
1108                Ok(Err(reason)) => {
1109                    report.blocked += 1;
1110                    tracing::warn!(child_id = %child.id, reason, "broker terminal receipt retained for retry");
1111                }
1112                Err(_) => {
1113                    report.blocked += 1;
1114                    tracing::warn!(child_id = %child.id, "broker terminal receipt repair timed out; retained for retry");
1115                }
1116            }
1117        }
1118        report
1119    }
1120
1121    async fn reconcile_child(&self, child: &Session) -> Result<(), &'static str> {
1122        if child.metadata.get("runtime.kind").map(String::as_str) != Some("external")
1123            || child.metadata.get("external.protocol").map(String::as_str) != Some("actor")
1124            || child.metadata.get("external.agent_id").map(String::as_str)
1125                != Some(super::config::LOCAL_ACTOR_AGENT_ID)
1126        {
1127            return Err("Child has no trusted built-in actor recovery route");
1128        }
1129        let current = self
1130            .store
1131            .load_session(&child.id)
1132            .await
1133            .map_err(|_| "canonical Child reload failed")?
1134            .ok_or("canonical Child disappeared")?;
1135        if current.kind != bamboo_domain::SessionKind::Child
1136            || current.id != child.id
1137            || current.created_at != child.created_at
1138            || current.parent_session_id != child.parent_session_id
1139            || current.root_session_id != child.root_session_id
1140            || current.project_id_meta() != child.project_id_meta()
1141            || current.metadata.get("runtime.kind").map(String::as_str) != Some("external")
1142            || current
1143                .metadata
1144                .get("external.protocol")
1145                .map(String::as_str)
1146                != Some("actor")
1147            || current
1148                .metadata
1149                .get("external.agent_id")
1150                .map(String::as_str)
1151                != Some(super::config::LOCAL_ACTOR_AGENT_ID)
1152            || bamboo_domain::ActorSession::from_session(&current).is_err()
1153        {
1154            return Err("canonical Child birth, Root, or Project changed");
1155        }
1156        let root = self
1157            .store
1158            .load_session(&child.root_session_id)
1159            .await
1160            .map_err(|_| "canonical Root reload failed")?
1161            .ok_or("canonical Root disappeared")?;
1162        if root.kind != bamboo_domain::SessionKind::Root
1163            || root.id != child.root_session_id
1164            || root.root_session_id != child.root_session_id
1165            || root.project_id_meta() != child.project_id_meta()
1166        {
1167            return Err("canonical Root identity changed");
1168        }
1169        let parent_id = child
1170            .parent_session_id
1171            .as_deref()
1172            .ok_or("Child parent identity missing")?;
1173        let parent = self
1174            .store
1175            .load_session(parent_id)
1176            .await
1177            .map_err(|_| "canonical parent reload failed")?
1178            .ok_or("canonical parent disappeared")?;
1179        if parent.id != parent_id
1180            || !matches!(
1181                parent.kind,
1182                bamboo_domain::SessionKind::Root | bamboo_domain::SessionKind::Child
1183            )
1184            || parent.root_session_id != child.root_session_id
1185            || parent.project_id_meta() != child.project_id_meta()
1186            || parent.spawn_depth.checked_add(1) != Some(current.spawn_depth)
1187        {
1188            return Err("canonical parent lineage changed");
1189        }
1190
1191        // Validate the Host checkpoint before opening a recovery connection.
1192        // This rejects an uncommitted terminal, stale transcript, or changed
1193        // Child identity before touching the broker.
1194        let receipts = self
1195            .store
1196            .recover_broker_terminal_receipts(&current)
1197            .await
1198            .map_err(|_| "Host broker terminal checkpoint unverified")?;
1199        if receipts.is_empty() {
1200            return Ok(());
1201        }
1202        let role = current
1203            .metadata
1204            .get("subagent_type")
1205            .map(String::as_str)
1206            .unwrap_or("worker");
1207        for receipt in receipts {
1208            publish_recovered_completion_source(&self.store, &receipt)
1209                .await
1210                .map_err(|_| "committed Child completion source remains pending")?;
1211            if current
1212                .metadata
1213                .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
1214            {
1215                let service = self
1216                    .ticket_service
1217                    .as_ref()
1218                    .ok_or("Ticket authority unavailable; no result ACK")?;
1219                crate::ticket_runtime::checkpoint_ticket_result(
1220                    service,
1221                    self.store.as_ref(),
1222                    &current,
1223                    &receipt.activation_run_id,
1224                )
1225                .await
1226                .map_err(|_| "Ticket result not checkpointed; broker receipt retained")?;
1227            }
1228            let local_mailbox = format!("p-{}", child.id);
1229            // The committed receipt records the route used by this run. A
1230            // later role configuration change must not redirect its ACK.
1231            let mut link = if receipt.parent_mailbox == local_mailbox {
1232                let bus = self
1233                    .bus
1234                    .as_ref()
1235                    .ok_or("local broker receipt bus unavailable")?;
1236                bamboo_broker::BrokerChildLink::connect_receipt_recovery(
1237                    &bus.endpoint,
1238                    bamboo_subagent::AgentRef {
1239                        session_id: local_mailbox,
1240                        role: None,
1241                    },
1242                    &bus.token,
1243                )
1244                .await
1245                .map_err(|_| "local broker receipt connection unavailable")?
1246            } else if let Some(remote) = self.remote.get(role) {
1247                let route = remote
1248                    .as_ref()
1249                    .ok_or("fixed remote broker route is unavailable or ambiguous")?;
1250                if receipt.parent_mailbox != route.parent.session_id {
1251                    return Err("fixed remote parent mailbox changed since Host checkpoint");
1252                }
1253                let tls = bamboo_broker::client_config_trusting_cert(&route.ca_cert_file)
1254                    .map_err(|_| "fixed remote broker TLS pin unavailable")?;
1255                bamboo_broker::BrokerChildLink::connect_receipt_recovery_with_tls(
1256                    &route.endpoint,
1257                    route.parent.clone(),
1258                    &route.token,
1259                    tls,
1260                )
1261                .await
1262                .map_err(|_| "fixed remote broker receipt connection unavailable")?
1263            } else {
1264                return Err("committed broker receipt route is unavailable");
1265            };
1266            if link.parent_mailbox() != receipt.parent_mailbox {
1267                return Err("broker receipt link mailbox differs from Host checkpoint");
1268            }
1269            bamboo_subagent::ChildLink::acknowledge_recovered_durable_frames(
1270                &mut link,
1271                &receipt.broker_identity,
1272                &receipt.message_ids,
1273            )
1274            .await
1275            .map_err(|_| "broker identity changed or exact ACK unconfirmed")?;
1276            self.store
1277                .clear_acknowledged_broker_terminal_receipt(
1278                    &receipt.session_id,
1279                    receipt.created_at,
1280                    &receipt.activation_run_id,
1281                    &receipt.parent_mailbox,
1282                )
1283                .await
1284                .map_err(|_| "broker ACK succeeded but Host receipt cleanup failed")?;
1285        }
1286        Ok(())
1287    }
1288}
1289
1290/// Finish only the interrupted publication of an already committed Host
1291/// receipt. The pending source stays in the existing protected source record;
1292/// no provider, event admission or transcript reconstruction is involved.
1293async fn publish_recovered_completion_source(
1294    store: &Arc<bamboo_storage::SessionStoreV2>,
1295    receipt: &bamboo_storage::v2::BrokerTerminalReceipt,
1296) -> Result<(), String> {
1297    let expected = store
1298        .load_session(&receipt.session_id)
1299        .await
1300        .map_err(|error| format!("Child completion recovery reload failed: {error}"))?
1301        .ok_or("Child completion recovery snapshot missing")?;
1302    if expected.id != receipt.session_id
1303        || expected.created_at != receipt.created_at
1304        || expected.parent_session_id.as_deref() != Some(receipt.parent_session_id.as_str())
1305        || expected.root_session_id != receipt.root_session_id
1306        || expected.project_id_meta() != receipt.project_id
1307    {
1308        return Err("Child completion recovery identity changed".into());
1309    }
1310    let mut candidate = expected.clone();
1311    if candidate.last_run_status().as_deref() == Some("running") {
1312        candidate.set_last_run_status(receipt.terminal_status.clone());
1313        if let Some(error) = &receipt.terminal_error {
1314            candidate.set_last_run_error(error.clone());
1315        } else {
1316            candidate.clear_last_run_error();
1317        }
1318    }
1319    if crate::execution::ChildCompletionSource::publish_after_durable_delivery(
1320        &mut candidate,
1321        &receipt.activation_run_id,
1322    )? {
1323        let source = crate::execution::ChildCompletionSource::from_committed_session(&candidate)
1324            .ok_or("Child completion recovery source invalid")?;
1325        let published = candidate
1326            .metadata
1327            .get("runtime.child_completion_source_v1")
1328            .ok_or("Child completion recovery source missing")?;
1329        // CAS reloads under the physical Session lock and mutates only the
1330        // source/status fields. Another reconciler or successor cannot be
1331        // overwritten by this earlier snapshot.
1332        let saved = store
1333            .publish_broker_terminal_source(receipt, &expected, published)
1334            .await
1335            .map_err(|error| format!("Child completion recovery publication failed: {error}"))?;
1336        if crate::execution::ChildCompletionSource::from_committed_session(&saved).as_ref()
1337            != Some(&source)
1338        {
1339            return Err("Child completion recovery source changed".into());
1340        }
1341    }
1342    Ok(())
1343}
1344
1345fn receipt_remote_route(
1346    placement: &bamboo_config::RemoteActorPlacement,
1347) -> Option<ReceiptRemoteRoute> {
1348    let peer = placement.broker_peer.as_ref().filter(|peer| peer.valid())?;
1349    let endpoint = url::Url::parse(&placement.endpoint).ok()?;
1350    if placement.endpoint.len() > 2048
1351        || endpoint.scheme() != "wss"
1352        || endpoint.host_str().is_none()
1353        || !endpoint.username().is_empty()
1354        || endpoint.password().is_some()
1355        || endpoint.query().is_some()
1356        || endpoint.fragment().is_some()
1357    {
1358        return None;
1359    }
1360    let token_env = placement
1361        .token_env
1362        .as_deref()
1363        .filter(|name| !name.is_empty() && name.len() <= 256)?;
1364    let token = std::env::var(token_env).ok()?;
1365    if !(32..=256).contains(&token.len()) || !token.bytes().all(|byte| byte.is_ascii_graphic()) {
1366        return None;
1367    }
1368    let ca_cert_file = PathBuf::from(placement.ca_cert_file.as_deref()?);
1369    bamboo_broker::client_config_trusting_cert(&ca_cert_file).ok()?;
1370    Some(ReceiptRemoteRoute {
1371        endpoint: placement.endpoint.clone(),
1372        token,
1373        ca_cert_file,
1374        parent: bamboo_subagent::AgentRef {
1375            session_id: peer.parent_mailbox.clone(),
1376            role: peer.parent_role.clone(),
1377        },
1378    })
1379}
1380
1381/// How `execute_external_child` should obtain its worker connection, decided
1382/// once from `spec.placement`. Splits the divergent acquire/connect + retire
1383/// logic three ways while the shared middle (Run dispatch, live registration,
1384/// drive, close) stays identical. Remote uses a scoped broker peer route.
1385enum PlacementKind {
1386    Local,
1387    Remote,
1388    Schedulable,
1389}
1390
1391/// Ticket execution needs the local process-owned stop checkpoint before ACK.
1392fn ensure_ticket_placement_is_enforceable(
1393    session: &Session,
1394    spec: &ProvisionSpec,
1395) -> Result<(), AgentError> {
1396    let ticket = session
1397        .metadata
1398        .contains_key(crate::ticket_runtime::TICKET_DISPATCH_KEY)
1399        || session
1400            .metadata
1401            .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY);
1402    if ticket && !matches!(spec.placement, Placement::Local) {
1403        return Err(AgentError::LLM(
1404            "Ticket execution requires local placement and owned stop proof".into(),
1405        ));
1406    }
1407    Ok(())
1408}
1409
1410/// A read-only activation needs an executor and worker whose tool surface and
1411/// no-shell checker enforce the typed capability. Codex's read-only sandbox
1412/// prevents writes but still permits command execution, so neither Codex mode
1413/// can currently satisfy the no-shell contract. Local Bamboo/Claude workers are
1414/// fingerprinted by their baked fields, while remote/schedulable placements
1415/// connect to an already-provisioned resident and [`RunSpec`] currently carries
1416/// no equivalent per-activation authority. Fail closed before provisioning or
1417/// dispatch instead of treating either posture as read-only enforcement.
1418fn ensure_read_only_activation_is_enforceable(spec: &ProvisionSpec) -> Result<(), AgentError> {
1419    if !spec.capabilities.read_only_enforced() {
1420        return Ok(());
1421    }
1422
1423    if matches!(spec.executor, ExecutorSpec::Codex { .. }) {
1424        return Err(AgentError::LLM(format!(
1425            "read-only actor role '{}' cannot use the Codex executor because its read-only sandbox still permits command execution; use bamboo_runtime or claude_code",
1426            spec.identity.role
1427        )));
1428    }
1429
1430    if !matches!(spec.placement, Placement::Local) {
1431        return Err(AgentError::LLM(format!(
1432            "read-only actor role '{}' requires local placement; remote and schedulable resident workers cannot verify this activation's read-only tool boundary",
1433            spec.identity.role
1434        )));
1435    }
1436
1437    Ok(())
1438}
1439
1440/// Spawns and drives a child session as an independent actor: a `bamboo-subagent` worker process.
1441pub struct ActorChildRunner {
1442    approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
1443    permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
1444    agent_id: String,
1445    worker_bin: PathBuf,
1446    worker_args: Vec<String>,
1447    builtin_required_context_route: bool,
1448    native_tool_ceiling: Option<Arc<dyn super::runtime::NativeToolCeilingSource>>,
1449    fabric_dir: PathBuf,
1450    executor: ExecutorSpec,
1451    /// Per-provider credentials snapshotted from the parent config at build
1452    /// time; the spec carries only the ONE the child's provider needs. Server
1453    /// runtimes install `live_provider_config` below so a provider hot reload
1454    /// cannot strand this fallback snapshot for the rest of the process.
1455    credentials: Vec<ScopedCredential>,
1456    /// Parent's default provider (used when the child has no explicit one).
1457    default_provider: String,
1458    /// Live server configuration used to resolve provider credentials at child
1459    /// activation time. This is deliberately limited to provider provisioning:
1460    /// executor/placement policy remains the immutable runner configuration.
1461    live_provider_config: Option<Arc<tokio::sync::RwLock<bamboo_llm::Config>>>,
1462    /// The mailbox bus to run local children over (the unified transport). Local
1463    /// sub-agents require it; `None` only when no broker could be embedded.
1464    bus: Option<bamboo_subagent::BusEndpoint>,
1465    /// Backpressure: bounds the number of concurrently *running* actors; further
1466    /// runs wait for a slot instead of exploding the process table. (Idle pooled
1467    /// workers do not hold a slot.)
1468    concurrency: std::sync::Arc<tokio::sync::Semaphore>,
1469    /// Warm-worker pool keyed by a reuse fingerprint
1470    /// (role/provider/model/workspace/disabled-tools/baked-caps). A finished run
1471    /// parks its bus worker here so the next interchangeable child reuses it
1472    /// (delivers its `Run` to the same mailbox) instead of spawning a fresh
1473    /// process — collapsing N sibling sub-agents onto a few warm workers.
1474    pool: Arc<tokio::sync::Mutex<WorkerPool>>,
1475    max_idle_per_key: usize,
1476    max_idle_total: usize,
1477    pool_reaper_started: AtomicBool,
1478    /// Monotonic fencing token for concrete worker attempts in this runtime.
1479    /// `activation_run_id` fences across runtime restarts; this epoch also
1480    /// fences a late first worker when an activation is retried in-place.
1481    next_execution_epoch: AtomicU64,
1482    /// Host-side decision for a child's gated-tool approval request (Phase 2).
1483    /// `None` ⇒ fail-closed DENY (the safe default). A wired decider (policy or
1484    /// human-routing bridge) returns approve/deny over the actor WS.
1485    approval_decider: Option<Arc<dyn ChildApprovalDecider>>,
1486    /// Off-loop parent-agent reviewer for forced-ask requests. The root server
1487    /// wires a session-aware reviewer; nested workers wire their owning model
1488    /// reviewer directly into the per-run runner.
1489    approval_reviewer: Option<Arc<dyn ChildApprovalReviewer>>,
1490    /// Per-run escalation host bridge for non-bypass child-approval routing (#68;
1491    /// Phase 6, Part B). The owning worker's `run()` installs its OWN host bridge
1492    /// here via `set_escalation_bridge`; `execute_external_child` CAPTURES it at
1493    /// grandchild-spawn time and hands the owned value to `drive()`, which uses it
1494    /// to RE-PROXY a child's approval request UP to the parent run — chaining up
1495    /// every level until a bypass level (model-review) or the top orchestrator
1496    /// (human) decides, then relaying the reply back down. Was a process-global
1497    /// slot; now per-runner so a fire-and-forget grandchild that OUTLIVES the run
1498    /// that spawned it keeps that run's bridge for its whole lifetime instead of
1499    /// reading a stale/overwritten global at approval time (→ fail-closed deny).
1500    escalation_bridge: Arc<std::sync::Mutex<Option<bamboo_subagent::executor::HostBridge>>>,
1501    /// Roles pinned to a REMOTE resident worker (#193), keyed by sub-agent role
1502    /// (the child's `subagent_type`). A role present here routes through the
1503    /// dedicated remote branch in `execute_external_child` (Bearer-authenticated
1504    /// `wss://` connect, no spawn, no pool, no kill) instead of the local
1505    /// subprocess + warm-pool path. Empty (the default) = all-local behavior.
1506    remote_placements: HashMap<String, ResolvedRemotePlacement>,
1507    /// Fixed parent mailbox subscription has exactly one in-process owner.
1508    strict_remote_subscription: tokio::sync::Mutex<()>,
1509    /// Roles routed to a REGISTRY-SCHEDULED worker (#181, P2b), keyed by sub-agent
1510    /// role. A role present here (AND not already in `remote_placements`, which
1511    /// wins) routes through the dedicated SCHEDULABLE branch in
1512    /// `execute_external_child`: query the registry for live workers in the pool,
1513    /// pick one (round-robin), connect over `wss://` — no spawn, no pool, no kill,
1514    /// and NO local-subprocess fallback (no live worker ⇒ a clear error). Empty
1515    /// (the default) = all-local behavior.
1516    schedulable_placements: HashMap<String, ResolvedSchedulablePlacement>,
1517    /// Per-pool round-robin cursor for schedulable scheduling (#181, P2b). Bumped
1518    /// once per pick so successive sibling spawns SPREAD across a pool's live
1519    /// workers instead of all landing on the first candidate. Best-effort spread,
1520    /// not a load balancer — the registry's live set can change between picks.
1521    /// Optional server authority used only by `Codex` in `bamboo` auth mode.
1522    codex_run_tokens: Option<Arc<dyn CodexRunTokenAuthority>>,
1523    /// Canonical logical-session inbox resources, late-bound by each owning
1524    /// runtime. Kept per runner/runtime; never process-global.
1525    session_inbox_runtime: Arc<std::sync::Mutex<Option<SessionInboxRuntimeBinding>>>,
1526    actor_directory_store: std::sync::Mutex<Option<Arc<bamboo_storage::SessionStoreV2>>>,
1527    canonical_subagent_tool: std::sync::Mutex<Option<Arc<dyn bamboo_agent_core::tools::Tool>>>,
1528    ticket_service: std::sync::Mutex<Option<Arc<bamboo_tickets::TicketService>>>,
1529    actor_event_observer: std::sync::Mutex<Option<Arc<dyn ActorEventObserver>>>,
1530    /// A processed broker terminal still needs the Host's final Child Session
1531    /// save before its exact Event/Outcome mailbox receipts can be ACKed.
1532    pending_durable_links: tokio::sync::Mutex<
1533        HashMap<(String, chrono::DateTime<chrono::Utc>, String), PendingDurableChildLink>,
1534    >,
1535}
1536
1537struct PendingDurableChildLink {
1538    link: Box<dyn bamboo_subagent::ChildLink>,
1539    /// Captured from this Host frame pump after canonical nested-wait checks.
1540    /// The map key binds it to the exact Child birth and activation Run.
1541    host_nested_wait_handoff: bool,
1542    // Never reconstructed from Worker JSON or ordinary Session metadata.
1543    strict_history: Option<StrictTerminalHistory>,
1544    strict_history_required: bool,
1545    strict_history_committed: bool,
1546}
1547
1548#[derive(Clone)]
1549struct StrictTerminalHistory {
1550    proof: bamboo_storage::v2::HostTerminalCompleteness,
1551    watermark: bamboo_subagent::ActorEventWatermark,
1552}
1553
1554fn effective_broker_terminal_status(
1555    raw: TerminalStatus,
1556    host_nested_wait_handoff: bool,
1557) -> Result<&'static str, String> {
1558    if host_nested_wait_handoff {
1559        if raw != TerminalStatus::Completed {
1560            return Err("nested wait cannot project a failed broker terminal".into());
1561        }
1562        Ok("suspended")
1563    } else {
1564        Ok(broker_terminal_status_label(raw))
1565    }
1566}
1567
1568fn broker_terminal_status_label(status: TerminalStatus) -> &'static str {
1569    match status {
1570        TerminalStatus::Completed => "completed",
1571        TerminalStatus::Suspended => "suspended",
1572        TerminalStatus::Error => "error",
1573        TerminalStatus::Cancelled => "cancelled",
1574    }
1575}
1576
1577/// Decides how the host answers a child worker's gated-tool approval request
1578/// (Phase 2: child → parent approval delegation). Async so an implementation
1579/// can consult a policy. With no decider wired the host replies with a
1580/// fail-closed DENY.
1581///
1582/// NOTE: `decide` is awaited inside the per-child frame pump, so an
1583/// implementation must resolve promptly (e.g. a policy lookup). Model-based
1584/// review belongs in [`ChildApprovalReviewer`], which runs off-loop and returns
1585/// through the live steering channel without stalling the frame pump.
1586#[async_trait]
1587pub trait ChildApprovalDecider: Send + Sync {
1588    /// Decide whether `child_session_id` may perform the gated action described
1589    /// by `request` (`{tool_name, permission, resource}`).
1590    async fn decide(&self, child_session_id: &str, request: &serde_json::Value) -> bool;
1591}
1592
1593/// Resolve a child approval request to approve/deny. Fail-closed (DENY) when no
1594/// decider is wired — the single, testable seam for the host-side decision.
1595async fn decide_child_approval(
1596    decider: Option<&Arc<dyn ChildApprovalDecider>>,
1597    child_session_id: &str,
1598    request: &serde_json::Value,
1599) -> bool {
1600    match decider {
1601        Some(decider) => decider.decide(child_session_id, request).await,
1602        None => false,
1603    }
1604}
1605
1606/// How long a chained parent-agent review may take before the child's gated
1607/// tool fails closed (DENY). Bounds an unanswered request so it cannot hang the
1608/// worker indefinitely.
1609const CHILD_APPROVAL_TIMEOUT: Duration = Duration::from_secs(300);
1610
1611/// Off-loop reviewer for a child's gated-tool approval request (Phase 6, Part B).
1612///
1613/// Installed (process-global) by a BYPASSED self-orchestrating worker so its
1614/// children's forced-ask (dangerous) gated actions — which still raise
1615/// `ConfirmationRequired` even under bypass — get an LLM reasonableness check
1616/// rather than a blind pass. `review` is an LLM call: `drive()` invokes it in a
1617/// SPAWNED task (NEVER in the frame pump) and delivers the verdict async via the
1618/// live channel, so the agent loop is never blocked.
1619/// Process-local Host scope. Durable data can compare its stamp but cannot
1620/// deserialize a new live authority from it.
1621#[derive(Clone)]
1622pub struct ChildApprovalScope {
1623    stamp: serde_json::Value,
1624    deadline: chrono::DateTime<chrono::Utc>,
1625    parent: String,
1626    child: String,
1627    run: String,
1628    epoch: u64,
1629    current_epoch: Arc<AtomicU64>,
1630    cancel: CancellationToken,
1631    router: Arc<crate::SessionActivationRouter>,
1632    admission: Arc<AtomicUsize>,
1633}
1634
1635pub struct ChildApprovalAdmission(Arc<AtomicUsize>);
1636impl Drop for ChildApprovalAdmission {
1637    fn drop(&mut self) {
1638        self.0.store(2, Ordering::Release);
1639    }
1640}
1641
1642impl ChildApprovalScope {
1643    /// Called by the actual Host frame pump, never from a wire payload.
1644    pub fn new(
1645        parent: &str,
1646        child: &str,
1647        transport: (u32, &str, u64, &str, chrono::DateTime<chrono::Utc>),
1648        router: Arc<crate::SessionActivationRouter>,
1649        cancel: CancellationToken,
1650        current_epoch: Arc<AtomicU64>,
1651    ) -> Self {
1652        let (attempt, run, epoch, reply, deadline) = transport;
1653        let deadline = deadline.min(chrono::Utc::now() + chrono::Duration::seconds(240));
1654        Self {
1655            stamp: serde_json::json!({"host_scope": uuid::Uuid::new_v4().to_string(),
1656                "attempt": attempt, "run": run, "epoch": epoch, "reply": reply,
1657                "deadline": deadline}),
1658            deadline,
1659            parent: parent.into(),
1660            child: child.into(),
1661            run: run.into(),
1662            epoch,
1663            current_epoch,
1664            cancel,
1665            router,
1666            admission: Arc::new(AtomicUsize::new(0)),
1667        }
1668    }
1669    pub fn stamp(&self) -> &serde_json::Value {
1670        &self.stamp
1671    }
1672    pub fn deadline(&self) -> chrono::DateTime<chrono::Utc> {
1673        self.deadline
1674    }
1675    pub fn first_admission(&self) -> Option<ChildApprovalAdmission> {
1676        self.admission
1677            .compare_exchange(0, 1, Ordering::AcqRel, Ordering::Acquire)
1678            .ok()
1679            .map(|_| ChildApprovalAdmission(self.admission.clone()))
1680    }
1681    pub fn is_admitting(&self) -> bool {
1682        self.admission.load(Ordering::Acquire) == 1
1683    }
1684    pub async fn is_current(&self, parent: &str, child: &str) -> bool {
1685        self.parent == parent
1686            && self.child == child
1687            && !self.cancel.is_cancelled()
1688            && self.current_epoch.load(Ordering::Acquire) == self.epoch
1689            && self.router.owns_run(child, &self.run).await
1690    }
1691}
1692
1693#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1694pub enum ChildApprovalReview {
1695    Reply(bool),
1696    NoReply,
1697}
1698
1699#[async_trait]
1700pub trait ChildApprovalReviewer: Send + Sync {
1701    /// Judge whether the gated action `request` (`{tool_name, permission,
1702    /// resource}`) is reasonable for `child_session_id`'s task. `true` = approve.
1703    async fn review(
1704        &self,
1705        parent_session_id: &str,
1706        child_session_id: &str,
1707        request: &serde_json::Value,
1708    ) -> bool;
1709
1710    async fn review_scoped(
1711        &self,
1712        parent: &str,
1713        child: &str,
1714        request: &serde_json::Value,
1715        _scope: &ChildApprovalScope,
1716    ) -> ChildApprovalReview {
1717        ChildApprovalReview::Reply(self.review(parent, child, request).await)
1718    }
1719}
1720
1721fn child_approval_reviewer_slot() -> &'static std::sync::OnceLock<Arc<dyn ChildApprovalReviewer>> {
1722    static SLOT: std::sync::OnceLock<Arc<dyn ChildApprovalReviewer>> = std::sync::OnceLock::new();
1723    &SLOT
1724}
1725
1726/// Install the process-global child-approval reviewer (idempotent; first wins).
1727pub fn set_child_approval_reviewer(reviewer: Arc<dyn ChildApprovalReviewer>) {
1728    let _ = child_approval_reviewer_slot().set(reviewer);
1729}
1730
1731/// The process-global child-approval reviewer, if installed.
1732pub fn child_approval_reviewer() -> Option<Arc<dyn ChildApprovalReviewer>> {
1733    child_approval_reviewer_slot().get().cloned()
1734}
1735
1736impl ActorChildRunner {
1737    #[allow(clippy::too_many_arguments)]
1738    pub fn new(
1739        agent_id: String,
1740        worker_bin: PathBuf,
1741        worker_args: Vec<String>,
1742        fabric_dir: PathBuf,
1743        executor: ExecutorSpec,
1744        credentials: Vec<ScopedCredential>,
1745        default_provider: String,
1746        max_concurrent: usize,
1747    ) -> Self {
1748        Self {
1749            approval_registry: None,
1750            permission_config: None,
1751            agent_id,
1752            worker_bin,
1753            worker_args,
1754            builtin_required_context_route: false,
1755            fabric_dir,
1756            executor,
1757            credentials,
1758            default_provider,
1759            live_provider_config: None,
1760            native_tool_ceiling: None,
1761            bus: None,
1762            concurrency: std::sync::Arc::new(tokio::sync::Semaphore::new(max_concurrent.max(1))),
1763            pool: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
1764            max_idle_per_key: DEFAULT_MAX_IDLE_PER_KEY,
1765            max_idle_total: DEFAULT_MAX_IDLE_TOTAL,
1766            pool_reaper_started: AtomicBool::new(false),
1767            next_execution_epoch: AtomicU64::new(0),
1768            approval_decider: None,
1769            approval_reviewer: None,
1770            escalation_bridge: Arc::new(std::sync::Mutex::new(None)),
1771            remote_placements: HashMap::new(),
1772            strict_remote_subscription: tokio::sync::Mutex::new(()),
1773            schedulable_placements: HashMap::new(),
1774            codex_run_tokens: None,
1775            session_inbox_runtime: Arc::new(std::sync::Mutex::new(None)),
1776            actor_directory_store: std::sync::Mutex::new(None),
1777            canonical_subagent_tool: std::sync::Mutex::new(None),
1778            ticket_service: std::sync::Mutex::new(None),
1779            actor_event_observer: std::sync::Mutex::new(None),
1780            pending_durable_links: tokio::sync::Mutex::new(HashMap::new()),
1781        }
1782    }
1783
1784    /// Bind the AppState-owned live configuration. Child activations read this
1785    /// after a successful provider reload, so newly added/rotated provider
1786    /// credentials take effect without restarting Bamboo.
1787    /// Only the built-in factory sets this from its immutable launch config.
1788    pub(crate) fn with_builtin_required_context_route(mut self, supported: bool) -> Self {
1789        self.builtin_required_context_route = supported;
1790        self
1791    }
1792
1793    pub(crate) fn with_native_tool_ceiling_source(
1794        mut self,
1795        source: Option<Arc<dyn super::runtime::NativeToolCeilingSource>>,
1796    ) -> Self {
1797        self.native_tool_ceiling = source;
1798        self
1799    }
1800
1801    async fn require_initial_input_release_worker(&self) -> crate::runtime::runner::Result<()> {
1802        bamboo_subagent::fleet::require_worker_capability(
1803            &self.worker_bin,
1804            &self.worker_args,
1805            bamboo_subagent::provision::INITIAL_INPUT_RELEASE_WORKER_CAPABILITY,
1806        )
1807        .await
1808        .map_err(|_| AgentError::LLM("owned initial release worker capability unconfirmed".into()))
1809    }
1810
1811    pub fn with_live_provider_config(
1812        mut self,
1813        config: Arc<tokio::sync::RwLock<bamboo_llm::Config>>,
1814    ) -> Self {
1815        self.live_provider_config = Some(config);
1816        self
1817    }
1818
1819    pub fn with_approval_registry(
1820        mut self,
1821        registry: super::approval_registry::SharedApprovalRegistry,
1822    ) -> Self {
1823        self.approval_registry = Some(registry);
1824        self
1825    }
1826
1827    pub fn with_permission_config(
1828        mut self,
1829        config: Arc<bamboo_tools::permission::PermissionConfig>,
1830    ) -> Self {
1831        self.permission_config = Some(config);
1832        self
1833    }
1834
1835    /// Run children over the mailbox bus (the unified actor+mailbox transport).
1836    /// When set, local children dial this bus and are driven by mailbox id; when
1837    /// unset they use the legacy direct-WS path. The server passes its in-process
1838    /// broker here (`subagents.broker`); tests without a broker leave it unset.
1839    pub fn with_bus(mut self, bus: Option<bamboo_subagent::BusEndpoint>) -> Self {
1840        self.bus = bus.filter(|b| !b.endpoint.trim().is_empty());
1841        self
1842    }
1843
1844    /// Wire the host-side decider for child gated-tool approval requests
1845    /// (Phase 2). Without this the host fail-closed DENYs every request.
1846    pub fn with_approval_decider(mut self, decider: Arc<dyn ChildApprovalDecider>) -> Self {
1847        self.approval_decider = Some(decider);
1848        self
1849    }
1850
1851    pub fn with_approval_reviewer(mut self, reviewer: Arc<dyn ChildApprovalReviewer>) -> Self {
1852        self.approval_reviewer = Some(reviewer);
1853        self
1854    }
1855
1856    pub fn with_codex_run_tokens(
1857        mut self,
1858        authority: Option<Arc<dyn CodexRunTokenAuthority>>,
1859    ) -> Self {
1860        self.codex_run_tokens = authority;
1861        self
1862    }
1863
1864    /// Pin specific sub-agent roles to remote resident workers (#193). The map
1865    /// is keyed by role (`subagent_type`); a child whose role is present connects
1866    /// over `wss://` to the resolved endpoint instead of spawning a local
1867    /// subprocess. Default (empty) keeps every role on the local path — exactly
1868    /// today's behavior.
1869    pub fn with_remote_placements(
1870        mut self,
1871        placements: HashMap<String, ResolvedRemotePlacement>,
1872    ) -> Self {
1873        self.remote_placements = placements;
1874        self
1875    }
1876
1877    /// Route specific sub-agent roles to a registry-SCHEDULED worker (#181, P2b).
1878    /// The map is keyed by role (`subagent_type`); a child whose role is present
1879    /// (and NOT already pinned by `remote_placements`, which takes precedence) is
1880    /// run on a live worker discovered from the registry instead of a local
1881    /// subprocess. Default (empty) keeps every role on the local path.
1882    pub fn with_schedulable_placements(
1883        mut self,
1884        placements: HashMap<String, ResolvedSchedulablePlacement>,
1885    ) -> Self {
1886        self.schedulable_placements = placements;
1887        self
1888    }
1889
1890    /// Reuse fingerprint: two children are interchangeable on one warm worker iff
1891    /// they share role, provider, model, provider-credential revision,
1892    /// workspace, disabled-tool set, AND every
1893    /// capability the worker BAKES at provision time (`BambooRuntimeExecutor`
1894    /// stamps these once and reuses them across runs): nesting depth, nested-spawn
1895    /// stack, requested/effective permission modes, legacy bypass/auto flags,
1896    /// permission enforcement, and the depth cap. Omitting any
1897    /// of these lets the pool hand a run a worker baked for a DIFFERENT posture —
1898    /// e.g. a depth-1 worker (with its own spawn stack) reused for a depth-4
1899    /// child would re-stamp `spawn_depth=1` and pass the depth-cap check, breaking
1900    /// the recursion bound; or a bypass worker reused for a non-bypass child. So
1901    /// these MUST split the pool bucket. Everything else (assignment, history) is
1902    /// shipped per-run in the `RunSpec` and does not affect the fingerprint.
1903    /// Reuse fingerprint (role/provider/model/workspace/disabled-tools/baked
1904    /// caps): two children with the same fingerprint are interchangeable on one
1905    /// warm worker, so they share a pool bucket. Any axis the worker bakes ONCE
1906    /// at provision time MUST be in here, else a worker baked for one posture
1907    /// gets reused for another (see the `fingerprint_*` tests).
1908    fn fingerprint(spec: &ProvisionSpec) -> String {
1909        let role = spec.identity.role.as_str();
1910        let (provider, model) = spec
1911            .model
1912            .as_ref()
1913            .map(|m| (m.provider.as_str(), m.model.as_str()))
1914            .unwrap_or(("", ""));
1915        let workspace = spec.workspace.as_deref().unwrap_or("");
1916        let mut tools = spec.disabled_tools.clone().unwrap_or_default();
1917        tools.sort();
1918        let caps = &spec.capabilities;
1919        // Provider credentials are baked into a worker at provision time. Hash
1920        // the scoped envelope so a hot-reloaded key/base URL cannot reuse a
1921        // worker provisioned with stale authority. The key itself never enters
1922        // the pool key or logs.
1923        let credential_revision = if spec.secrets.provider_credentials.is_empty() {
1924            "none".to_string()
1925        } else {
1926            let serialized = serde_json::to_vec(&spec.secrets.provider_credentials)
1927                .expect("scoped provider credentials are serializable");
1928            hex::encode(Sha256::digest(serialized))
1929        };
1930        // The worker constructs its executor exactly once. In particular,
1931        // Codex exec and app-server workers are not interchangeable.
1932        let executor = serde_json::to_string(&spec.executor).unwrap_or_default();
1933        format!(
1934            "{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}",
1935            tools.join(","),
1936            spec.identity.depth,
1937            caps.nested_spawn,
1938            caps.permission_requested_mode,
1939            caps.permission_effective_mode,
1940            caps.bypass,
1941            caps.auto_approve_permissions,
1942            caps.enforce_permissions,
1943            caps.max_spawn_depth.unwrap_or(0),
1944            // #73 review (P1): a worker bakes `no_human_review` ONCE from this flag
1945            // at build() and never re-reads it per run, so the pool MUST NOT hand a
1946            // worker baked for one approval posture to a run of the opposite one —
1947            // else a scheduled-root worker reused for an interactive child would
1948            // silently model-review instead of asking the human (and vice-versa,
1949            // reintroducing the 300s-deny). Split the bucket on it.
1950            caps.no_human_approver,
1951            // The read-only no-shell checker is baked once at build(), so a planner
1952            // or Guardian worker must NOT be reused for an ordinary child.
1953            caps.read_only,
1954            // Preserve the legacy Guardian bit as a distinct fingerprint axis
1955            // during rolling upgrades even though both bits enforce read-only.
1956            caps.guardian_read_only,
1957            caps.child_creation_identity,
1958        )
1959    }
1960
1961    /// Start one weakly-owned sweep task for this runner. It does not keep the
1962    /// runner/pool alive, and it never holds the pool lock while killing a child.
1963    fn ensure_pool_reaper(&self) {
1964        if self
1965            .pool_reaper_started
1966            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
1967            .is_err()
1968        {
1969            return;
1970        }
1971        let pool = Arc::downgrade(&self.pool);
1972        tokio::spawn(async move {
1973            let mut interval = tokio::time::interval(POOLED_REAPER_INTERVAL);
1974            interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1975            // Tokio intervals tick immediately once; consume that tick so a new
1976            // runner does not scan an empty pool before its first worker parks.
1977            interval.tick().await;
1978            loop {
1979                interval.tick().await;
1980                let Some(pool) = pool.upgrade() else {
1981                    break;
1982                };
1983                let _ = reap_worker_pool_once(&pool).await;
1984            }
1985        });
1986    }
1987
1988    fn fresh_required_worker_spec(spec: &ProvisionSpec) -> ProvisionSpec {
1989        let mut physical = spec.clone();
1990        // A killed one-shot worker may leave an unACKed Run in its mailbox.
1991        // A fresh subscriber must not replay that Run. Logical birth, storage
1992        // namespace, typed input targets and authority remain on the RunSpec.
1993        physical.identity.child_id = format!("required-worker-{}", uuid::Uuid::new_v4());
1994        physical
1995    }
1996
1997    /// Check out a warm bus worker for `key`, reusing a live parked one if any,
1998    /// else spawning a fresh one that dials the bus. The returned worker is OWNED
1999    /// by the caller for the run's duration (checkout removes it from the pool, so
2000    /// a concurrent sibling gets a different worker or spawns its own — one run per
2001    /// worker at a time, matching the pre-bus pool semantics).
2002    async fn acquire_bus_worker(
2003        &self,
2004        key: &str,
2005        spec: &ProvisionSpec,
2006    ) -> crate::runtime::runner::Result<PooledWorker> {
2007        if spec.capabilities.required_child_context {
2008            let physical = Self::fresh_required_worker_spec(spec);
2009            let worker = spawn_worker_on_bus(&self.worker_bin, &self.worker_args, &physical)
2010                .await
2011                .map_err(|error| {
2012                    AgentError::LLM(format!("required-context worker spawn failed: {error}"))
2013                })?;
2014            let mailbox_id = worker.record.agent_id.clone();
2015            return Ok(PooledWorker {
2016                worker,
2017                mailbox_id,
2018                parked_at: None,
2019            });
2020        }
2021        self.ensure_pool_reaper();
2022        // Drain the bucket, skipping (and reaping) any worker whose process exited
2023        // or crossed its idle deadline while parked. A live, fresh one is handed
2024        // straight out for reuse.
2025        loop {
2026            let candidate = {
2027                let mut pool = self.pool.lock().await;
2028                let candidate = pool.get_mut(key).and_then(|bucket| bucket.pop());
2029                if pool.get(key).is_some_and(Vec::is_empty) {
2030                    pool.remove(key);
2031                }
2032                candidate
2033            };
2034            let Some(mut candidate) = candidate else {
2035                break;
2036            };
2037            let now = Instant::now();
2038            let expired = parked_worker_expired(
2039                candidate.parked_at,
2040                now,
2041                Duration::from_secs(POOLED_IDLE_TIMEOUT_SECS),
2042            );
2043            if candidate.worker.is_alive() && !expired {
2044                candidate.parked_at = None;
2045                return Ok(candidate);
2046            }
2047            tracing::info!(
2048                pool_key = key,
2049                mailbox_id = %candidate.mailbox_id,
2050                worker_pid = ?candidate.worker.pid(),
2051                shutdown_reason = if expired {
2052                    PoolReapReason::IdleTimeout.as_str()
2053                } else {
2054                    PoolReapReason::ProcessExited.as_str()
2055                },
2056                "discarding unusable parked sub-agent worker during checkout"
2057            );
2058            candidate.worker.kill().await;
2059        }
2060
2061        let spawned = spawn_worker_on_bus(&self.worker_bin, &self.worker_args, spec)
2062            .await
2063            .map_err(|e| AgentError::LLM(format!("actor spawn (bus) failed: {e}")))?;
2064        let mailbox_id = spawned.record.agent_id.clone();
2065        Ok(PooledWorker {
2066            worker: spawned,
2067            mailbox_id,
2068            parked_at: None,
2069        })
2070    }
2071
2072    /// Park a warm bus worker for reuse after a clean run; if its bucket is full
2073    /// (or it died), kill it instead. The worker stays dialed-in + subscribed
2074    /// while parked, so a reusing child just delivers a new `Run` to its mailbox.
2075    async fn release_bus_worker(&self, key: &str, mut worker: PooledWorker) {
2076        self.ensure_pool_reaper();
2077        if !worker.worker.is_alive() {
2078            tracing::info!(
2079                pool_key = key,
2080                mailbox_id = %worker.mailbox_id,
2081                worker_pid = ?worker.worker.pid(),
2082                shutdown_reason = "process_exited_before_park",
2083                "discarding sub-agent worker instead of parking"
2084            );
2085            worker.worker.kill().await;
2086            return;
2087        }
2088        worker.parked_at = Some(Instant::now());
2089        let mut pool = self.pool.lock().await;
2090        let key_count = pool.get(key).map_or(0, Vec::len);
2091        let total_count = pool.values().map(Vec::len).sum::<usize>();
2092        let limit_reason =
2093            idle_pool_limit_reason(&pool, key, self.max_idle_per_key, self.max_idle_total);
2094        if let Some(shutdown_reason) = limit_reason {
2095            drop(pool);
2096            tracing::info!(
2097                pool_key = key,
2098                mailbox_id = %worker.mailbox_id,
2099                worker_pid = ?worker.worker.pid(),
2100                idle_workers_for_key = key_count,
2101                idle_workers_total = total_count,
2102                max_idle_per_key = self.max_idle_per_key,
2103                max_idle_total = self.max_idle_total,
2104                shutdown_reason,
2105                "discarding sub-agent worker because the warm pool is full"
2106            );
2107            worker.worker.kill().await;
2108            return;
2109        }
2110        pool.entry(key.to_string()).or_default().push(worker);
2111    }
2112
2113    /// Assemble the parent-resolved provisioning document for this child using
2114    /// one coherent provider snapshot.
2115    fn build_spec_with_provider_config(
2116        &self,
2117        session: &Session,
2118        job: &SpawnJob,
2119        credentials: &[ScopedCredential],
2120        default_provider: &str,
2121    ) -> ProvisionSpec {
2122        let mut spec = ProvisionSpec::new(
2123            ChildIdentity {
2124                child_id: job.child_session_id.clone(),
2125                parent_id: Some(job.parent_session_id.clone()),
2126                project_key: None,
2127                role: session
2128                    .metadata
2129                    .get("subagent_type")
2130                    .cloned()
2131                    .unwrap_or_else(|| "worker".to_string()),
2132                // The child session already carries the correct depth
2133                // (create_child_action's new_child_of did parent.spawn_depth+1);
2134                // stamp it so the worker can re-establish it on its run session
2135                // and enforce the max-depth cap across the actor boundary.
2136                depth: session.spawn_depth,
2137            },
2138            self.executor.clone(),
2139            self.fabric_dir.to_string_lossy().into_owned(),
2140        );
2141        spec.workspace = session.workspace.clone();
2142        if let ExecutorSpec::Codex {
2143            workspace_owned, ..
2144        } = &mut spec.executor
2145        {
2146            *workspace_owned = Some(
2147                spec.workspace
2148                    .as_deref()
2149                    .is_some_and(workspace_is_bamboo_owned),
2150            );
2151        }
2152        // Unified transport: when a bus is configured, the child dials it (no
2153        // listen socket / file discovery) and the parent drives it by mailbox id.
2154        spec.bus = self.bus.clone();
2155        // Final model: the session's pinned model_ref (create.model / routing already applied),
2156        // falling back to the job's bare model on the parent's default provider.
2157        spec.model = session
2158            .model_ref
2159            .as_ref()
2160            .map(|r| ModelRefSpec {
2161                provider: r.provider.clone(),
2162                model: r.model.clone(),
2163            })
2164            .or_else(|| {
2165                let m = job.model.trim();
2166                (!m.is_empty()).then(|| ModelRefSpec {
2167                    provider: default_provider.to_string(),
2168                    model: m.to_string(),
2169                })
2170            });
2171        spec.disabled_tools = job.disabled_tools.clone();
2172        match &spec.executor {
2173            // Codex auth is independent of the session's normal Bamboo model
2174            // provider. Inherit/API-key/Bamboo modes need no credential-store
2175            // secret at provisioning; custom mode gets exactly its referenced
2176            // key. This prevents an unrelated upstream provider key from
2177            // reaching a Codex worker that only needs a per-run bcx1_ token.
2178            ExecutorSpec::Codex {
2179                auth_mode,
2180                provider_key_ref,
2181                ..
2182            } => {
2183                if auth_mode.as_deref() == Some("custom") {
2184                    if let Some(reference) = provider_key_ref {
2185                        if let Some(credential) = credentials.iter().find(|credential| {
2186                            credential.credential_ref.as_deref() == Some(reference)
2187                        }) {
2188                            spec.secrets.provider_credentials.push(credential.clone());
2189                        } else {
2190                            tracing::warn!(
2191                                "actor child {}: custom Codex credential reference '{}' did not resolve",
2192                                job.child_session_id,
2193                                reference
2194                            );
2195                        }
2196                    } else {
2197                        tracing::warn!(
2198                            "actor child {}: custom Codex executor has no credential reference",
2199                            job.child_session_id
2200                        );
2201                    }
2202                }
2203            }
2204            // Other executors keep the existing least-privilege contract: only
2205            // the credential for the child session's selected provider.
2206            _ => {
2207                let provider = spec
2208                    .model
2209                    .as_ref()
2210                    .map(|model| model.provider.as_str())
2211                    .filter(|provider| !provider.trim().is_empty())
2212                    .unwrap_or(default_provider);
2213                if let Some(credential) = credentials
2214                    .iter()
2215                    .find(|credential| credential.provider == provider)
2216                {
2217                    spec.secrets.provider_credentials.push(credential.clone());
2218                } else {
2219                    tracing::warn!(
2220                        "actor child {}: no credential found for provider '{}'",
2221                        job.child_session_id,
2222                        provider
2223                    );
2224                }
2225            }
2226        }
2227        // Phase 6 (direct nested execution): a worker BELOW the depth cap may
2228        // orchestrate its OWN children — on startup it builds its own spawn
2229        // stack and runs the real SubAgent tool (no host proxy). The cap (the
2230        // SubAgent tool refuses to spawn at/over `max_spawn_depth`) bounds the
2231        // recursion. Driven purely by the child's depth, so it auto-propagates
2232        // down the tree without any extra config threading.
2233        spec.capabilities.nested_spawn = session.spawn_depth < MAX_SPAWN_DEPTH;
2234        spec.capabilities.max_spawn_depth = Some(MAX_SPAWN_DEPTH);
2235        // #69: activate child-approval review. Sub-agents enforce permissions so
2236        // their DANGEROUS actions (the worker uses a HIGH threshold) reach the
2237        // parent for review — escalated to the human, or model-reviewed off-loop
2238        // when the parent is in bypass. The worker installs no checker without
2239        // this, so the whole review chain would otherwise stay dormant.
2240        spec.capabilities.enforce_permissions = true;
2241        // Propagate "bypass permissions" so a self-orchestrating worker knows it
2242        // is a bypassed parent and installs the off-loop model-reviewer for its
2243        // children's forced-ask actions (Phase 6, Part B). The child session
2244        // already carries the inherited flag (create_child_action seeds it).
2245        let requested_permission_mode = session
2246            .agent_runtime_state
2247            .as_ref()
2248            .map(|state| state.effective_permission_mode())
2249            .unwrap_or_default();
2250        let configured_permission_mode = self
2251            .permission_config
2252            .as_ref()
2253            .map(|config| config.mode())
2254            .unwrap_or_default();
2255        // #73: propagate "no interactive human approver" (headless / scheduled /
2256        // deployed root, inherited by the child session). When set, the worker's
2257        // per-run approval proxy model-reviews a gated action locally instead of
2258        // escalating to a human who will never answer (which would 300s-deny).
2259        spec.capabilities.no_human_approver = session
2260            .agent_runtime_state
2261            .as_ref()
2262            .is_some_and(|s| s.no_human_approver);
2263        // Read-only authority comes from the child's typed runtime state, never
2264        // its cosmetic role label. The sole legacy recovery path additionally
2265        // requires the old Guardian role AND its host-owned mutation denylist,
2266        // preserving in-flight pre-upgrade reviewers without making a label an
2267        // authority boundary.
2268        let typed_read_only = session
2269            .agent_runtime_state
2270            .as_ref()
2271            .is_some_and(|state| state.read_only);
2272        let legacy_guardian_read_only = spec.identity.role == "guardian"
2273            && spec.disabled_tools.as_ref().is_some_and(|disabled| {
2274                ["Edit", "Write", "SubAgent", "WebFetch"]
2275                    .iter()
2276                    .all(|name| disabled.iter().any(|disabled_name| disabled_name == *name))
2277            });
2278        let read_only = typed_read_only || legacy_guardian_read_only;
2279        spec.capabilities.read_only = read_only;
2280        // Emit the old field for every read-only child. Older workers ignore
2281        // `read_only` but understand this bit and will install the strict
2282        // no-shell checker, which prevents a rolling upgrade from weakening Plan.
2283        spec.capabilities.guardian_read_only = read_only;
2284        if spec.capabilities.read_only_enforced() {
2285            // Do not even construct nested delegation machinery for a read-only
2286            // inspection child; the denylist below remains a second boundary.
2287            spec.capabilities.nested_spawn = false;
2288            // Treat the capability as the authority boundary, not as a hint
2289            // that every caller must remember to pair with a schema filter.
2290            // Preserve caller-specific disables while always removing Bamboo's
2291            // shell, mutating, persistent, interactive, and delegation surfaces.
2292            spec.disabled_tools
2293                .get_or_insert_with(Default::default)
2294                .extend(crate::runtime::guardian_state::read_only_child_disabled_tools());
2295            match &mut spec.executor {
2296                ExecutorSpec::ClaudeCode {
2297                    permission_mode,
2298                    inherit_user_config,
2299                    ..
2300                } => {
2301                    *permission_mode = Some("plan".to_string());
2302                    // User config may add MCP tools/hooks outside the provisioned
2303                    // surface, so it is never inherited by a read-only child.
2304                    *inherit_user_config = Some(false);
2305                }
2306                ExecutorSpec::Codex {
2307                    mode,
2308                    sandbox,
2309                    inherit_user_config,
2310                    approval_policy,
2311                    network_access,
2312                    allow_danger_bypass,
2313                    permission_profile,
2314                    ..
2315                } => {
2316                    *sandbox = Some("read-only".to_string());
2317                    *inherit_user_config = Some(false);
2318                    // app-server provisioning accepts `on-request`; the typed
2319                    // Plan activation maps that to `approvalPolicy=never`.
2320                    *approval_policy = Some(
2321                        if mode.as_deref() == Some("app_server") {
2322                            "on-request"
2323                        } else {
2324                            "never"
2325                        }
2326                        .to_string(),
2327                    );
2328                    *network_access = Some(false);
2329                    *allow_danger_bypass = Some(false);
2330                    *permission_profile = Some("read-only".to_string());
2331                }
2332                _ => {}
2333            }
2334        }
2335        let read_only_overlay = session
2336            .agent_runtime_state
2337            .as_ref()
2338            .is_some_and(|state| state.plan_mode.is_some())
2339            || spec.capabilities.read_only_enforced()
2340            || executor_has_read_only_permission_profile(&spec.executor);
2341        let permission_resolution = bamboo_domain::resolve_permission_mode_with_read_only(
2342            requested_permission_mode,
2343            configured_permission_mode,
2344            read_only_overlay,
2345        );
2346        spec.capabilities.bypass = permission_resolution.bypass_permissions();
2347        spec.capabilities.auto_approve_permissions =
2348            permission_resolution.suppress_approval_prompts();
2349        spec.capabilities.permission_requested_mode =
2350            permission_resolution.requested.as_str().to_string();
2351        spec.capabilities.permission_effective_mode =
2352            permission_resolution.effective.as_str().to_string();
2353        // #193: route this role to a REMOTE resident worker when one is pinned.
2354        // `spec.identity.role` was just computed from `subagent_type` above; a
2355        // match flips the placement to Remote. The scoped broker credential stays
2356        // on the Host transport and never enters the child RunSpec. No match
2357        // leaves the default `Placement::Local`,
2358        // so the local path is byte-for-byte unchanged for every non-pinned role.
2359        if let Some(placement) = self.remote_placements.get(spec.identity.role.as_str()) {
2360            spec.placement = Placement::Remote {
2361                endpoint: placement.endpoint.clone(),
2362            };
2363        } else if let Some(placement) = self.schedulable_placements.get(spec.identity.role.as_str())
2364        {
2365            // #181 (P2b): route this role to a SCHEDULED worker — ONLY when it is
2366            // NOT already pinned to a fixed remote endpoint (the `else if` makes
2367            // remote_placements take precedence for a role in both). The concrete
2368            // worker is picked at run time in `execute_external_child` from the bus
2369            // (a live connected worker of the pool role). No per-placement bearer
2370            // now — the bus connection uses the bus token. No match in either map
2371            // leaves the default `Placement::Local`.
2372            spec.placement = Placement::Schedulable {
2373                pool: placement.pool.clone(),
2374            };
2375        }
2376        spec
2377    }
2378
2379    /// Assemble from the startup snapshot. Kept synchronous for focused unit
2380    /// tests and non-server callers that do not install a live config source.
2381    fn build_spec(&self, session: &Session, job: &SpawnJob) -> ProvisionSpec {
2382        self.build_spec_with_provider_config(
2383            session,
2384            job,
2385            &self.credentials,
2386            &self.default_provider,
2387        )
2388    }
2389
2390    /// Assemble from the latest AppState config when available. Taking the
2391    /// async read lock here gives every new activation a post-reload credential
2392    /// view without copying secrets into a second independently refreshed
2393    /// runtime store.
2394    async fn build_live_spec(&self, session: &Session, job: &SpawnJob) -> ProvisionSpec {
2395        let Some(config) = self.live_provider_config.as_ref() else {
2396            return self.build_spec(session, job);
2397        };
2398
2399        let config = config.read().await;
2400        let credentials = super::runtime::extract_provider_credentials(&config);
2401        let default_provider = config.effective_default_provider().to_string();
2402        self.build_spec_with_provider_config(session, job, &credentials, &default_provider)
2403    }
2404
2405    /// The `metadata["placement"]` JSON to stamp on a child from its resolved
2406    /// placement, preferring the matching cluster node's `host_label` (its
2407    /// operator label/host) over the raw endpoint/pool. `None` for a Local child
2408    /// (the DTO defaults it to the backend's own host). Split out of
2409    /// `execute_external_child` so the role→placement→host resolution is unit-testable.
2410    fn placement_stamp_for(&self, spec: &ProvisionSpec) -> Option<String> {
2411        let host_label = match &spec.placement {
2412            Placement::Remote { .. } => self
2413                .remote_placements
2414                .get(spec.identity.role.as_str())
2415                .and_then(|p| p.host_label.as_deref()),
2416            Placement::Schedulable { .. } => self
2417                .schedulable_placements
2418                .get(spec.identity.role.as_str())
2419                .and_then(|p| p.host_label.as_deref()),
2420            Placement::Local => None,
2421        };
2422        placement_metadata(&spec.placement, host_label)
2423    }
2424
2425    fn scoped_placement_route(
2426        &self,
2427        spec: &ProvisionSpec,
2428    ) -> Result<Option<ScopedPlacementRoute>, AgentError> {
2429        match &spec.placement {
2430            Placement::Local => Ok(None),
2431            Placement::Remote { .. } => {
2432                let placement = self
2433                    .remote_placements
2434                    .get(&spec.identity.role)
2435                    .ok_or_else(remote_broker_unavailable)?;
2436                let peer = placement
2437                    .broker_peer
2438                    .as_ref()
2439                    .and_then(|peer| peer.as_ref().ok())
2440                    .ok_or_else(remote_broker_unavailable)?;
2441                let worker_role = peer
2442                    .worker_role
2443                    .clone()
2444                    .ok_or_else(remote_broker_unavailable)?;
2445                Ok(Some(ScopedPlacementRoute {
2446                    endpoint: placement.endpoint.clone(),
2447                    token: placement
2448                        .token
2449                        .clone()
2450                        .ok_or_else(remote_broker_unavailable)?,
2451                    ca_cert_file: placement
2452                        .ca_cert_file
2453                        .clone()
2454                        .ok_or_else(remote_broker_unavailable)?,
2455                    parent: bamboo_subagent::AgentRef {
2456                        session_id: peer.parent_mailbox.clone(),
2457                        role: peer.parent_role.clone(),
2458                    },
2459                    worker_role,
2460                    pinned_mailbox: Some(peer.worker_mailbox.clone()),
2461                    requirements: placement
2462                        .requirements
2463                        .clone()
2464                        .ok_or_else(remote_broker_unavailable)?,
2465                }))
2466            }
2467            Placement::Schedulable { .. } => {
2468                let placement = self
2469                    .schedulable_placements
2470                    .get(&spec.identity.role)
2471                    .ok_or_else(remote_broker_unavailable)?;
2472                let parent = placement
2473                    .broker_parent
2474                    .as_ref()
2475                    .ok_or_else(remote_broker_unavailable)?;
2476                Ok(Some(ScopedPlacementRoute {
2477                    endpoint: placement.endpoint.clone(),
2478                    token: placement
2479                        .token
2480                        .clone()
2481                        .ok_or_else(remote_broker_unavailable)?,
2482                    ca_cert_file: placement
2483                        .ca_cert_file
2484                        .clone()
2485                        .ok_or_else(remote_broker_unavailable)?,
2486                    parent: bamboo_subagent::AgentRef {
2487                        session_id: parent.parent_mailbox.clone(),
2488                        role: Some(parent.parent_role.clone()),
2489                    },
2490                    worker_role: placement.pool.clone(),
2491                    pinned_mailbox: None,
2492                    requirements: placement
2493                        .requirements
2494                        .clone()
2495                        .ok_or_else(remote_broker_unavailable)?,
2496                }))
2497            }
2498        }
2499    }
2500
2501    async fn select_scoped_worker(
2502        &self,
2503        route: &ScopedPlacementRoute,
2504        registry: &bamboo_storage::v2::FileHostRegistry,
2505        actor_id: &str,
2506        project_id: Option<bamboo_domain::ProjectId>,
2507        required_tools: &BTreeSet<String>,
2508    ) -> Result<String, AgentError> {
2509        if let Some(mailbox) = &route.pinned_mailbox {
2510            return Ok(mailbox.clone());
2511        }
2512        let mut query = route.query().await?;
2513        let candidates = query
2514            .list_connected(&route.worker_role)
2515            .await
2516            .map_err(|_| remote_broker_unavailable())?;
2517        let scheduler = PlacementScheduler::new(registry.clone());
2518        let mut selected: Option<(String, usize, u16)> = None;
2519        for mailbox in candidates {
2520            let Some(observed) = query
2521                .observe_host_capacity(&mailbox, &route.worker_role)
2522                .await
2523                .map_err(|_| remote_broker_unavailable())?
2524            else {
2525                continue;
2526            };
2527            let Ok(host) = scheduler
2528                .observe_scoped_host(
2529                    &mut query,
2530                    &observed.host_ref,
2531                    &mailbox,
2532                    &route.worker_role,
2533                    chrono::Duration::seconds(110),
2534                )
2535                .await
2536            else {
2537                continue;
2538            };
2539            let now = chrono::Utc::now();
2540            let request = HostPlacementRequest {
2541                intent: HostPlacementIntent::Pool {
2542                    pool_id: route.worker_role.clone(),
2543                },
2544                actor_id: actor_id.to_owned(),
2545                run_id: "selection".into(),
2546                project_id: project_id.clone(),
2547                trust_zone: route.requirements.trust_zone.clone(),
2548                workspace_label: route.requirements.workspace_label.clone(),
2549                executor: "bamboo-runtime".into(),
2550                required_tools: required_tools.clone(),
2551                network_zone: route.requirements.network_zone.clone(),
2552                require_network_isolation: route.requirements.require_network_isolation,
2553                preferred_host_ref: None,
2554                now,
2555                lease_expires_at: now + chrono::Duration::seconds(80),
2556            };
2557            if host.capabilities.placement_class != bamboo_domain::ActorPlacementClass::Remote
2558                || !request.eligible(&host)
2559            {
2560                continue;
2561            }
2562            let load = host.active_slots(now);
2563            if selected.as_ref().is_none_or(|(_, old_load, old_slots)| {
2564                load * usize::from(*old_slots) < *old_load * usize::from(host.max_slots)
2565            }) {
2566                selected = Some((mailbox, load, host.max_slots));
2567            }
2568        }
2569        selected
2570            .map(|(mailbox, _, _)| mailbox)
2571            .ok_or_else(remote_broker_unavailable)
2572    }
2573}
2574
2575#[async_trait]
2576impl ExternalChildRunner for ActorChildRunner {
2577    async fn validate_required_child_context_route(&self, session: &Session) -> Result<(), String> {
2578        let role = session
2579            .metadata
2580            .get("subagent_type")
2581            .map(String::as_str)
2582            .unwrap_or("worker");
2583        let current = std::env::current_exe()
2584            .ok()
2585            .and_then(|path| path.canonicalize().ok());
2586        if self.native_tool_ceiling.is_none()
2587            || !self.builtin_required_context_route
2588            || !self.should_handle(session).await
2589            || current.is_none()
2590            || self.worker_bin.canonicalize().ok() != current
2591            || self.worker_args != ["subagent-worker".to_string()]
2592            || !matches!(self.executor, ExecutorSpec::BambooRuntime)
2593            || self.bus.is_none()
2594            || self.remote_placements.contains_key(role)
2595            || self.schedulable_placements.contains_key(role)
2596        {
2597            return Err(
2598                "required_child_context_unsupported: built-in fresh local Bamboo worker required"
2599                    .into(),
2600            );
2601        }
2602        bamboo_subagent::fleet::require_worker_capability(
2603            &self.worker_bin,
2604            &self.worker_args,
2605            bamboo_subagent::provision::REQUIRED_CHILD_CONTEXT_WORKER_CAPABILITY,
2606        )
2607        .await
2608        .map_err(|_| {
2609            "required_child_context_unsupported: worker capability unconfirmed".to_string()
2610        })?;
2611        bamboo_subagent::fleet::require_worker_capability(
2612            &self.worker_bin,
2613            &self.worker_args,
2614            bamboo_subagent::provision::NATIVE_TOOL_CEILING_WORKER_CAPABILITY,
2615        )
2616        .await
2617        .map_err(|_| {
2618            "native_tool_ceiling_unsupported: worker capability unconfirmed".to_string()
2619        })?;
2620        bamboo_subagent::fleet::require_worker_capability(
2621            &self.worker_bin,
2622            &self.worker_args,
2623            bamboo_subagent::provision::CHILD_CREATION_IDENTITY_WORKER_CAPABILITY,
2624        )
2625        .await
2626        .map_err(|_| {
2627            "required_child_context_unsupported: worker birth capability unconfirmed".into()
2628        })
2629    }
2630    async fn should_handle(&self, session: &Session) -> bool {
2631        session.metadata.get("runtime.kind") == Some(&"external".to_string())
2632            && session.metadata.get("external.protocol") == Some(&"actor".to_string())
2633            && session.metadata.get("external.agent_id") == Some(&self.agent_id)
2634    }
2635
2636    fn set_escalation_bridge(&self, bridge: Option<bamboo_subagent::executor::HostBridge>) {
2637        *self.escalation_bridge.lock().recover_poison() = bridge;
2638    }
2639
2640    fn set_session_inbox_runtime(&self, binding: Option<SessionInboxRuntimeBinding>) {
2641        *self.session_inbox_runtime.lock().recover_poison() = binding;
2642    }
2643
2644    fn set_actor_directory_store(&self, store: Option<Arc<bamboo_storage::SessionStoreV2>>) {
2645        *self.actor_directory_store.lock().recover_poison() = store;
2646    }
2647
2648    fn set_canonical_subagent_tool(&self, tool: Option<Arc<dyn bamboo_agent_core::tools::Tool>>) {
2649        *self.canonical_subagent_tool.lock().recover_poison() = tool;
2650    }
2651
2652    fn set_ticket_service(&self, service: Option<Arc<bamboo_tickets::TicketService>>) {
2653        *self.ticket_service.lock().recover_poison() = service;
2654    }
2655
2656    fn set_actor_event_observer(&self, observer: Option<Arc<dyn ActorEventObserver>>) {
2657        *self.actor_event_observer.lock().recover_poison() = observer;
2658    }
2659
2660    async fn prepare_durable_child_delivery(
2661        &self,
2662        session: &Session,
2663        activation_run_id: &str,
2664    ) -> Result<bool, String> {
2665        let key = (
2666            session.id.clone(),
2667            session.created_at,
2668            activation_run_id.to_owned(),
2669        );
2670        let (delivery, host_nested_wait_handoff, strict_history) = {
2671            let pending = self.pending_durable_links.lock().await;
2672            let Some(pending) = pending.get(&key) else {
2673                return Ok(false);
2674            };
2675            if pending.strict_history_required && pending.strict_history.is_none() {
2676                return Err("Host strict terminal completeness proof missing".into());
2677            }
2678            (
2679                pending
2680                    .link
2681                    .durable_delivery_receipt()
2682                    .ok_or_else(|| "broker terminal has no exact durable receipt".to_string())?,
2683                pending.host_nested_wait_handoff,
2684                pending.strict_history.clone(),
2685            )
2686        };
2687        let expected_status =
2688            effective_broker_terminal_status(delivery.terminal_status, host_nested_wait_handoff)?;
2689        if session.last_run_status().as_deref() != Some(expected_status) {
2690            tracing::warn!(
2691                child_session_id = %session.id,
2692                activation_run_id,
2693                expected_status,
2694                actual_status = ?session.last_run_status(),
2695                "Host final status differs from accepted broker terminal"
2696            );
2697            return Err("Host final status differs from accepted broker terminal".into());
2698        }
2699        let store = self
2700            .actor_directory_store
2701            .lock()
2702            .recover_poison()
2703            .clone()
2704            .ok_or_else(|| "Host broker receipt store unavailable".to_string())?;
2705        let binding = self
2706            .session_inbox_runtime
2707            .lock()
2708            .recover_poison()
2709            .clone()
2710            .ok_or_else(|| "Host activation fence unavailable for broker receipt".to_string())?;
2711        if !binding
2712            .router
2713            .owns_run(&session.id, activation_run_id)
2714            .await
2715        {
2716            return Err("stale Child activation cannot prepare broker receipt".into());
2717        }
2718        let ticket_service = self.ticket_service.lock().recover_poison().clone();
2719        let yielded_call = if expected_status == "completed"
2720            && session
2721                .metadata
2722                .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
2723        {
2724            let service = ticket_service
2725                .as_deref()
2726                .ok_or("Ticket authority unavailable for broker question")?;
2727            crate::ticket_runtime::question_terminal_call_id(service, session, activation_run_id)
2728                .map_err(|e| e.to_string())?
2729        } else {
2730            None
2731        };
2732        let route = bamboo_storage::v2::BrokerTerminalRoute {
2733            activation_run_id,
2734            broker_identity: &delivery.broker_identity,
2735            parent_mailbox: &delivery.parent_mailbox,
2736            broker_correlation_id: &delivery.correlation_id,
2737            message_ids: &delivery.message_ids,
2738        };
2739        if let Some(call_id) = yielded_call {
2740            let proof = bamboo_storage::v2::HostToolYield::from_verified_host(
2741                session,
2742                activation_run_id,
2743                &call_id,
2744            )
2745            .map_err(|error| error.to_string())?;
2746            if let Some(history) = strict_history.as_ref() {
2747                store
2748                    .prepare_broker_tool_yield_receipt_with_completeness(
2749                        session,
2750                        route,
2751                        &proof,
2752                        &history.proof,
2753                    )
2754                    .await
2755            } else {
2756                store
2757                    .prepare_broker_tool_yield_receipt(session, route, &proof)
2758                    .await
2759            }
2760            .map_err(|error| format!("Host broker receipt prepare failed: {error}"))?;
2761        } else {
2762            if let Some(history) = strict_history.as_ref() {
2763                store
2764                    .prepare_broker_terminal_receipt_with_completeness(
2765                        session,
2766                        route,
2767                        &history.proof,
2768                    )
2769                    .await
2770            } else {
2771                store
2772                    .prepare_broker_terminal_receipt(
2773                        session,
2774                        activation_run_id,
2775                        &delivery.broker_identity,
2776                        &delivery.parent_mailbox,
2777                        &delivery.correlation_id,
2778                        &delivery.message_ids,
2779                    )
2780                    .await
2781            }
2782            .map_err(|error| format!("Host broker receipt prepare failed: {error}"))?;
2783        }
2784        Ok(true)
2785    }
2786
2787    async fn commit_durable_child_delivery(
2788        &self,
2789        session: &Session,
2790        activation_run_id: &str,
2791    ) -> Result<(), String> {
2792        let key = (
2793            session.id.clone(),
2794            session.created_at,
2795            activation_run_id.to_owned(),
2796        );
2797        let mut pending_links = self.pending_durable_links.lock().await;
2798        let Some(pending) = pending_links.get_mut(&key) else {
2799            return Ok(());
2800        };
2801        let delivery = pending
2802            .link
2803            .durable_delivery_receipt()
2804            .ok_or_else(|| "broker terminal lost its exact durable receipt".to_string())?;
2805        let expected_status = effective_broker_terminal_status(
2806            delivery.terminal_status,
2807            pending.host_nested_wait_handoff,
2808        )?;
2809        if session.last_run_status().as_deref() != Some(expected_status) {
2810            return Err("Host final status differs from accepted broker terminal".into());
2811        }
2812        if pending.strict_history_required && pending.strict_history.is_none() {
2813            return Err("Host strict terminal completeness proof missing".into());
2814        }
2815        let store = self
2816            .actor_directory_store
2817            .lock()
2818            .recover_poison()
2819            .clone()
2820            .ok_or_else(|| "Host broker receipt store unavailable".to_string())?;
2821        let committed = if let Some(history) = pending.strict_history.as_ref() {
2822            store
2823                .commit_broker_terminal_receipt_with_completeness(
2824                    session,
2825                    activation_run_id,
2826                    &history.proof,
2827                )
2828                .await
2829        } else {
2830            store
2831                .commit_broker_terminal_receipt(session, activation_run_id)
2832                .await
2833        }
2834        .map_err(|error| format!("Host broker receipt checkpoint unconfirmed: {error}"))?;
2835        if committed.broker_identity != delivery.broker_identity
2836            || committed.parent_mailbox != delivery.parent_mailbox
2837            || committed.broker_correlation_id != delivery.correlation_id
2838            || committed.message_ids != delivery.message_ids
2839            || committed.terminal_status != expected_status
2840        {
2841            return Err("Host broker receipt changed before completion".into());
2842        }
2843        if let Some(history) = pending.strict_history.as_ref() {
2844            let proof = committed
2845                .terminal_completeness
2846                .as_ref()
2847                .ok_or_else(|| "Host strict terminal completeness readback missing".to_string())?;
2848            let creation = bamboo_subagent::proto::ChildCreationIdentity {
2849                created_at: session.created_at,
2850                spawn_depth: session.spawn_depth,
2851            };
2852            validate_strict_history_terminal(
2853                Some(&history.watermark),
2854                session,
2855                session
2856                    .parent_session_id
2857                    .as_deref()
2858                    .ok_or("Child parent missing")?,
2859                Some(activation_run_id),
2860                proof.execution_epoch,
2861                Some(&creation),
2862                proof.contiguous_applied_seq,
2863            )
2864            .map_err(|error| error.to_string())?;
2865        }
2866        // The same exact-snapshot CAS serves live and cold publication. An
2867        // append-safe generic save could expose Completed after merging new
2868        // input that invalidates the source; CAS rejects before that write.
2869        publish_recovered_completion_source(&store, &committed).await?;
2870        pending.strict_history_committed = true;
2871        Ok(())
2872    }
2873
2874    async fn confirm_durable_child_delivery(
2875        &self,
2876        session: &Session,
2877        activation_run_id: &str,
2878        save_succeeded: bool,
2879    ) -> Result<(), String> {
2880        if save_succeeded
2881            && session
2882                .metadata
2883                .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
2884        {
2885            let service = self
2886                .ticket_service
2887                .lock()
2888                .recover_poison()
2889                .clone()
2890                .ok_or_else(|| {
2891                    "Ticket authority unavailable for terminal checkpoint".to_string()
2892                })?;
2893            let runtime = self
2894                .session_inbox_runtime
2895                .lock()
2896                .recover_poison()
2897                .clone()
2898                .ok_or_else(|| "Ticket runtime unavailable for terminal checkpoint".to_string())?;
2899            crate::ticket_runtime::checkpoint_ticket_result(
2900                &service,
2901                runtime.storage.as_ref(),
2902                session,
2903                activation_run_id,
2904            )
2905            .await
2906            .map_err(|e| e.to_string())?;
2907        }
2908        let key = (
2909            session.id.clone(),
2910            session.created_at,
2911            activation_run_id.to_owned(),
2912        );
2913        let Some(mut pending) = self.pending_durable_links.lock().await.remove(&key) else {
2914            return Ok(());
2915        };
2916        // A later SDK timeout or Host postprocessing error cannot convert an
2917        // accepted Worker terminal into a different receipt status.
2918        let delivery = pending
2919            .link
2920            .durable_delivery_receipt()
2921            .ok_or_else(|| "broker terminal lost its exact durable receipt".to_string())?;
2922        let expected_status = effective_broker_terminal_status(
2923            delivery.terminal_status,
2924            pending.host_nested_wait_handoff,
2925        )?;
2926        if !save_succeeded || session.last_run_status().as_deref() != Some(expected_status) {
2927            return Ok(());
2928        }
2929        let store = self
2930            .actor_directory_store
2931            .lock()
2932            .recover_poison()
2933            .clone()
2934            .ok_or_else(|| "Host broker receipt store unavailable".to_string())?;
2935        let committed = if pending.strict_history_required {
2936            if !pending.strict_history_committed {
2937                return Err("Host strict completeness was not committed before ACK".into());
2938            }
2939            let history = pending
2940                .strict_history
2941                .as_ref()
2942                .ok_or_else(|| "Host strict terminal completeness proof missing".to_string())?;
2943            store
2944                .commit_broker_terminal_receipt_with_completeness(
2945                    session,
2946                    activation_run_id,
2947                    &history.proof,
2948                )
2949                .await
2950        } else {
2951            store
2952                .commit_broker_terminal_receipt(session, activation_run_id)
2953                .await
2954        }
2955        .map_err(|error| format!("Host broker receipt checkpoint unconfirmed: {error}"))?;
2956        if committed.broker_identity != delivery.broker_identity
2957            || committed.parent_mailbox != delivery.parent_mailbox
2958            || committed.broker_correlation_id != delivery.correlation_id
2959            || committed.message_ids != delivery.message_ids
2960            || committed.terminal_status != expected_status
2961        {
2962            return Err("Host broker receipt changed before ACK".into());
2963        }
2964        pending
2965            .link
2966            .acknowledge_durable_frames()
2967            .await
2968            .map_err(|error| format!("child broker durable ACK unconfirmed: {error}"))?;
2969        store
2970            .clear_acknowledged_broker_terminal_receipt(
2971                &session.id,
2972                session.created_at,
2973                activation_run_id,
2974                &delivery.parent_mailbox,
2975            )
2976            .await
2977            .map_err(|error| {
2978                format!("broker ACK succeeded but Host receipt cleanup failed: {error}")
2979            })
2980    }
2981
2982    #[tracing::instrument(
2983        name = "actor_child_run",
2984        level = "warn",
2985        skip_all,
2986        fields(child_session_id = %job.child_session_id, activation_run_id = tracing::field::Empty)
2987    )]
2988    async fn execute_external_child(
2989        &self,
2990        session: &mut Session,
2991        job: &SpawnJob,
2992        event_tx: mpsc::Sender<AgentEvent>,
2993        cancel_token: CancellationToken,
2994    ) -> crate::runtime::runner::Result<()> {
2995        crate::session_app::child_session::named_profile::validate_named_profile(session)
2996            .map_err(|error| AgentError::LLM(error.to_string()))?;
2997        // #68 CORRECTNESS CRUX: capture the per-run escalation bridge HERE, at the
2998        // moment this grandchild is spawned — while the parent run's bridge is
2999        // still in our slot — into an owned local handed to `drive()` for this
3000        // grandchild's whole lifetime. A fire-and-forget grandchild that OUTLIVES
3001        // the run that spawned it must NOT re-read `self.escalation_bridge` at
3002        // approval time: by then `run()` may have cleared/overwritten it (a worker
3003        // serves runs sequentially), and re-proxying through a closed bridge
3004        // fail-closed denies. Capturing at spawn pins the right bridge per run.
3005        let escalation = self.escalation_bridge.lock().recover_poison().clone();
3006        let session_inbox_runtime = self.session_inbox_runtime.lock().recover_poison().clone();
3007        let actor_directory_store = self.actor_directory_store.lock().recover_poison().clone();
3008        let canonical_subagent_tool = self.canonical_subagent_tool.lock().recover_poison().clone();
3009        let ticket_service = self.ticket_service.lock().recover_poison().clone();
3010        let actor_event_observer = self.actor_event_observer.lock().recover_poison().clone();
3011        let required_context = bamboo_domain::ChildContextBinding::from_session(session)
3012            .map_err(|error| AgentError::Budget(error.to_string()))?;
3013        if crate::session_app::child_session::named_profile::has_named_profile(session)
3014            && required_context.is_none()
3015        {
3016            return Err(AgentError::LLM(
3017                "named_profile_requires_strict_context_route".into(),
3018            ));
3019        }
3020        let assignment = required_context
3021            .as_ref()
3022            .map(|binding| binding.payload.required_assignment.clone())
3023            .unwrap_or_else(|| extract_assignment(session));
3024        let mut spec = self.build_live_spec(session, job).await;
3025        // Reject before remote routing, Actor claims, provisioning or Ticket
3026        // Run admission: resident workers cannot produce local owned stop proof.
3027        ensure_ticket_placement_is_enforceable(session, &spec)?;
3028        let scoped_route = self.scoped_placement_route(&spec)?;
3029        let strict_remote = scoped_route.as_ref();
3030        if strict_remote.is_some() && !matches!(spec.executor, ExecutorSpec::BambooRuntime) {
3031            return Err(remote_broker_unavailable());
3032        }
3033        let lineage = if strict_remote.is_some() {
3034            Some(
3035                remote_canonical_lineage(
3036                    session_inbox_runtime
3037                        .as_ref()
3038                        .ok_or_else(remote_broker_unavailable)?,
3039                    session,
3040                )
3041                .await?,
3042            )
3043        } else {
3044            None
3045        };
3046        let creation = if (matches!(spec.placement, Placement::Local) || strict_remote.is_some())
3047            && matches!(spec.executor, ExecutorSpec::BambooRuntime)
3048        {
3049            let binding = session_inbox_runtime.as_ref().ok_or_else(|| {
3050                AgentError::LLM(
3051                    "child creation identity unavailable: canonical host Storage required".into(),
3052                )
3053            })?;
3054            spec.capabilities.child_creation_identity = true;
3055            Some(canonical_child_creation(binding, session, job).await?)
3056        } else {
3057            None
3058        };
3059        // Mark the worker reusable + give it an idle timeout so it self-reaps if
3060        // orphaned. Warm bus workers are pooled per fingerprint and reused.
3061        spec.reusable = required_context.is_none();
3062        spec.capabilities.required_child_context = required_context.is_some();
3063        if required_context.is_some() {
3064            self.validate_required_child_context_route(session)
3065                .await
3066                .map_err(AgentError::LLM)?;
3067        }
3068        if required_context.is_some() {
3069            let names = self
3070                .native_tool_ceiling
3071                .as_ref()
3072                .unwrap()
3073                .observe(session)
3074                .await
3075                .map_err(AgentError::LLM)?;
3076            let birth = creation
3077                .as_ref()
3078                .ok_or_else(|| AgentError::LLM("native_tool_ceiling_birth_missing".into()))?;
3079            spec.capabilities.native_tool_ceiling_required = true;
3080            spec.capabilities.native_tool_ceiling =
3081                Some(bamboo_subagent::proto::NativeToolCeiling {
3082                    version: 1,
3083                    child_session_id: session.id.clone(),
3084                    parent_session_id: job.parent_session_id.clone(),
3085                    root_session_id: session.root_session_id.clone(),
3086                    created_at: birth.created_at,
3087                    spawn_depth: birth.spawn_depth,
3088                    project_id: project_id_for_actor_run(session)?,
3089                    tools: names,
3090                });
3091            // Positive native names are a leaf surface, never an expansion path.
3092            spec.capabilities.mcp = None;
3093            spec.capabilities.mcp_proxy = None;
3094            spec.capabilities.skills_dir = None;
3095            spec.capabilities.nested_spawn = false;
3096            spec.validate()
3097                .map_err(|_| AgentError::LLM("native_tool_ceiling_invalid".into()))?;
3098        }
3099        // Eligibility uses the actual Host callable ceiling, after the strict
3100        // built-in route and birth capability checks. A role label is not a grant.
3101        let first_reply_store = actor_directory_store.clone().filter(|_| {
3102            required_context.is_some()
3103                && crate::session_app::child_session::named_profile::has_named_profile(session)
3104                && matches!(spec.placement, Placement::Local)
3105                && matches!(spec.executor, ExecutorSpec::BambooRuntime)
3106                && spec.capabilities.native_tool_ceiling_required
3107                && spec
3108                    .capabilities
3109                    .native_tool_ceiling
3110                    .as_ref()
3111                    .is_some_and(|ceiling| {
3112                        ceiling.tools.is_empty()
3113                            || (spec.capabilities.read_only_enforced() && ceiling.tools == ["Glob"])
3114                    })
3115        });
3116        let plain_actor_store = if let Some(store) = first_reply_store {
3117            let root = bamboo_agent_core::storage::Storage::load_session(
3118                store.as_ref(),
3119                &session.root_session_id,
3120            )
3121            .await
3122            .map_err(|_| plain_actor_unsupported())?
3123            .ok_or_else(|| plain_actor_unsupported())?;
3124            if root.id != session.root_session_id || root.kind != bamboo_domain::SessionKind::Root {
3125                return Err(plain_actor_unsupported());
3126            }
3127            if root.root_orchestration_only_enabled() {
3128                if !plain_initial_history(session)
3129                    && (spec
3130                        .capabilities
3131                        .native_tool_ceiling
3132                        .as_ref()
3133                        .is_none_or(|ceiling| !ceiling.tools.is_empty())
3134                        || (!plain_completed_history(session) && pre_ack_input(session)?.is_none()))
3135                {
3136                    return Err(plain_actor_unsupported());
3137                }
3138                Some(store)
3139            } else if spec
3140                .capabilities
3141                .native_tool_ceiling
3142                .as_ref()
3143                .is_some_and(|ceiling| ceiling.tools == ["Glob"])
3144            {
3145                // Ordinary Glob remains legacy. Observe only its own mapped
3146                // initialization signals; never require a whole-tree snapshot.
3147                require_unowned_glob(store.as_ref(), session).await?;
3148                None
3149            } else {
3150                // Public bounded observation is only a no-replay check. It is
3151                // never an activation grant. Ordinary first runs stay legacy.
3152                let observed = store
3153                    .actor_subtree_snapshot(
3154                        ActorSnapshotPrincipal::host_owner(),
3155                        &session.root_session_id,
3156                        &session.id,
3157                        ActorSnapshotLimits::default(),
3158                    )
3159                    .await
3160                    .map_err(|_| plain_actor_unsupported())?;
3161                if observed
3162                    .nodes
3163                    .iter()
3164                    .any(|node| node.actor_id == session.id && node.activation.is_some())
3165                {
3166                    return Err(plain_actor_unsupported());
3167                }
3168                None
3169            }
3170        } else {
3171            None
3172        };
3173        let readonly_actor = plain_actor_store.is_some()
3174            && spec
3175                .capabilities
3176                .native_tool_ceiling
3177                .as_ref()
3178                .is_some_and(|ceiling| ceiling.tools == ["Glob"]);
3179        let local_history_tools = (plain_actor_store.is_none()
3180            && required_context.is_some()
3181            && (crate::session_app::child_session::named_profile::has_named_profile(session)
3182                || session
3183                    .metadata
3184                    .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY))
3185            && matches!(spec.placement, Placement::Local)
3186            && matches!(spec.executor, ExecutorSpec::BambooRuntime))
3187        .then_some(spec.capabilities.native_tool_ceiling.as_ref())
3188        .flatten()
3189        .filter(|ceiling| {
3190            bamboo_subagent::proto::LocalToolMessages::supports_tools(
3191                &ceiling.tools,
3192                spec.capabilities.read_only_enforced(),
3193            )
3194        })
3195        .map(|ceiling| ceiling.tools.clone());
3196        if plain_actor_store.is_some() && !readonly_actor {
3197            self.require_initial_input_release_worker().await?;
3198            spec.capabilities.initial_input_release_required = true;
3199            spec.validate().map_err(|_| plain_actor_unsupported())?;
3200        }
3201        let mut readonly_messages = Vec::new();
3202        let mut plain_activation = None;
3203        if spec.limits.idle_timeout_secs.is_none() {
3204            spec.limits.idle_timeout_secs = Some(POOLED_IDLE_TIMEOUT_SECS);
3205        }
3206        ensure_read_only_activation_is_enforceable(&spec)?;
3207        let pool_key = Self::fingerprint(&spec);
3208
3209        // The recommended provider URL is deliberately parent-loopback. A
3210        // resident remote worker would interpret 127.0.0.1 as itself, not this
3211        // server, so reject that ambiguous deployment instead of minting a
3212        // credential that can never authenticate to the intended parent.
3213        if executor_uses_bamboo_codex(&spec.executor) && !matches!(spec.placement, Placement::Local)
3214        {
3215            return Err(AgentError::LLM(
3216                "Codex auth mode 'bamboo' requires local actor placement; use custom mode with a reachable URL for remote workers"
3217                    .to_string(),
3218            ));
3219        }
3220        let project_id = project_id_for_actor_run(session)?;
3221        let requested_permission_mode = session
3222            .agent_runtime_state
3223            .as_ref()
3224            .map(|state| state.effective_permission_mode())
3225            .unwrap_or_default();
3226        // Policy is captured per activation (not only when a worker is
3227        // provisioned), so reused local workers and resident remote/broker
3228        // workers observe the latest durable revision and bypass flag at the
3229        // next run boundary. Session grants are intentionally not inherited.
3230        let permission_policy = if let Some(config) = self.permission_config.as_ref() {
3231            ensure_no_active_scoped_session_denies(config, &session.id)?;
3232            let read_only_overlay = session
3233                .agent_runtime_state
3234                .as_ref()
3235                .is_some_and(|state| state.plan_mode.is_some())
3236                || spec.capabilities.read_only_enforced()
3237                || executor_has_read_only_permission_profile(&spec.executor);
3238            let resolution = bamboo_domain::resolve_permission_mode_with_read_only(
3239                requested_permission_mode,
3240                config.mode(),
3241                read_only_overlay,
3242            );
3243            let policy = serde_json::to_value(config.to_serializable()).map_err(|error| {
3244                AgentError::LLM(format!(
3245                    "failed to serialize permission policy for external executor: {error}"
3246                ))
3247            })?;
3248            Some(PermissionPolicyContext {
3249                revision: config.policy_revision(),
3250                requested_mode: resolution.requested.as_str().to_string(),
3251                effective_mode: resolution.effective.as_str().to_string(),
3252                bypass_permissions: resolution.bypass_permissions(),
3253                auto_approve_permissions: resolution.suppress_approval_prompts(),
3254                session_id: session.id.clone(),
3255                workspace_path: session.workspace.clone(),
3256                environment_lease: None,
3257                inherit_session_grants: false,
3258                policy,
3259            })
3260        } else {
3261            None
3262        };
3263        let provisioned_permission =
3264            spec.capabilities.permission_resolution().map_err(|error| {
3265                AgentError::LLM(format!("invalid provisioned permission posture: {error}"))
3266            })?;
3267        let policy_resolution = permission_policy
3268            .as_ref()
3269            .map(|context| {
3270                context.resolved_modes().map(|(requested, effective)| {
3271                    bamboo_domain::PermissionModeResolution {
3272                        requested,
3273                        effective,
3274                    }
3275                })
3276            })
3277            .transpose()
3278            .map_err(|error| {
3279                AgentError::LLM(format!("invalid host permission posture: {error}"))
3280            })?;
3281        let host_audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata);
3282        let has_explicit_deny = self.permission_config.as_ref().is_some_and(|config| {
3283            bamboo_tools::permission::explicit_deny_policy_reason(&config.to_serializable())
3284                .is_some()
3285        });
3286        let expected_executor_mapping = expected_permission_executor_mapping(
3287            &spec.executor,
3288            policy_resolution.unwrap_or(provisioned_permission),
3289            has_explicit_deny,
3290        )?;
3291        let mut expected_permission_posture =
3292            expected_executor_mapping.map(|executor_mapping| ExpectedPermissionPosture {
3293                policy_revision: permission_policy
3294                    .as_ref()
3295                    .map(|context| context.revision)
3296                    .or_else(|| host_audit.as_ref().map(|audit| audit.policy_revision))
3297                    .unwrap_or_default(),
3298                resolution: policy_resolution.unwrap_or(provisioned_permission),
3299                expected_audit_revision: host_audit.as_ref().map(|audit| audit.audit_revision),
3300                executor_mapping,
3301            });
3302
3303        // Backpressure: hold a concurrency slot for the lifetime of the *run*
3304        // (cancellation still proceeds — the cancel branch in drive() runs while
3305        // we hold the permit). Released when this fn returns, i.e. once the worker
3306        // is parked back into the pool, so idle workers don't pin slots.
3307        let _slot = if strict_remote.is_some() {
3308            tokio::select! { biased;
3309                _ = cancel_token.cancelled() => return Err(AgentError::Cancelled),
3310                permit = self.concurrency.acquire() => permit.map_err(|_| remote_broker_unavailable())?,
3311            }
3312        } else {
3313            self.concurrency
3314                .acquire()
3315                .await
3316                .map_err(|_| AgentError::LLM("actor concurrency limiter closed".into()))?
3317        };
3318        let _remote_subscription = if strict_remote.is_some() {
3319            Some(tokio::select! { biased;
3320                _ = cancel_token.cancelled() => return Err(AgentError::Cancelled),
3321                guard = self.strict_remote_subscription.lock() => guard,
3322            })
3323        } else {
3324            None
3325        };
3326
3327        // Bamboo-as-provider credentials are minted at the activation boundary,
3328        // after backpressure admits the run and never at worker provisioning.
3329        // This is load-bearing for warm workers: a parked process must never
3330        // retain a token from its previous run. The guard revokes on every return
3331        // path (success, error, cancellation, dispatch failure, or first-frame
3332        // retry exhaustion).
3333        let (run_secrets, _codex_token_guard) = build_codex_run_secrets(
3334            &spec.executor,
3335            self.codex_run_tokens.clone(),
3336            &job.child_session_id,
3337        )?;
3338
3339        // Split LOCAL (spawn + warm-pool) from the two process-less remote paths
3340        // ONLY at the divergent spots — acquire/connect here and the park/retire at
3341        // the end. Everything between (Run dispatch, live-actor registration,
3342        // drive, the close) is identical for all three. `kind` is the single guard.
3343        //   - Local       (#0):  byte-for-byte the pre-#193 reuse-or-spawn path.
3344        //   - Remote       (#1311): connect to a scoped broker peer, no spawn.
3345        //   - Schedulable  (#181): resolve a live worker from the registry, connect.
3346        let kind = match spec.placement {
3347            Placement::Remote { .. } => PlacementKind::Remote,
3348            Placement::Schedulable { .. } => PlacementKind::Schedulable,
3349            Placement::Local => PlacementKind::Local,
3350        };
3351        let remote = !matches!(kind, PlacementKind::Local);
3352        let scoped_registry = if remote {
3353            let store = actor_directory_store
3354                .as_ref()
3355                .ok_or_else(remote_broker_unavailable)?;
3356            Some(
3357                bamboo_storage::v2::FileHostRegistry::new(store.bamboo_home_dir().to_path_buf())
3358                    .await
3359                    .map_err(|_| remote_broker_unavailable())?,
3360            )
3361        } else {
3362            None
3363        };
3364        let scoped_tools = scoped_route.as_ref().map(|route| {
3365            let mut tools = route.requirements.required_tools.clone();
3366            if let Some(ceiling) = &spec.capabilities.native_tool_ceiling {
3367                tools.extend(ceiling.tools.iter().cloned());
3368            }
3369            tools
3370        });
3371
3372        // Stamp WHICH machine this child runs on onto its session metadata, so the
3373        // UI can show it (mirrored into the session index → SessionSummary.placement).
3374        // Only remote/scheduled placements need a stamp — a Local child falls through
3375        // to the DTO default (this backend's own host). Persisted by the caller with
3376        // the rest of the child session after we return.
3377        if let Some(placement_meta) = self.placement_stamp_for(&spec) {
3378            session
3379                .metadata
3380                .insert("placement".to_string(), placement_meta);
3381        }
3382
3383        // Retry-once loop: a pooled local worker can die between its liveness
3384        // check and handling the Run (a tiny TOCTOU window) — its Run then sits
3385        // queued with no server. The first-frame watchdog in `drive` surfaces that
3386        // as `WorkerUnresponsive`; we reap the dead worker and re-acquire ONCE
3387        // (which spawns fresh / reuses the next live one). Remote/schedulable have
3388        // no spawn fallback, so they never retry.
3389        let mut attempt = 0u8;
3390        let (result, actor) = loop {
3391            let execution_epoch = self.next_execution_epoch.fetch_add(1, Ordering::Relaxed) + 1;
3392            let (actor, mut client) = match kind {
3393                PlacementKind::Remote => {
3394                    let route = scoped_route
3395                        .as_ref()
3396                        .ok_or_else(remote_broker_unavailable)?;
3397                    let mailbox_id = route
3398                        .pinned_mailbox
3399                        .clone()
3400                        .ok_or_else(remote_broker_unavailable)?;
3401                    let tls = bamboo_broker::client_config_trusting_cert(&route.ca_cert_file)
3402                        .map_err(|_| remote_broker_unavailable())?;
3403                    let connect =
3404                        bamboo_broker::BrokerChildLink::connect_strict_with_tls_environment_lease(
3405                            &route.endpoint,
3406                            route.parent.clone(),
3407                            &route.token,
3408                            bamboo_subagent::AgentRef {
3409                                session_id: mailbox_id.clone(),
3410                                role: Some(route.worker_role.clone()),
3411                            },
3412                            tls,
3413                        );
3414                    let link = tokio::select! { biased;
3415                        _ = cancel_token.cancelled() => return Err(AgentError::Cancelled),
3416                        result = tokio::time::timeout(Duration::from_secs(30), connect) =>
3417                            result.map_err(|_| remote_broker_unavailable())?.map_err(|_| remote_broker_unavailable())?,
3418                    };
3419                    let record = AgentRecord {
3420                        agent_id: job.child_session_id.clone(),
3421                        role: spec.identity.role.clone(),
3422                        labels: Vec::new(),
3423                        endpoint: String::new(),
3424                        pid: 0,
3425                        version: String::new(),
3426                        started_at: chrono::Utc::now(),
3427                        lease_expires_at: chrono::Utc::now(),
3428                    };
3429                    (
3430                        PooledWorker {
3431                            worker: SpawnedChild::remote(record),
3432                            mailbox_id,
3433                            parked_at: None,
3434                        },
3435                        Box::new(link) as Box<dyn bamboo_subagent::ChildLink>,
3436                    )
3437                }
3438                PlacementKind::Schedulable => {
3439                    let route = scoped_route
3440                        .as_ref()
3441                        .ok_or_else(remote_broker_unavailable)?;
3442                    let registry = scoped_registry
3443                        .as_ref()
3444                        .ok_or_else(remote_broker_unavailable)?;
3445                    let mailbox_id = self
3446                        .select_scoped_worker(
3447                            route,
3448                            registry,
3449                            &session.id,
3450                            project_id.clone(),
3451                            scoped_tools
3452                                .as_ref()
3453                                .ok_or_else(remote_broker_unavailable)?,
3454                        )
3455                        .await?;
3456                    let tls = bamboo_broker::client_config_trusting_cert(&route.ca_cert_file)
3457                        .map_err(|_| remote_broker_unavailable())?;
3458                    let connect =
3459                        bamboo_broker::BrokerChildLink::connect_strict_with_tls_environment_lease(
3460                            &route.endpoint,
3461                            route.parent.clone(),
3462                            &route.token,
3463                            bamboo_subagent::AgentRef {
3464                                session_id: mailbox_id.clone(),
3465                                role: Some(route.worker_role.clone()),
3466                            },
3467                            tls,
3468                        );
3469                    let link = tokio::select! { biased;
3470                        _ = cancel_token.cancelled() => return Err(AgentError::Cancelled),
3471                        result = tokio::time::timeout(Duration::from_secs(30), connect) =>
3472                            result.map_err(|_| remote_broker_unavailable())?.map_err(|_| remote_broker_unavailable())?,
3473                    };
3474                    let actor = PooledWorker {
3475                        worker: SpawnedChild::remote(AgentRecord {
3476                            agent_id: mailbox_id.clone(),
3477                            role: spec.identity.role.clone(),
3478                            labels: Vec::new(),
3479                            endpoint: route.endpoint.clone(),
3480                            pid: 0,
3481                            version: String::new(),
3482                            started_at: chrono::Utc::now(),
3483                            lease_expires_at: chrono::Utc::now(),
3484                        }),
3485                        mailbox_id,
3486                        parked_at: None,
3487                    };
3488                    let client: Box<dyn bamboo_subagent::ChildLink> = Box::new(link);
3489                    (actor, client)
3490                }
3491                PlacementKind::Local => {
3492                    // LOCAL = the mailbox bus (the unified transport): check out a warm
3493                    // pooled worker (reuse a live parked one, else spawn fresh) and
3494                    // drive it by mailbox id — no listen socket, no file discovery, no
3495                    // respawn-on-connect-miss (the broker queues the Run until the
3496                    // worker handles it). The legacy direct-WS path was retired; the bus
3497                    // is required.
3498                    let bus = self.bus.as_ref().ok_or_else(|| {
3499                        AgentError::LLM(
3500                            "local sub-agents require a mailbox bus (subagents.broker); none is \
3501                         configured and the bus could not be embedded"
3502                                .to_string(),
3503                        )
3504                    })?;
3505                    let actor = self.acquire_bus_worker(&pool_key, &spec).await?;
3506                    let parent = bamboo_subagent::AgentRef {
3507                        session_id: format!("p-{}", job.child_session_id),
3508                        role: None,
3509                    };
3510                    let link = bamboo_broker::BrokerChildLink::connect(
3511                        &bus.endpoint,
3512                        parent,
3513                        &bus.token,
3514                        actor.mailbox_id.clone(),
3515                    )
3516                    .await
3517                    .map_err(|e| {
3518                        AgentError::LLM(format!("broker child link connect failed: {e}"))
3519                    })?;
3520                    let client: Box<dyn bamboo_subagent::ChildLink> = Box::new(link);
3521                    (actor, client)
3522                }
3523            };
3524
3525            // Recover an older Run's already Host-checkpointed Event/Outcome
3526            // receipts before dispatching a successor. The private V2 ledger
3527            // binds exact Child birth, Run, transcript prefix and broker
3528            // Maildir identity. A different broker or an unproven final save
3529            // blocks this activation instead of deleting the wrong mailbox.
3530            if let Some(store) = actor_directory_store.as_ref() {
3531                let recovery = async {
3532                    let receipts = store
3533                        .recover_broker_terminal_receipts(session)
3534                        .await
3535                        .map_err(|error| {
3536                            format!("Host broker receipt recovery blocked: {error}")
3537                        })?;
3538                    for receipt in receipts {
3539                        // This path already owns a successor Run and its public
3540                        // running marker. Old committed receipts authorize ACK
3541                        // only; never publish the predecessor over that owner.
3542                        if client.broker_parent_mailbox() != Some(receipt.parent_mailbox.as_str()) {
3543                            return Err(
3544                                "old Child broker parent mailbox changed before ACK".to_string()
3545                            );
3546                        }
3547                        client
3548                            .acknowledge_recovered_durable_frames(
3549                                &receipt.broker_identity,
3550                                &receipt.message_ids,
3551                            )
3552                            .await
3553                            .map_err(|error| {
3554                                format!("old Child broker ACK unconfirmed: {error}")
3555                            })?;
3556                        store
3557                            .clear_acknowledged_broker_terminal_receipt(
3558                                &receipt.session_id,
3559                                receipt.created_at,
3560                                &receipt.activation_run_id,
3561                                &receipt.parent_mailbox,
3562                            )
3563                            .await
3564                            .map_err(|error| {
3565                                format!("old Child broker ACK cleanup failed: {error}")
3566                            })?;
3567                    }
3568                    Ok::<(), String>(())
3569                }
3570                .await;
3571                if let Err(error) = recovery {
3572                    if !remote {
3573                        actor.worker.kill().await;
3574                    }
3575                    return Err(AgentError::LLM(error));
3576                }
3577            }
3578
3579            // Publish the actor delivery owner and claim the complete bounded
3580            // authorized prefix before dispatching Run. These deliveries ride
3581            // inside RunSpec, so the worker durably enqueues them before its
3582            // first provider boundary rather than racing a later steer frame.
3583            let (delivery_tx, mut delivery_rx) = mpsc::unbounded_channel::<u64>();
3584            let bound_activation_run_id = match session_inbox_runtime.as_ref() {
3585                Some(binding) => {
3586                    let run_id = binding
3587                        .router
3588                        .attach_delivery_sink(&job.child_session_id, delivery_tx.clone())
3589                        .await;
3590                    if run_id.is_none() {
3591                        tracing::debug!(
3592                            session_id = %job.child_session_id,
3593                            "actor driver had no current SessionInbox activation owner to bind"
3594                        );
3595                    }
3596                    run_id
3597                }
3598                None => None,
3599            };
3600            tracing::Span::current().record(
3601                "activation_run_id",
3602                bound_activation_run_id.as_deref().unwrap_or("unbound"),
3603            );
3604            drop(delivery_tx);
3605            let initial_pairs = match (
3606                session_inbox_runtime.as_ref(),
3607                bound_activation_run_id.as_deref(),
3608            ) {
3609                (Some(binding), Some(run_id)) if plain_actor_store.is_none() && !remote => {
3610                    match claim_canonical_deliveries(binding, session, run_id, usize::MAX, None)
3611                        .await
3612                    {
3613                        Ok(deliveries) => deliveries,
3614                        Err(error) => {
3615                            binding
3616                                .router
3617                                .detach_delivery_sink(&job.child_session_id, run_id)
3618                                .await;
3619                            if !remote {
3620                                actor.worker.kill().await;
3621                            }
3622                            return Err(error);
3623                        }
3624                    }
3625                }
3626                _ => Vec::new(),
3627            };
3628            let mut initial_session_messages = initial_pairs
3629                .iter()
3630                .map(|(_, delivery)| delivery.clone())
3631                .collect::<Vec<_>>();
3632            let mut initial_inflight_claims = initial_pairs
3633                .into_iter()
3634                .map(|(claim, _)| claim)
3635                .collect::<VecDeque<_>>();
3636            // Recompute after claim reconciliation: a warm retry may have had a
3637            // canonical receipt whose transcript proof was restored above.
3638            let mut messages = if let Some(binding) = &required_context {
3639                binding
3640                    .validate_messages(&session.id, &session.messages)
3641                    .map_err(|error| AgentError::Budget(error.to_string()))?;
3642                session
3643                    .messages
3644                    .iter()
3645                    .map(serde_json::to_value)
3646                    .collect::<std::result::Result<Vec<_>, _>>()
3647                    .map_err(|_| {
3648                        AgentError::Budget(
3649                            bamboo_domain::ChildContextPacketError::Invalid.to_string(),
3650                        )
3651                    })?
3652            } else {
3653                session
3654                    .messages
3655                    .iter()
3656                    .filter_map(|message| serde_json::to_value(message).ok())
3657                    .collect()
3658            };
3659
3660            if strict_remote.is_some() {
3661                let current =
3662                    remote_canonical_lineage(session_inbox_runtime.as_ref().unwrap(), session)
3663                        .await;
3664                if current.as_ref().ok() != lineage.as_ref()
3665                    || bound_activation_run_id.as_deref().is_none_or(str::is_empty)
3666                    || cancel_token.is_cancelled()
3667                {
3668                    if let (Some(binding), Some(run_id)) = (
3669                        session_inbox_runtime.as_ref(),
3670                        bound_activation_run_id.as_deref(),
3671                    ) {
3672                        binding
3673                            .router
3674                            .detach_delivery_sink(&job.child_session_id, run_id)
3675                            .await;
3676                    }
3677                    return Err(if cancel_token.is_cancelled() {
3678                        AgentError::Cancelled
3679                    } else {
3680                        remote_broker_unavailable()
3681                    });
3682                }
3683            }
3684            if let Some(expected) = creation.as_ref() {
3685                let checked =
3686                    canonical_child_creation(session_inbox_runtime.as_ref().unwrap(), session, job)
3687                        .await;
3688                if checked.as_ref().ok() != Some(expected) {
3689                    if let (Some(binding), Some(run_id)) = (
3690                        session_inbox_runtime.as_ref(),
3691                        bound_activation_run_id.as_deref(),
3692                    ) {
3693                        binding
3694                            .router
3695                            .detach_delivery_sink(&job.child_session_id, run_id)
3696                            .await;
3697                    }
3698                    actor.worker.kill().await;
3699                    return Err(AgentError::LLM(
3700                        "child creation identity changed before dispatch".into(),
3701                    ));
3702                }
3703            }
3704            if let Some(store) = &plain_actor_store {
3705                let started = async {
3706                    // The Run posture was resolved from Host configuration
3707                    // before provisioning. Bind that exact revision and the
3708                    // Host-selected local placement before claiming authority;
3709                    // a worker event or display placement cannot supply either.
3710                    let policy = permission_policy
3711                        .as_ref()
3712                        .ok_or_else(|| plain_actor_unsupported())?;
3713                    let config = self
3714                        .permission_config
3715                        .as_ref()
3716                        .ok_or_else(|| plain_actor_unsupported())?;
3717                    if config.policy_revision() != policy.revision
3718                        || !matches!(spec.placement, Placement::Local)
3719                    {
3720                        return Err(plain_actor_unsupported());
3721                    }
3722                    store
3723                        .bind_control_plane(
3724                            &session.id,
3725                            bamboo_domain::ActorControlPlaneBinding {
3726                                policy_revision: Some(policy.revision),
3727                                placement_intent: Some(bamboo_domain::ActorPlacementIntent::Local),
3728                            },
3729                        )
3730                        .await
3731                        .map_err(|error| AgentError::LLM(error.to_string()))?;
3732                    PlainActorActivation::start_and_prepare(
3733                        store.clone(),
3734                        session,
3735                        session_inbox_runtime.as_ref().unwrap(),
3736                        bound_activation_run_id.as_deref(),
3737                        self.permission_config.clone(),
3738                        !readonly_actor,
3739                        spec.capabilities
3740                            .initial_input_release_required
3741                            .then_some(actor.mailbox_id.as_str()),
3742                    )
3743                    .await
3744                }
3745                .await;
3746                match started {
3747                    Ok((activation, prepared)) => {
3748                        if let Some((prefix, delivery)) = prepared {
3749                            messages = prefix;
3750                            initial_session_messages = vec![delivery];
3751                        }
3752                        plain_activation = Some(activation);
3753                    }
3754                    Err(error) => {
3755                        if let Some(run_id) = bound_activation_run_id.as_deref() {
3756                            session_inbox_runtime
3757                                .as_ref()
3758                                .unwrap()
3759                                .router
3760                                .detach_delivery_sink(&job.child_session_id, run_id)
3761                                .await;
3762                        }
3763                        actor.worker.kill().await;
3764                        return Err(error);
3765                    }
3766                }
3767            }
3768            let mut logical_identity = logical_identity_for_actor_run(session, job);
3769            logical_identity.creation = creation.clone();
3770            let run_permission_policy = if strict_remote.is_some() {
3771                let prepared = async {
3772                    let mut policy = permission_policy.clone().ok_or_else(|| {
3773                        AgentError::LLM("remote_environment_permission_policy_missing".into())
3774                    })?;
3775                    let workspace = session.workspace.as_deref().ok_or_else(|| {
3776                        AgentError::LLM("remote_environment_workspace_missing".into())
3777                    })?;
3778                    let run_id = bound_activation_run_id.as_deref().ok_or_else(|| {
3779                        AgentError::LLM("remote_environment_activation_missing".into())
3780                    })?;
3781                    let lease = bamboo_subagent::environment::EnvironmentLease::capture(
3782                        workspace,
3783                        &session.id,
3784                        run_id,
3785                        execution_epoch,
3786                    )
3787                    .await
3788                    .map_err(|error| AgentError::LLM(error.into()))?;
3789                    policy.workspace_path = None;
3790                    policy.environment_lease = Some(lease);
3791                    Ok::<_, AgentError>(Some(policy))
3792                }
3793                .await;
3794                match prepared {
3795                    Ok(policy) => policy,
3796                    Err(error) => {
3797                        if let (Some(binding), Some(run_id)) = (
3798                            session_inbox_runtime.as_ref(),
3799                            bound_activation_run_id.as_deref(),
3800                        ) {
3801                            binding
3802                                .router
3803                                .detach_delivery_sink(&job.child_session_id, run_id)
3804                                .await;
3805                        }
3806                        return Err(error);
3807                    }
3808                }
3809            } else {
3810                permission_policy.clone()
3811            };
3812            if session
3813                .metadata
3814                .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
3815            {
3816                let admission = async {
3817                    let service = ticket_service
3818                        .as_ref()
3819                        .ok_or_else(|| AgentError::LLM("Ticket authority unavailable".into()))?;
3820                    let runtime = session_inbox_runtime.as_ref().ok_or_else(|| {
3821                        AgentError::LLM("Ticket activation runtime unavailable".into())
3822                    })?;
3823                    let run_id = bound_activation_run_id
3824                        .as_deref()
3825                        .ok_or_else(|| AgentError::LLM("Ticket activation owner missing".into()))?;
3826                    crate::ticket_runtime::admit_ticket_run(service, runtime, session, run_id)
3827                        .await
3828                        .map_err(|e| AgentError::LLM(e.to_string()))
3829                }
3830                .await;
3831                if let Err(error) = admission {
3832                    if let (Some(runtime), Some(run_id)) = (
3833                        session_inbox_runtime.as_ref(),
3834                        bound_activation_run_id.as_deref(),
3835                    ) {
3836                        runtime
3837                            .router
3838                            .detach_delivery_sink(&job.child_session_id, run_id)
3839                            .await;
3840                    }
3841                    actor.worker.kill().await;
3842                    return Err(error);
3843                }
3844            }
3845            let mut run_spec = RunSpec {
3846                // Cloned (not moved) so a retry can re-dispatch to a fresh worker.
3847                assignment: assignment.clone(),
3848                logical_session: Some(logical_identity),
3849                project_id: project_id.clone(),
3850                // Creation/update already resolves the child's explicit
3851                // selection and own role preference into this field. Do
3852                // not infer effort from a possibly inherited model_ref.
3853                reasoning_effort: if !remote && matches!(self.executor, ExecutorSpec::BambooRuntime)
3854                {
3855                    session
3856                        .reasoning_effort
3857                        .map(|effort| effort.as_str().to_owned())
3858                } else {
3859                    None
3860                },
3861                permission_policy: run_permission_policy,
3862                messages,
3863                activation_run_id: bound_activation_run_id.clone(),
3864                execution_epoch,
3865                initial_session_messages,
3866                secrets: run_secrets.clone(),
3867            };
3868            let mut active_lease = if remote {
3869                let reserve = ActivePlacementLease::reserve(
3870                    scoped_registry
3871                        .as_ref()
3872                        .ok_or_else(remote_broker_unavailable)?
3873                        .clone(),
3874                    actor_directory_store
3875                        .as_ref()
3876                        .ok_or_else(remote_broker_unavailable)?
3877                        .clone(),
3878                    scoped_route
3879                        .as_ref()
3880                        .ok_or_else(remote_broker_unavailable)?
3881                        .clone(),
3882                    actor.mailbox_id.clone(),
3883                    session.id.clone(),
3884                    bound_activation_run_id
3885                        .clone()
3886                        .ok_or_else(remote_broker_unavailable)?,
3887                    project_id.clone(),
3888                    scoped_tools.clone().ok_or_else(remote_broker_unavailable)?,
3889                    &cancel_token,
3890                )
3891                .await;
3892                match reserve {
3893                    Ok(lease) => Some(lease),
3894                    Err(error) => {
3895                        if let (Some(binding), Some(run_id)) = (
3896                            session_inbox_runtime.as_ref(),
3897                            bound_activation_run_id.as_deref(),
3898                        ) {
3899                            binding
3900                                .router
3901                                .detach_delivery_sink(&job.child_session_id, run_id)
3902                                .await;
3903                        }
3904                        return Err(error);
3905                    }
3906                }
3907            } else {
3908                None
3909            };
3910            if let Some(lease) = active_lease.as_mut() {
3911                let seeded = async {
3912                    let binding = session_inbox_runtime
3913                        .as_ref()
3914                        .ok_or_else(remote_broker_unavailable)?;
3915                    let run_id = bound_activation_run_id
3916                        .as_deref()
3917                        .ok_or_else(remote_broker_unavailable)?;
3918                    let pairs = claim_canonical_deliveries(
3919                        binding,
3920                        session,
3921                        run_id,
3922                        usize::MAX,
3923                        Some((lease.actor_store.as_ref(), &lease.fence)),
3924                    )
3925                    .await?;
3926                    if let Some(binding) = &required_context {
3927                        binding
3928                            .validate_messages(&session.id, &session.messages)
3929                            .map_err(|error| AgentError::Budget(error.to_string()))?;
3930                    }
3931                    run_spec.messages = session
3932                        .messages
3933                        .iter()
3934                        .map(serde_json::to_value)
3935                        .collect::<std::result::Result<Vec<_>, _>>()
3936                        .map_err(|_| remote_broker_unavailable())?;
3937                    run_spec.initial_session_messages =
3938                        pairs.iter().map(|(_, delivery)| delivery.clone()).collect();
3939                    initial_inflight_claims = pairs
3940                        .into_iter()
3941                        .map(|(claim, _)| claim)
3942                        .collect::<VecDeque<_>>();
3943                    Ok::<(), AgentError>(())
3944                }
3945                .await;
3946                if let Err(error) = seeded {
3947                    if let Err(rollback_error) = lease.abort_before_dispatch().await {
3948                        tracing::warn!(stage = "claim_pre_dispatch_rollback", reason = %rollback_error, "remote claim rollback failed");
3949                    }
3950                    if let (Some(binding), Some(run_id)) = (
3951                        session_inbox_runtime.as_ref(),
3952                        bound_activation_run_id.as_deref(),
3953                    ) {
3954                        binding
3955                            .router
3956                            .detach_delivery_sink(&job.child_session_id, run_id)
3957                            .await;
3958                    }
3959                    return Err(error);
3960                }
3961            }
3962            let mut run_send_invoked = false;
3963            let dispatch = async {
3964                if let Some(activation) = plain_activation
3965                    .as_ref()
3966                    .filter(|activation| activation.initial_input.is_some())
3967                {
3968                    activation.refresh_continuation_posture(
3969                        session,
3970                        &run_spec,
3971                        &mut expected_permission_posture,
3972                    )?;
3973                }
3974                send_actor_run(&mut *client, run_spec.clone(), &mut run_send_invoked).await
3975            }
3976            .await;
3977            if let Err(e) = dispatch {
3978                if remote {
3979                    tracing::warn!(stage = "run_dispatch", "remote placement admission failed");
3980                }
3981                if let (Some(binding), Some(run_id)) = (
3982                    session_inbox_runtime.as_ref(),
3983                    bound_activation_run_id.as_deref(),
3984                ) {
3985                    binding
3986                        .router
3987                        .detach_delivery_sink(&job.child_session_id, run_id)
3988                        .await;
3989                }
3990                if !remote {
3991                    actor.worker.kill().await;
3992                }
3993                if let Some(activation) = &plain_activation {
3994                    activation.finish(ActorActivationFinish::Failed).await?;
3995                }
3996                if let Some(lease) = active_lease.as_mut() {
3997                    match lease.abort_if_run_unsent(run_send_invoked).await {
3998                        Ok(false) => {
3999                            tracing::warn!(stage = "run_dispatch_unconfirmed", "remote Run send failed after publication became possible; retaining Actor activation and slot until terminal proof or expiry");
4000                        }
4001                        Ok(true) => {}
4002                        Err(rollback_error) => {
4003                            tracing::warn!(stage = "run_pre_dispatch_rollback", reason = %rollback_error, "remote Run rollback failed");
4004                        }
4005                    }
4006                }
4007                return Err(e);
4008            }
4009
4010            // Register as a live actor so send_message (running, no interrupt) can
4011            // steer this child in-band over the existing WS connection. The guard
4012            // unregisters on every exit path.
4013            let (live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
4014            let live_guard = super::live::register(
4015                &job.child_session_id,
4016                live_tx,
4017                attempt as u32,
4018                self.approval_registry.clone(),
4019            );
4020
4021            let mut strict_history = None;
4022            let result = drive(ActorDriveContext {
4023                client: &mut *client,
4024                parent_session_id: &job.parent_session_id,
4025                child_session_id: &job.child_session_id,
4026                child_attempt: attempt as u32,
4027                approval_registry: self.approval_registry.as_ref(),
4028                approval_decider: self.approval_decider.as_ref(),
4029                approval_reviewer: self.approval_reviewer.as_ref(),
4030                escalation_bridge: escalation.clone(),
4031                event_tx: &event_tx,
4032                cancel_token: active_lease
4033                    .as_ref()
4034                    .map_or(&cancel_token, |lease| &lease.run_cancel),
4035                live_rx: &mut live_rx,
4036                delivery_rx: &mut delivery_rx,
4037                logical_session: session,
4038                expected_permission_posture: expected_permission_posture.clone(),
4039                expected_creation: creation.as_ref(),
4040                session_inbox_runtime: session_inbox_runtime.as_ref(),
4041                actor_directory_store: actor_directory_store.as_deref(),
4042                canonical_subagent_tool: canonical_subagent_tool.clone(),
4043                ticket_service: ticket_service.clone(),
4044                activation_run_id: bound_activation_run_id.as_deref(),
4045                execution_epoch,
4046                expected_source_actor_id: &actor.mailbox_id,
4047                initial_inflight_claims,
4048                // First-frame watchdog for EVERY placement: a wedged-but-connected
4049                // worker (subscribed ≠ serving — e.g. stuck on a prior LLM call) emits
4050                // no first frame; without a deadline drive() blocks forever. Bounding it
4051                // turns the "running-but-unresponsive" hang into a recoverable
4052                // WorkerUnresponsive (reap+respawn local / re-pick schedulable / error
4053                // on a fixed remote endpoint).
4054                plain_actor: plain_activation.is_some(),
4055                remote_environment_lease: strict_remote.is_some(),
4056                readonly_output: if readonly_actor {
4057                    Some(&mut readonly_messages)
4058                } else {
4059                    None
4060                },
4061                local_history_tools: local_history_tools.as_deref(),
4062                local_history_read_only: spec.capabilities.read_only_enforced(),
4063                strict_history_output: Some(&mut strict_history),
4064                plain_input: plain_activation.as_ref(),
4065                canonical_activation: active_lease.as_ref().map(|lease| &lease.fence),
4066                canonical_placement_ref: active_lease.as_ref().map(|lease| &lease.placement_ref),
4067                actor_event_observer: actor_event_observer.as_deref(),
4068                // BrokerChildLink replaces the actual correlation for each Run;
4069                // direct legacy WS Terminal frames carry no such identity.
4070                plain_run: self
4071                    .bus
4072                    .as_ref()
4073                    .map(|_| (&run_spec, &self.next_execution_epoch)),
4074                first_frame_timeout: Some(WORKER_FIRST_FRAME_TIMEOUT),
4075            })
4076            .await;
4077            let mut result = if active_lease
4078                .as_ref()
4079                .is_some_and(ActivePlacementLease::lost)
4080            {
4081                Err(remote_broker_unavailable())
4082            } else {
4083                result
4084            };
4085            if local_history_tools.is_none() {
4086                let reply = result
4087                    .as_ref()
4088                    .ok()
4089                    .and_then(|reply| reply.as_deref())
4090                    .filter(|reply| !reply.is_empty())
4091                    .map(str::to_owned);
4092                if let (Some(lease), Some(reply)) = (active_lease.as_ref(), reply) {
4093                    if let Err(error) = append_fenced_remote_reply(
4094                        lease.actor_store.as_ref(),
4095                        &lease.fence,
4096                        session,
4097                        &reply,
4098                    )
4099                    .await
4100                    {
4101                        result = Err(error);
4102                    }
4103                }
4104            }
4105            if remote {
4106                if let Err(error) = &result {
4107                    let code = match error {
4108                        AgentError::LLM(message) if message.starts_with("actor event") => {
4109                            "actor_event"
4110                        }
4111                        AgentError::LLM(message) if message.starts_with("remote_actor") => {
4112                            "remote_actor"
4113                        }
4114                        AgentError::LLM(message) if message.starts_with("remote_environment") => {
4115                            if message.len() <= 128
4116                                && message.bytes().all(|byte| {
4117                                    byte.is_ascii_lowercase()
4118                                        || byte.is_ascii_digit()
4119                                        || byte == b'_'
4120                                })
4121                            {
4122                                message.as_str()
4123                            } else {
4124                                "environment"
4125                            }
4126                        }
4127                        AgentError::LLM(message) if message.contains("permission posture") => {
4128                            "permission_posture"
4129                        }
4130                        AgentError::LLM(message) if message.contains("broker") => "broker",
4131                        AgentError::LLM(_) => "other_llm",
4132                        AgentError::WorkerUnresponsive(_) => "unresponsive",
4133                        AgentError::Cancelled => "cancelled",
4134                        _ => "other",
4135                    };
4136                    tracing::warn!(stage = "actor_drive", code, "remote actor run failed");
4137                }
4138            }
4139            if let Some(lease) = active_lease.as_mut() {
4140                let accepted_terminal = client
4141                    .durable_delivery_receipt()
4142                    .map(|receipt| receipt.terminal_status);
4143                if let Err(error) = lease
4144                    .complete(
4145                        remote_activation_finish(&result, accepted_terminal),
4146                        client.has_pending_durable_terminal(),
4147                    )
4148                    .await
4149                {
4150                    tracing::warn!(
4151                        stage = "actor_complete",
4152                        "remote placement admission failed"
4153                    );
4154                    if let (Some(binding), Some(run_id)) = (
4155                        session_inbox_runtime.as_ref(),
4156                        bound_activation_run_id.as_deref(),
4157                    ) {
4158                        binding
4159                            .router
4160                            .detach_delivery_sink(&job.child_session_id, run_id)
4161                            .await;
4162                    }
4163                    drop(live_guard);
4164                    drop(client); // No Host checkpoint proof means no broker ACK.
4165                    drop(active_lease);
4166                    return Err(error);
4167                }
4168            }
4169            drop(active_lease);
4170            if let (Some(binding), Some(run_id)) = (
4171                session_inbox_runtime.as_ref(),
4172                bound_activation_run_id.as_deref(),
4173            ) {
4174                binding
4175                    .router
4176                    .detach_delivery_sink(&job.child_session_id, run_id)
4177                    .await;
4178            }
4179            // Unregister IMMEDIATELY: after drive returns nobody consumes live_rx,
4180            // so a send_message landing in the close/park window below must see
4181            // "not live" and take the durable-queue fallback instead of vanishing.
4182            // (Even if one slipped in earlier, send_message also appends it to the
4183            // durable transcript, so the next activation still rehydrates it.)
4184            drop(live_guard);
4185            // Keep only a fully processed broker terminal's exact mailbox
4186            // receipts alive until the SDK has saved this Child's canonical
4187            // transcript and final status. No run fence means no Host proof.
4188            if client.has_pending_durable_terminal() {
4189                if let Some(run_id) = bound_activation_run_id.as_deref() {
4190                    let key = (session.id.clone(), session.created_at, run_id.to_owned());
4191                    let host_nested_wait_handoff = session
4192                        .metadata
4193                        .get("runtime.actor_nested_wait_handoff")
4194                        .is_some_and(|value| value == "true");
4195                    let mut pending = self.pending_durable_links.lock().await;
4196                    if pending.contains_key(&key) {
4197                        return Err(AgentError::LLM(
4198                            "child broker terminal receipt already awaits Host checkpoint".into(),
4199                        ));
4200                    }
4201                    pending.insert(
4202                        key,
4203                        PendingDurableChildLink {
4204                            link: client,
4205                            host_nested_wait_handoff,
4206                            strict_history,
4207                            strict_history_required: local_history_tools.is_some(),
4208                            strict_history_committed: false,
4209                        },
4210                    );
4211                } else {
4212                    // A Worker-local transcript is not Host checkpoint proof.
4213                    drop(client);
4214                }
4215            } else {
4216                drop(client);
4217            }
4218
4219            // Retry only a local worker that we can kill. A silent remote
4220            // Worker may still be executing its first Run; starting the same
4221            // Actor on another Host before a terminal fence would duplicate it.
4222            if required_context.is_none()
4223                && attempt == 0
4224                && matches!(kind, PlacementKind::Local)
4225                && matches!(result, Err(AgentError::WorkerUnresponsive(_)))
4226            {
4227                tracing::warn!(
4228                    "actor child {} got no first frame; reaping the worker and respawning once",
4229                    job.child_session_id
4230                );
4231                actor.worker.kill().await;
4232                attempt += 1;
4233                continue;
4234            }
4235            break (result, actor);
4236        };
4237
4238        let result = if strict_remote.is_some() {
4239            result.map_err(|error| match &error {
4240                AgentError::Cancelled => error,
4241                AgentError::LLM(message) => remote_environment_error_code(message)
4242                    .map(|code| AgentError::LLM(code.to_owned()))
4243                    .unwrap_or_else(remote_broker_unavailable),
4244                _ => remote_broker_unavailable(),
4245            })
4246        } else {
4247            result
4248        };
4249
4250        // Park the warm worker for reuse on a clean run, or kill it on
4251        // error/cancel (a wedged worker must not be reused). Remote / schedulable
4252        // workers are registry-managed — never ours to pool/kill, just drop.
4253        if remote {
4254            drop(actor);
4255        } else if required_context.is_some() {
4256            if session
4257                .metadata
4258                .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
4259            {
4260                let service = self.ticket_service.lock().recover_poison().clone();
4261                let runtime = self.session_inbox_runtime.lock().recover_poison().clone();
4262                let owned_run_id = crate::ticket_runtime::read_dispatch(session)
4263                    .ok()
4264                    .flatten()
4265                    .and_then(|dispatch| dispatch.receipt.map(|receipt| receipt.run_id));
4266                match actor.worker.kill_confirmed().await {
4267                    Ok(proof) => {
4268                        if let (Some(service), Some(runtime), Some(run_id)) =
4269                            (service, runtime, owned_run_id.as_deref())
4270                        {
4271                            if let Err(error) = crate::ticket_runtime::checkpoint_owned_ticket_stop(
4272                                &service,
4273                                &runtime,
4274                                session,
4275                                run_id,
4276                                &proof,
4277                                result.is_ok(),
4278                            )
4279                            .await
4280                            {
4281                                // The canonical terminal save can retain output
4282                                // and retry this stop fact before broker ACK.
4283                                tracing::warn!(child_id = %session.id, %error, "Ticket owned-process stop publication retained for reconciliation");
4284                            }
4285                        }
4286                    }
4287                    Err(error) => {
4288                        tracing::warn!(child_id = %session.id, %error, "Ticket process stop remains unconfirmed; resources retained");
4289                    }
4290                }
4291            } else {
4292                actor.worker.kill().await;
4293            }
4294        } else {
4295            match &result {
4296                Ok(_) => self.release_bus_worker(&pool_key, actor).await,
4297                Err(_) => actor.worker.kill().await,
4298            }
4299        }
4300
4301        if let Some(activation) = plain_activation {
4302            return match result {
4303                Ok(Some(text)) if !text.is_empty() => {
4304                    if readonly_actor {
4305                        let messages = std::mem::take(&mut readonly_messages);
4306                        let committed = activation
4307                            .store
4308                            .append_actor_transcript(bamboo_storage::ActorTranscriptAppend {
4309                                fence: activation.fence.clone(),
4310                                expected_created_at: activation.created_at,
4311                                expected_messages: session.messages.clone(),
4312                                expected_provider_transcript: activation
4313                                    .provider_transcript
4314                                    .clone(),
4315                                messages: messages.clone(),
4316                                native_groups: Vec::new(),
4317                            })
4318                            .await;
4319                        return match committed {
4320                            Ok(committed) => {
4321                                *session = committed;
4322                                for message in messages {
4323                                    let _ = event_tx
4324                                        .send(AgentEvent::message_appended(&session.id, &message))
4325                                        .await;
4326                                }
4327                                activation
4328                                    .finish_with_observer(
4329                                        ActorActivationFinish::Succeeded,
4330                                        actor_event_observer.as_deref(),
4331                                    )
4332                                    .await
4333                            }
4334                            Err(error) => {
4335                                let _ = activation.finish(ActorActivationFinish::Failed).await;
4336                                Err(AgentError::LLM(format!(
4337                                    "actor reply commit failed: {error}"
4338                                )))
4339                            }
4340                        };
4341                    }
4342                    match activation.append_reply(session, text, &event_tx).await {
4343                        Ok(()) => {
4344                            activation
4345                                .finish_with_observer(
4346                                    ActorActivationFinish::Succeeded,
4347                                    actor_event_observer.as_deref(),
4348                                )
4349                                .await
4350                        }
4351                        Err(error) => {
4352                            // OutcomeUnconfirmed is an error, never ordinary-save fallback.
4353                            let _ = activation.finish(ActorActivationFinish::Failed).await;
4354                            Err(AgentError::LLM(format!(
4355                                "actor reply commit failed: {error}"
4356                            )))
4357                        }
4358                    }
4359                }
4360                other => {
4361                    let outcome = if matches!(&other, Err(AgentError::Cancelled)) {
4362                        ActorActivationFinish::Cancelled
4363                    } else {
4364                        ActorActivationFinish::Failed
4365                    };
4366                    activation.finish(outcome).await?;
4367                    match other {
4368                        Err(error) => Err(error),
4369                        _ => Err(plain_actor_unsupported()),
4370                    }
4371                }
4372            };
4373        }
4374
4375        // Write-back: persist the actor's final reply onto the child session so
4376        // the transcript survives and the NEXT activation sees it as history.
4377        // (run_child_spawn saves the session right after we return.)
4378        match result {
4379            Ok(Some(text)) => {
4380                if !remote && local_history_tools.is_none() && !text.is_empty() {
4381                    session.add_message(bamboo_agent_core::Message::assistant(text, None));
4382                }
4383                Ok(())
4384            }
4385            Ok(None) => Ok(()),
4386            Err(e) => Err(e),
4387        }
4388    }
4389}
4390
4391// This is a conservative no-replay observation, not Actor authority. The
4392// canonical mapping comes from Storage; record/marker names match its writer.
4393async fn require_unowned_glob(
4394    store: &bamboo_storage::SessionStoreV2,
4395    session: &Session,
4396) -> Result<(), AgentError> {
4397    let rel = store
4398        .resolve_rel_path(&session.id)
4399        .await
4400        .ok_or_else(|| plain_actor_unsupported())?;
4401    if !store.bamboo_home_dir().is_absolute()
4402        || rel
4403            != format!(
4404                "sessions/{}/children/{}",
4405                session.root_session_id, session.id
4406            )
4407    {
4408        return Err(plain_actor_unsupported());
4409    }
4410    let directory = store.bamboo_home_dir().join(rel);
4411    let mut checked = PathBuf::new();
4412    for component in directory.components() {
4413        if !matches!(
4414            component,
4415            std::path::Component::RootDir | std::path::Component::Normal(_)
4416        ) {
4417            return Err(plain_actor_unsupported());
4418        }
4419        checked.push(component.as_os_str());
4420        if !tokio::fs::symlink_metadata(&checked)
4421            .await
4422            .map_err(|_| plain_actor_unsupported())?
4423            .is_dir()
4424        {
4425            return Err(plain_actor_unsupported());
4426        }
4427    }
4428    for name in ["actor-authority.json", "actor-authority.initialized.json"] {
4429        match tokio::fs::symlink_metadata(directory.join(name)).await {
4430            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
4431            _ => return Err(plain_actor_unsupported()),
4432        }
4433    }
4434    Ok(())
4435}
4436
4437// Zero-tool or read-only Glob named Child of a durable Ultra Root.
4438// Glob uses its typed tail; zero-tool supports new input after a plain completion
4439// as well as two bounded corrections within a Running activation.
4440// An expired initial owner can be replaced with one new input through the same
4441// checkpoint/release boundary. No lease renewal, raw steering or remote activation.
4442struct PlainActorActivation {
4443    store: Arc<bamboo_storage::SessionStoreV2>,
4444    fence: ActorActivationFence,
4445    inbox_generation: u64,
4446    created_at: chrono::DateTime<chrono::Utc>,
4447    provider_transcript: bamboo_domain::ProviderTranscriptState,
4448    input_consumer: SessionInboxConsumerId,
4449    input_deadline: chrono::DateTime<chrono::Utc>,
4450    actor_deadline: chrono::DateTime<chrono::Utc>,
4451    initial_release_required: bool,
4452    input_inbox: bamboo_storage::FileSessionInbox,
4453    permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
4454    initial_input: Option<SessionInboxOwnedClaim>,
4455    recovering_pre_ack: bool,
4456    completion_router: std::sync::Mutex<Option<ActorEventRouter>>,
4457}
4458type PlainInitialDelivery = (Vec<serde_json::Value>, SessionMessageDelivery);
4459#[track_caller]
4460fn plain_actor_unsupported() -> AgentError {
4461    let source = std::panic::Location::caller();
4462    tracing::warn!(
4463        process_id = std::process::id(),
4464        source_file = source.file(),
4465        source_line = source.line(),
4466        source_column = source.column(),
4467        "plain Actor continuation unsupported"
4468    );
4469    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())
4470}
4471fn plain_initial_history(session: &Session) -> bool {
4472    !session.messages.iter().any(|message| {
4473        matches!(message.role, Role::Assistant | Role::Tool)
4474            || message
4475                .metadata
4476                .as_ref()
4477                .is_some_and(|metadata| metadata.get("_bamboo_owned_input_checkpoint").is_some())
4478    }) && session.provider_transcript.is_empty()
4479        && session.session_inbox_admission().is_none()
4480        && session.pending_injected_messages().is_none()
4481}
4482// Selection only. Canonical terminal status/new input are checked at start;
4483// the existing fenced input checkpoint validates the complete durable prefix.
4484fn plain_completed_history(session: &Session) -> bool {
4485    session.provider_transcript.is_empty()
4486        && session.pending_injected_messages().is_none()
4487        && session
4488            .messages
4489            .last()
4490            .is_some_and(|message| message.role == Role::Assistant && !message.content.is_empty())
4491        && !session
4492            .messages
4493            .iter()
4494            .any(|message| message.role == Role::Tool || message.tool_calls.is_some())
4495}
4496// Selection only. Storage's closed raw bookkeeper/current-prefix validator is
4497// still mandatory after the actual replacement claim; this metadata is no grant.
4498fn pre_ack_input(
4499    session: &Session,
4500) -> Result<Option<(bamboo_domain::SessionMessageId, u64)>, AgentError> {
4501    let Some(message) = session.messages.last() else {
4502        return Ok(None);
4503    };
4504    let Some(proof) = message
4505        .metadata
4506        .as_ref()
4507        .and_then(|v| v.get("_bamboo_owned_input_checkpoint"))
4508    else {
4509        return Ok(None);
4510    };
4511    let id = bamboo_domain::SessionMessageId::parse(message.id.clone())
4512        .map_err(|_| plain_actor_unsupported())?;
4513    let generation = proof
4514        .get("generation")
4515        .and_then(serde_json::Value::as_u64)
4516        .filter(|g| *g > 0)
4517        .ok_or_else(|| plain_actor_unsupported())?;
4518    if message.role != Role::User
4519        || session
4520            .messages
4521            .iter()
4522            .filter(|m| m.id == message.id)
4523            .count()
4524            != 1
4525        || !session
4526            .session_inbox_admission()
4527            .is_some_and(|cursor| cursor.contains(&id))
4528        || !session.provider_transcript.is_empty()
4529        || session.pending_injected_messages().is_some()
4530        || session
4531            .messages
4532            .iter()
4533            .any(|m| m.role == Role::Tool || m.tool_calls.is_some())
4534    {
4535        return Err(plain_actor_unsupported());
4536    }
4537    Ok(Some((id, generation)))
4538}
4539fn qualified_pre_ack_owner(
4540    entry: &bamboo_domain::ActorDirectoryEntry,
4541    now: chrono::DateTime<chrono::Utc>,
4542) -> bool {
4543    let Some(old) = entry.activation.as_ref() else {
4544        return false;
4545    };
4546    let recoverable = (entry.actor.state == ActorLogicalState::Failed
4547        && old.status == bamboo_domain::ActorActivationStatus::Failed)
4548        || (entry.actor.state == ActorLogicalState::Active
4549            && old.status.is_live()
4550            && old.lease_expires_at <= now);
4551    let trusted = old.placement_ref.as_ref().is_some_and(|placement| {
4552        placement.class == bamboo_domain::ActorPlacementClass::Local
4553            && placement
4554                .lease_id
4555                .strip_prefix("owned-initial-release-v1:")
4556                .is_some_and(|worker| {
4557                    !worker.is_empty()
4558                        && worker.len() <= 256
4559                        && !worker.contains("..")
4560                        && worker.bytes().all(|b| {
4561                            b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.'
4562                        })
4563                })
4564    });
4565    recoverable && trusted
4566}
4567async fn expired_pre_ack_input(
4568    inbox: &bamboo_storage::FileSessionInbox,
4569    session: &Session,
4570) -> Result<Option<(bamboo_domain::SessionMessageId, u64)>, AgentError> {
4571    let Some((id, generation)) = pre_ack_input(session)? else {
4572        return Ok(None);
4573    };
4574    let backlog = inbox
4575        .inspect(&session.id)
4576        .await
4577        .map_err(|_| plain_actor_unsupported())?;
4578    let leases = inbox
4579        .inspect_owned_leases(&session.id, 2, chrono::Utc::now())
4580        .await
4581        .map_err(|_| plain_actor_unsupported())?;
4582    if backlog.pending != 0
4583        || backlog.claimed != 1
4584        || backlog.generation != generation
4585        || backlog.oldest_generation != Some(generation)
4586        || !backlog.activation_pending()
4587        || leases.len() != 1
4588        || !leases[0].expired
4589        || leases[0].generation != generation
4590        || inbox
4591            .was_admitted(&session.id, &id)
4592            .await
4593            .map_err(|_| plain_actor_unsupported())?
4594    {
4595        return Err(plain_actor_unsupported());
4596    }
4597    Ok(Some((id, generation)))
4598}
4599impl PlainActorActivation {
4600    // Callee-side boxing keeps this storage chain out of the already large
4601    // external-runner future without changing its cancellation semantics.
4602    fn start<'a>(
4603        store: Arc<bamboo_storage::SessionStoreV2>,
4604        session: &'a Session,
4605        binding: &'a SessionInboxRuntimeBinding,
4606        run_id: Option<&'a str>,
4607        permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
4608        allow_failed_retry: bool,
4609        release_worker: Option<&'a str>,
4610    ) -> std::pin::Pin<Box<impl std::future::Future<Output = Result<Self, AgentError>> + Send + 'a>>
4611    {
4612        Box::pin(async move {
4613            let run_id = run_id.ok_or_else(|| plain_actor_unsupported())?;
4614            let backlog = binding
4615                .inbox
4616                .inspect(&session.id)
4617                .await
4618                .map_err(|_| plain_actor_unsupported())?;
4619            let entry = store
4620                .ensure_actor(&session.id)
4621                .await
4622                .map_err(|error| AgentError::LLM(error.to_string()))?;
4623            let config = permission_config
4624                .as_ref()
4625                .ok_or_else(|| plain_actor_unsupported())?;
4626            if entry.actor.policy_revision != Some(config.policy_revision())
4627                || entry.actor.placement_intent != Some(bamboo_domain::ActorPlacementIntent::Local)
4628            {
4629                return Err(plain_actor_unsupported());
4630            }
4631            let fresh = entry.actor.current_attempt == 0
4632                && entry.actor.state == ActorLogicalState::Cold
4633                && entry.activation.is_none()
4634                && backlog.pending == 0
4635                && backlog.claimed == 0
4636                && backlog.generation == 0;
4637            let new_input = backlog.pending == 1
4638                && backlog.claimed == 0
4639                && backlog.oldest_generation == Some(backlog.generation)
4640                && backlog.activation_pending();
4641            let retry = allow_failed_retry
4642                && entry.actor.state == ActorLogicalState::Failed
4643                && entry.actor.current_attempt > 0
4644                && entry.activation.as_ref().is_some_and(|old| {
4645                    old.status == bamboo_domain::ActorActivationStatus::Failed
4646                        && backlog.generation > old.inbox_generation
4647                })
4648                && new_input;
4649            let continuation = allow_failed_retry
4650                && release_worker.is_some()
4651                && entry.actor.state == ActorLogicalState::Cold
4652                && entry.actor.current_attempt > 0
4653                && entry.activation.as_ref().is_some_and(|old| {
4654                    old.status == bamboo_domain::ActorActivationStatus::Succeeded
4655                        && backlog.generation > old.inbox_generation
4656                })
4657                && new_input
4658                && plain_completed_history(session);
4659            let input_inbox = bamboo_storage::FileSessionInbox::new(
4660                store.clone(),
4661                bamboo_domain::SessionInboxLimits::default(),
4662            );
4663            let now = chrono::Utc::now();
4664            // The old initial Run may still be executing physically. Its final
4665            // append remains fenced; only a new owned input can release this
4666            // replacement worker. Previously admitted history is not replayed.
4667            let replacement = allow_failed_retry
4668                && release_worker.is_some()
4669                && entry.actor.state == ActorLogicalState::Active
4670                && qualified_pre_ack_owner(&entry, now)
4671                && entry.activation.as_ref().is_some_and(|old| {
4672                    old.status.is_live() && old.lease_expires_at <= now && old.inbox_generation == 0
4673                })
4674                && new_input
4675                && plain_initial_history(session);
4676            let recovery = if allow_failed_retry
4677                && release_worker.is_some()
4678                && qualified_pre_ack_owner(&entry, now)
4679            {
4680                expired_pre_ack_input(&input_inbox, session).await?
4681            } else {
4682                None
4683            };
4684            if (recovery.is_some() || continuation || replacement)
4685                && (!entry.actor.matches_session(session)
4686                    || entry.actor.project_id
4687                        != project_id_for_actor_run(session)?.map(|id| id.to_string()))
4688            {
4689                return Err(plain_actor_unsupported());
4690            }
4691            if recovery.is_none()
4692                && !continuation
4693                && !replacement
4694                && ((!fresh && !retry) || !plain_initial_history(session))
4695            {
4696                return Err(plain_actor_unsupported());
4697            }
4698            let duration = crate::runtime::execution::spawn::watchdog_policy_for_session(session)
4699                .max_total_secs
4700                .checked_add(60)
4701                .and_then(chrono::Duration::try_seconds)
4702                .ok_or_else(|| plain_actor_unsupported())?;
4703            let expires = now
4704                .checked_add_signed(duration)
4705                .ok_or_else(|| plain_actor_unsupported())?;
4706            let input_consumer = SessionInboxConsumerId::new();
4707            let input_deadline = expires.min(now + chrono::Duration::hours(1));
4708            let recovered = if let Some((id, generation)) = &recovery {
4709                if !binding.router.owns_run(&session.id, run_id).await {
4710                    return Err(plain_actor_unsupported());
4711                }
4712                let lease = SessionInboxLeaseRequest {
4713                    consumer: input_consumer.clone(),
4714                    now,
4715                    duration: input_deadline - now,
4716                };
4717                let mut claims = input_inbox
4718                    .claim_owned(&session.id, 1, Some(run_id), &lease)
4719                    .await
4720                    .map_err(|_| plain_actor_unsupported())?;
4721                let claim = claims.pop().ok_or_else(|| plain_actor_unsupported())?;
4722                if claim.claim.envelope.id != *id
4723                    || claim.claim.generation != *generation
4724                    || !matches!(&claim.claim.envelope.body, bamboo_domain::SessionMessageBody::Content(content)
4725                        if content.parts.is_empty() && !content.text.trim().is_empty() && content.text.len() <= 8192)
4726                {
4727                    return Err(plain_actor_unsupported());
4728                }
4729                Some(claim)
4730            } else {
4731                None
4732            };
4733            let claimed = store
4734                .claim_activation(&ActorActivationClaim {
4735                    actor_id: session.id.clone(),
4736                    run_id: run_id.into(),
4737                    lease_owner: input_consumer.as_str().into(),
4738                    lease_expires_at: expires,
4739                    inbox_generation: recovered
4740                        .as_ref()
4741                        .map_or(backlog.generation, |claim| claim.claim.generation),
4742                    placement_ref: release_worker.map(|worker| bamboo_domain::ActorPlacementRef {
4743                        class: bamboo_domain::ActorPlacementClass::Local,
4744                        // Written only after the real worker capability probe
4745                        // and spawn. Opaque provenance, never a live-owner grant.
4746                        lease_id: format!("owned-initial-release-v1:{worker}"),
4747                        slot_epoch: None,
4748                    }),
4749                    now,
4750                })
4751                .await
4752                .map_err(|error| AgentError::LLM(error.to_string()))?;
4753            let activation = Self {
4754                store,
4755                fence: claimed.fence(),
4756                inbox_generation: claimed.inbox_generation,
4757                created_at: session.created_at,
4758                provider_transcript: session.provider_transcript.clone(),
4759                input_consumer,
4760                input_deadline,
4761                actor_deadline: claimed.lease_expires_at,
4762                initial_release_required: release_worker.is_some(),
4763                input_inbox,
4764                permission_config,
4765                initial_input: recovered,
4766                recovering_pre_ack: recovery.is_some(),
4767                completion_router: std::sync::Mutex::new(None),
4768            };
4769            if let Err(error) = activation
4770                .store
4771                .start_activation(&activation.fence, chrono::Utc::now())
4772                .await
4773            {
4774                let _ = activation.finish(ActorActivationFinish::Failed).await;
4775                return Err(AgentError::LLM(error.to_string()));
4776            }
4777            Ok(activation)
4778        })
4779    }
4780    fn start_and_prepare<'a>(
4781        store: Arc<bamboo_storage::SessionStoreV2>,
4782        session: &'a mut Session,
4783        binding: &'a SessionInboxRuntimeBinding,
4784        run_id: Option<&'a str>,
4785        permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
4786        allow_failed_retry: bool,
4787        release_worker: Option<&'a str>,
4788    ) -> std::pin::Pin<
4789        Box<
4790            impl std::future::Future<
4791                    Output = Result<(Self, Option<PlainInitialDelivery>), AgentError>,
4792                > + Send
4793                + 'a,
4794        >,
4795    > {
4796        Box::pin(async move {
4797            let mut activation = Self::start(
4798                store,
4799                session,
4800                binding,
4801                run_id,
4802                permission_config,
4803                allow_failed_retry,
4804                release_worker,
4805            )
4806            .await?;
4807            if activation.inbox_generation == 0 {
4808                return Ok((activation, None));
4809            }
4810            let prepared = async {
4811                let run_id = run_id.ok_or_else(|| plain_actor_unsupported())?;
4812                let backlog = activation
4813                    .input_inbox
4814                    .inspect(&session.id)
4815                    .await
4816                    .map_err(|_| plain_actor_unsupported())?;
4817                let expected = if activation.recovering_pre_ack {
4818                    (0, 1)
4819                } else {
4820                    (1, 0)
4821                };
4822                if (backlog.pending, backlog.claimed) != expected
4823                    || backlog.generation != activation.inbox_generation
4824                {
4825                    return Err(plain_actor_unsupported());
4826                }
4827                let claim = match activation.initial_input.clone() {
4828                    Some(claim) => claim,
4829                    None => activation
4830                        .claim_input(binding, session, run_id)
4831                        .await?
4832                        .ok_or_else(|| plain_actor_unsupported())?,
4833                };
4834                if claim.claim.generation != activation.inbox_generation {
4835                    return Err(plain_actor_unsupported());
4836                }
4837                let delivery = activation.checkpoint_input(session, run_id, &claim).await?;
4838                activation.initial_input = Some(claim);
4839                Ok(delivery)
4840            }
4841            .await;
4842            match prepared {
4843                Ok(delivery) => Ok((activation, Some(delivery))),
4844                Err(error) => {
4845                    activation.finish(ActorActivationFinish::Failed).await?;
4846                    Err(error)
4847                }
4848            }
4849        })
4850    }
4851    fn claim_input<'a>(
4852        &'a self,
4853        binding: &'a SessionInboxRuntimeBinding,
4854        session: &'a Session,
4855        run_id: &'a str,
4856    ) -> std::pin::Pin<
4857        Box<
4858            impl std::future::Future<Output = Result<Option<SessionInboxOwnedClaim>, AgentError>>
4859                + Send
4860                + 'a,
4861        >,
4862    > {
4863        Box::pin(async move {
4864            if !self
4865                .input_inbox
4866                .inspect(&session.id)
4867                .await
4868                .map_err(|_| plain_actor_unsupported())?
4869                .activation_pending()
4870            {
4871                return Ok(None);
4872            }
4873            if run_id != self.fence.run_id || !binding.router.owns_run(&session.id, run_id).await {
4874                return Err(plain_actor_unsupported());
4875            }
4876            let now = chrono::Utc::now();
4877            if now >= self.input_deadline {
4878                return Err(plain_actor_unsupported());
4879            }
4880            let lease = SessionInboxLeaseRequest {
4881                consumer: self.input_consumer.clone(),
4882                now,
4883                duration: self.input_deadline - now,
4884            };
4885            let claim = self
4886                .input_inbox
4887                .claim_owned(&session.id, 1, Some(run_id), &lease)
4888                .await
4889                .map_err(|error| {
4890                    tracing::warn!(%error, "Actor correction claim unconfirmed");
4891                    AgentError::LLM(
4892                        "Actor correction claim unconfirmed; durable input is preserved".into(),
4893                    )
4894                })?
4895                .into_iter()
4896                .next();
4897            if let Some(claim) = &claim {
4898                if !matches!(&claim.claim.envelope.body, bamboo_domain::SessionMessageBody::Content(content)
4899                    if content.parts.is_empty() && !content.text.trim().is_empty() && content.text.len() <= 8192)
4900                {
4901                    self.input_inbox
4902                        .fail_owned(
4903                            &session.id,
4904                            claim,
4905                            &SessionInboxFailureReport {
4906                                now: chrono::Utc::now(),
4907                                error_code: "unsupported_actor_input".into(),
4908                            },
4909                        )
4910                        .await
4911                        .map_err(|error| {
4912                            tracing::warn!(%error, "Actor correction failure report unconfirmed");
4913                            AgentError::LLM(
4914                                "Actor correction failure report unconfirmed; durable input is preserved".into(),
4915                            )
4916                        })?;
4917                    return Err(plain_actor_unsupported());
4918                }
4919            }
4920            Ok(claim)
4921        })
4922    }
4923    fn checkpoint_input<'a>(
4924        &'a self,
4925        session: &'a mut Session,
4926        run_id: &'a str,
4927        claim: &'a SessionInboxOwnedClaim,
4928    ) -> std::pin::Pin<
4929        Box<
4930            impl std::future::Future<Output = Result<PlainInitialDelivery, AgentError>> + Send + 'a,
4931        >,
4932    > {
4933        Box::pin(async move {
4934            // The worker receives the committed pre-input prefix. Only typed
4935            // delivery adds the User; the Host-private bookkeeper is not a grant.
4936            let request = bamboo_storage::ActorInputCheckpoint {
4937                fence: self.fence.clone(),
4938                expected_created_at: self.created_at,
4939                claim: claim.clone(),
4940                expected_messages: session.messages.clone(),
4941                expected_provider_transcript: session.provider_transcript.clone(),
4942                expected_admission: session.session_inbox_admission().cloned(),
4943            };
4944            let mut retries = 0;
4945            let committed = loop {
4946                let result = self.input_inbox.checkpoint_actor_input(request.clone()).await;
4947                if matches!(&result, Err(bamboo_storage::ActorInputCheckpointError::PrefixConflict))
4948                    && retries < 3
4949                {
4950                    // A Parent can save ordinary tool/wait state while this
4951                    // Child checkpoints. PrefixConflict guarantees no publish;
4952                    // retry the SAME Child prefix, claim and fence, with every
4953                    // storage authority check repeated. Never adopt a changed
4954                    // Child transcript or retry an unconfirmed publication.
4955                    tokio::time::sleep(Duration::from_millis(10 << retries)).await;
4956                    retries += 1;
4957                    continue;
4958                }
4959                break result;
4960            }.map_err(|error| {
4961                tracing::warn!(%error, "Actor correction checkpoint rejected or unconfirmed; no worker dispatch or ACK");
4962                AgentError::LLM("Actor correction checkpoint unconfirmed; durable Inbox and history are preserved".into())
4963            })?;
4964            if self.recovering_pre_ack
4965                && committed.status
4966                    != bamboo_storage::ActorInputCheckpointStatus::AlreadyCheckpointed
4967            {
4968                return Err(plain_actor_unsupported());
4969            }
4970            let messages = session
4971                .messages
4972                .iter()
4973                .map(|message| {
4974                    let mut worker = message.clone();
4975                    if committed.status
4976                        == bamboo_storage::ActorInputCheckpointStatus::AlreadyCheckpointed
4977                        && worker.id == committed.envelope.id.as_str()
4978                    {
4979                        worker
4980                            .metadata
4981                            .as_mut()
4982                            .and_then(serde_json::Value::as_object_mut)
4983                            .ok_or_else(|| plain_actor_unsupported())?
4984                            .remove("_bamboo_owned_input_checkpoint");
4985                        if !bamboo_domain::is_matching_session_message(&worker, &committed.envelope)
4986                        {
4987                            return Err(plain_actor_unsupported());
4988                        }
4989                    }
4990                    serde_json::to_value(worker).map_err(|_| plain_actor_unsupported())
4991                })
4992                .collect::<Result<Vec<_>, _>>()?;
4993            *session = committed.session;
4994            Ok((
4995                messages,
4996                SessionMessageDelivery {
4997                    target_session_id: session.id.clone(),
4998                    envelope: committed.envelope,
4999                    canonical_claim_generation: committed.generation,
5000                    activation_run_id: run_id.into(),
5001                    activation_policy: claim.claim.activation_policy,
5002                },
5003            ))
5004        })
5005    }
5006    fn append_reply<'a>(
5007        &'a self,
5008        session: &'a mut Session,
5009        text: String,
5010        event_tx: &'a mpsc::Sender<AgentEvent>,
5011    ) -> std::pin::Pin<Box<impl std::future::Future<Output = Result<(), AgentError>> + Send + 'a>>
5012    {
5013        Box::pin(async move {
5014            let message = bamboo_agent_core::Message::assistant(text, None);
5015            let request = bamboo_storage::ActorTranscriptAppend {
5016                fence: self.fence.clone(),
5017                expected_created_at: self.created_at,
5018                expected_messages: session.messages.clone(),
5019                expected_provider_transcript: self.provider_transcript.clone(),
5020                messages: vec![message.clone()],
5021                native_groups: Vec::new(),
5022            };
5023            let mut retries = 0;
5024            *session = loop {
5025                let result = self.store.append_actor_transcript(request.clone()).await;
5026                if matches!(
5027                    &result,
5028                    Err(bamboo_storage::ActorTranscriptAppendError::PrefixConflict)
5029                ) && retries < 3
5030                {
5031                    // Parent writes may change the ancestor witness before
5032                    // publication. Retry this exact reply and Child prefix;
5033                    // never reload/adopt a different transcript or replay an
5034                    // unconfirmed write. Storage rechecks the same live fence.
5035                    tokio::time::sleep(Duration::from_millis(10 << retries)).await;
5036                    retries += 1;
5037                    continue;
5038                }
5039                break result;
5040            }
5041            .map_err(|error| {
5042                tracing::warn!(
5043                    %error,
5044                    "Actor reply checkpoint rejected or unconfirmed; durable history is preserved"
5045                );
5046                AgentError::LLM(format!("actor reply commit failed: {error}"))
5047            })?;
5048            let _ = event_tx
5049                .send(AgentEvent::message_appended(&session.id, &message))
5050                .await;
5051            Ok(())
5052        })
5053    }
5054    // First Terminal is a real completed plain reply, not a cache event. Commit
5055    // it before appending the typed correction, then use the existing startup
5056    // admission barrier on a second correlated Run under the SAME Host fence.
5057    fn continue_input<'a>(
5058        &'a self,
5059        client: &'a mut dyn bamboo_subagent::ChildLink,
5060        binding: &'a SessionInboxRuntimeBinding,
5061        session: &'a mut Session,
5062        run_id: &'a str,
5063        run: Option<(&'a RunSpec, &'a AtomicU64)>,
5064        expected_posture: &'a mut Option<ExpectedPermissionPosture>,
5065        text: String,
5066        event_tx: &'a mpsc::Sender<AgentEvent>,
5067    ) -> std::pin::Pin<
5068        Box<
5069            impl std::future::Future<
5070                    Output = Result<Option<(SessionInboxOwnedClaim, u64)>, AgentError>,
5071                > + Send
5072                + 'a,
5073        >,
5074    > {
5075        Box::pin(async move {
5076            let Some(claim) = self.claim_input(binding, session, run_id).await? else {
5077                return Ok(None);
5078            };
5079            let (run, epochs) = run.ok_or_else(|| plain_actor_unsupported())?;
5080            self.append_reply(session, text, event_tx).await?;
5081            let (messages, delivery) = self.checkpoint_input(session, run_id, &claim).await?;
5082            // Fresh actual Host audit, unchanged permission/ceiling checks.
5083            self.refresh_continuation_posture(session, run, expected_posture)?;
5084            let epoch = epochs
5085                .fetch_add(1, Ordering::Relaxed)
5086                .checked_add(1)
5087                .ok_or_else(|| plain_actor_unsupported())?;
5088            let mut next = run.clone();
5089            next.messages = messages;
5090            next.initial_session_messages = vec![delivery];
5091            next.execution_epoch = epoch;
5092            client.send(ParentFrame::Run(next)).await.map_err(|_| {
5093                AgentError::LLM(
5094                    "Actor correction dispatch unconfirmed; durable input is preserved".into(),
5095                )
5096            })?;
5097            Ok(Some((claim, epoch)))
5098        })
5099    }
5100    fn refresh_continuation_posture(
5101        &self,
5102        session: &Session,
5103        run: &RunSpec,
5104        expected: &mut Option<ExpectedPermissionPosture>,
5105    ) -> Result<(), AgentError> {
5106        let Some(expected) = expected.as_mut() else {
5107            return Err(plain_actor_unsupported());
5108        };
5109        let audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
5110            .ok_or_else(|| plain_actor_unsupported())?;
5111        if audit.policy_revision != expected.policy_revision
5112            || audit.resolution != expected.resolution
5113            || audit.executor_mapping != expected.executor_mapping
5114        {
5115            return Err(plain_actor_unsupported());
5116        }
5117        match (&self.permission_config, &run.permission_policy) {
5118            (Some(config), Some(policy)) => {
5119                ensure_no_active_scoped_session_denies(config, &session.id)?;
5120                if config.policy_revision() != policy.revision
5121                    || policy.revision != audit.policy_revision
5122                    || policy.session_id != session.id
5123                    || policy.workspace_path != session.workspace
5124                    || policy.inherit_session_grants
5125                    || serde_json::to_value(config.to_serializable())
5126                        .map_err(|_| plain_actor_unsupported())?
5127                        != policy.policy
5128                {
5129                    return Err(plain_actor_unsupported());
5130                }
5131            }
5132            (None, None) => {}
5133            _ => return Err(plain_actor_unsupported()),
5134        }
5135        expected.expected_audit_revision = Some(audit.audit_revision);
5136        Ok(())
5137    }
5138    fn release_initial_input<'a>(
5139        &'a self,
5140        binding: &'a SessionInboxRuntimeBinding,
5141        session: &'a Session,
5142        run: &'a RunSpec,
5143        epoch: u64,
5144        claim: &'a SessionInboxOwnedClaim,
5145        request: &'a bamboo_subagent::proto::InitialInputReleaseRequest,
5146        expected: &'a mut Option<ExpectedPermissionPosture>,
5147        previous: Option<&'a bamboo_subagent::proto::InitialInputRelease>,
5148    ) -> std::pin::Pin<
5149        Box<
5150            impl std::future::Future<
5151                    Output = Result<bamboo_subagent::proto::InitialInputRelease, AgentError>,
5152                > + Send
5153                + 'a,
5154        >,
5155    > {
5156        Box::pin(async move {
5157            if !self.initial_release_required {
5158                return Err(plain_actor_unsupported());
5159            }
5160            let delivery = SessionMessageDelivery {
5161                target_session_id: session.id.clone(),
5162                envelope: claim.claim.envelope.clone(),
5163                canonical_claim_generation: claim.claim.generation,
5164                activation_run_id: self.fence.run_id.clone(),
5165                activation_policy: claim.claim.activation_policy,
5166            };
5167            let mut current_run = run.clone();
5168            current_run.execution_epoch = epoch;
5169            let exact = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
5170                &current_run,
5171                &delivery,
5172                request.nonce.clone(),
5173            )
5174            .map_err(|_| plain_actor_unsupported())?;
5175            if &exact != request
5176                || request.child_id != session.id
5177                || request.created_at != session.created_at
5178                || request.spawn_depth != session.spawn_depth
5179                || request.parent_id != session.parent_session_id.as_deref().unwrap_or_default()
5180                || request.root_id != session.root_session_id
5181                || request.project_id != project_id_for_actor_run(session)?
5182                || !binding
5183                    .router
5184                    .owns_run(&session.id, &self.fence.run_id)
5185                    .await
5186            {
5187                return Err(plain_actor_unsupported());
5188            }
5189            let deadline = self
5190                .actor_deadline
5191                .min(self.input_deadline)
5192                .min(claim.lease.expires_at);
5193            if chrono::Utc::now() >= deadline {
5194                return Err(plain_actor_unsupported());
5195            }
5196            self.store
5197                .validate_fence(&self.fence, chrono::Utc::now())
5198                .await
5199                .map_err(|_| plain_actor_unsupported())?;
5200            let actual =
5201                bamboo_agent_core::storage::Storage::load_session(self.store.as_ref(), &session.id)
5202                    .await
5203                    .map_err(|_| plain_actor_unsupported())?
5204                    .ok_or_else(|| plain_actor_unsupported())?;
5205            if actual.created_at != session.created_at
5206                || actual.root_session_id != session.root_session_id
5207                || actual.parent_session_id != session.parent_session_id
5208                || actual.spawn_depth != session.spawn_depth
5209                || project_id_for_actor_run(&actual)? != request.project_id
5210                || serde_json::to_value(&actual.messages).map_err(|_| plain_actor_unsupported())?
5211                    != serde_json::to_value(&session.messages)
5212                        .map_err(|_| plain_actor_unsupported())?
5213                || serde_json::to_value(&actual.provider_transcript)
5214                    .map_err(|_| plain_actor_unsupported())?
5215                    != serde_json::to_value(&session.provider_transcript)
5216                        .map_err(|_| plain_actor_unsupported())?
5217                || serde_json::to_value(actual.session_inbox_admission())
5218                    .map_err(|_| plain_actor_unsupported())?
5219                    != serde_json::to_value(session.session_inbox_admission())
5220                        .map_err(|_| plain_actor_unsupported())?
5221            {
5222                return Err(plain_actor_unsupported());
5223            }
5224            self.refresh_continuation_posture(&actual, &current_run, expected)?;
5225            if let Some(previous) = previous {
5226                if !previous.permits(request, chrono::Utc::now())
5227                    || previous.expires_at != deadline
5228                    || !self
5229                        .input_inbox
5230                        .was_admitted(&session.id, &claim.claim.envelope.id)
5231                        .await
5232                        .map_err(|_| plain_actor_unsupported())?
5233                {
5234                    return Err(plain_actor_unsupported());
5235                }
5236            } else {
5237                // Full current-prefix AlreadyCheckpointed readback still checks
5238                // the actual physical owner. A local worker receipt is insufficient.
5239                let checkpoint = bamboo_storage::ActorInputCheckpoint {
5240                    fence: self.fence.clone(),
5241                    expected_created_at: self.created_at,
5242                    claim: claim.clone(),
5243                    expected_messages: actual.messages.clone(),
5244                    expected_provider_transcript: actual.provider_transcript.clone(),
5245                    expected_admission: actual.session_inbox_admission().cloned(),
5246                };
5247                let mut retries = 0;
5248                loop {
5249                    let result = self
5250                        .input_inbox
5251                        .checkpoint_actor_input(checkpoint.clone())
5252                        .await;
5253                    if matches!(
5254                        &result,
5255                        Err(bamboo_storage::ActorInputCheckpointError::PrefixConflict)
5256                    ) && retries < 3
5257                    {
5258                        // As at initial checkpoint, this error guarantees no
5259                        // publication. Repeat the identical prefix, claim and
5260                        // fence; a Parent's ordinary save grants no authority.
5261                        tracing::warn!(retries, "Actor initial release prefix changed before publication; retrying same claim");
5262                        tokio::time::sleep(Duration::from_millis(10 << retries)).await;
5263                        retries += 1;
5264                        continue;
5265                    }
5266                    result.map_err(|error| {
5267                        tracing::warn!(%error, "Actor initial release checkpoint rejected or unconfirmed");
5268                        plain_actor_unsupported()
5269                    })?;
5270                    break;
5271                }
5272                self.confirm_input(
5273                    binding,
5274                    &actual,
5275                    &self.fence.run_id,
5276                    claim,
5277                    &bamboo_subagent::proto::SessionMessageAdmissionConfirmation {
5278                        target_session_id: request.child_id.clone(),
5279                        envelope_id: request.envelope_id.clone(),
5280                        canonical_claim_generation: request.generation,
5281                        activation_run_id: request.activation_run_id.clone(),
5282                    },
5283                )
5284                .await?;
5285            }
5286            // ACK's terminal receipt is idempotent even after expiry. It cannot
5287            // replace these final current-owner/deadline/policy checks.
5288            if chrono::Utc::now() >= deadline
5289                || !binding
5290                    .router
5291                    .owns_run(&session.id, &self.fence.run_id)
5292                    .await
5293            {
5294                return Err(plain_actor_unsupported());
5295            }
5296            self.store
5297                .validate_fence(&self.fence, chrono::Utc::now())
5298                .await
5299                .map_err(|_| plain_actor_unsupported())?;
5300            let final_session =
5301                bamboo_agent_core::storage::Storage::load_session(self.store.as_ref(), &session.id)
5302                    .await
5303                    .map_err(|_| plain_actor_unsupported())?
5304                    .ok_or_else(|| plain_actor_unsupported())?;
5305            self.store
5306                .validate_fence(&self.fence, chrono::Utc::now())
5307                .await
5308                .map_err(|_| plain_actor_unsupported())?;
5309            self.refresh_continuation_posture(&final_session, &current_run, expected)?;
5310            if chrono::Utc::now() >= deadline {
5311                return Err(plain_actor_unsupported());
5312            }
5313            Ok(bamboo_subagent::proto::InitialInputRelease {
5314                request: request.clone(),
5315                expires_at: deadline,
5316            })
5317        })
5318    }
5319    async fn confirm_input(
5320        &self,
5321        binding: &SessionInboxRuntimeBinding,
5322        session: &Session,
5323        run_id: &str,
5324        claim: &SessionInboxOwnedClaim,
5325        confirmation: &bamboo_subagent::proto::SessionMessageAdmissionConfirmation,
5326    ) -> Result<(), AgentError> {
5327        if confirmation.target_session_id != session.id
5328            || confirmation.envelope_id != claim.claim.envelope.id.as_str()
5329            || confirmation.canonical_claim_generation != claim.claim.generation
5330            || confirmation.activation_run_id != run_id
5331            || run_id != self.fence.run_id
5332            || !binding.router.owns_run(&session.id, run_id).await
5333        {
5334            return Err(plain_actor_unsupported());
5335        }
5336        self.input_inbox
5337            .ack_owned(&session.id, claim, chrono::Utc::now())
5338            .await
5339            .map_err(|error| {
5340                tracing::warn!(%error, "Actor correction ACK unresolved");
5341                AgentError::LLM(
5342                    "Actor correction ACK unresolved; durable input is preserved".into(),
5343                )
5344            })
5345    }
5346    async fn finish(&self, outcome: ActorActivationFinish) -> Result<(), AgentError> {
5347        self.finish_with_observer(outcome, None).await
5348    }
5349
5350    async fn finish_with_observer(
5351        &self,
5352        outcome: ActorActivationFinish,
5353        observer: Option<&dyn ActorEventObserver>,
5354    ) -> Result<(), AgentError> {
5355        let committed = self
5356            .store
5357            .finish_activation(&self.fence, chrono::Utc::now(), outcome)
5358            .await
5359            .map_err(|error| AgentError::LLM(format!("Actor completion unconfirmed: {error}")))?;
5360        // A validated final Terminal hands off this one router. Failed writes
5361        // never publish, and neither a retry nor another finish can reuse it.
5362        let router = self.completion_router.lock().recover_poison().take();
5363        if let (Some(observer), Some(router)) = (observer, router) {
5364            // The Directory may already hold a successor by this point. Never
5365            // relabel the old completion, and never turn an already committed
5366            // success into an execution failure if best-effort delivery is lost.
5367            match self.store.inspect_actor(&self.fence.actor_id).await {
5368                Ok(entry) => match router.into_host_completion(&entry, &committed) {
5369                    Ok(event) => observer.publish(event),
5370                    Err(_) => tracing::debug!(actor_id = %self.fence.actor_id,
5371                        "Actor completion notification superseded"),
5372                },
5373                Err(error) => tracing::warn!(actor_id = %self.fence.actor_id, %error,
5374                    "Actor completion committed; notification snapshot unavailable"),
5375            }
5376        }
5377        Ok(())
5378    }
5379}
5380fn local_tool_history_unsupported() -> AgentError {
5381    AgentError::LLM("local_tool_history_unsupported".into())
5382}
5383
5384#[derive(Default)]
5385struct LocalToolCollector {
5386    messages: Option<Vec<bamboo_agent_core::Message>>,
5387    starts: HashMap<String, (String, serde_json::Value)>,
5388    outcomes: HashMap<String, (bool, String)>,
5389    // Installed only by the Host's exact Task callback receipt. Worker event
5390    // JSON and transcript metadata cannot grant this terminal exception.
5391    ticket_yield: Option<(String, bamboo_tickets::PendingRequest)>,
5392}
5393
5394impl LocalToolCollector {
5395    fn ticket_event(
5396        &self,
5397        value: &serde_json::Value,
5398        committed: &HashMap<String, serde_json::Value>,
5399    ) -> Result<bool, AgentError> {
5400        if value["type"] == bamboo_subagent::proto::LocalToolMessages::TYPE {
5401            return Ok(true);
5402        }
5403        match serde_json::from_value::<AgentEvent>(value.clone())
5404            .map_err(|_| local_tool_history_unsupported())?
5405        {
5406            AgentEvent::ToolStart { tool_name, .. }
5407                if !matches!(tool_name.as_str(), "Task" | "Read" | "Write") =>
5408            {
5409                Err(local_tool_history_unsupported())
5410            }
5411            AgentEvent::ToolComplete {
5412                tool_call_id,
5413                result,
5414            } if result.success => {
5415                let Some((name, args)) = self.starts.get(&tool_call_id) else {
5416                    return Err(local_tool_history_unsupported());
5417                };
5418                let valid = if name == "Task" {
5419                    committed.get(&tool_call_id) == Some(args)
5420                } else {
5421                    committed.get(&tool_call_id).is_some_and(|proof| {
5422                        proof["file_tool"] == *name
5423                            && proof["arguments"] == *args
5424                            && proof["result"] == result.result
5425                    })
5426                };
5427                if !valid {
5428                    return Err(AgentError::LLM(
5429                        "LocalPlan success has no exact Host receipt".into(),
5430                    ));
5431                }
5432                Ok(true)
5433            }
5434            // Only the committed Host projection may publish a plan update.
5435            AgentEvent::TaskListUpdated { .. } => Ok(false),
5436            _ => Ok(true),
5437        }
5438    }
5439
5440    fn event(&mut self, value: &serde_json::Value) -> Result<bool, AgentError> {
5441        use bamboo_subagent::proto::LocalToolMessages;
5442        if self.messages.is_some() {
5443            return Err(local_tool_history_unsupported());
5444        }
5445        if value["type"] == LocalToolMessages::TYPE {
5446            let observation: LocalToolMessages = serde_json::from_value(value.clone())
5447                .map_err(|_| local_tool_history_unsupported())?;
5448            self.messages = Some(
5449                observation
5450                    .validate()
5451                    .map_err(|_| local_tool_history_unsupported())?,
5452            );
5453            return Ok(false);
5454        }
5455        let event: AgentEvent =
5456            serde_json::from_value(value.clone()).map_err(|_| local_tool_history_unsupported())?;
5457        match event {
5458            AgentEvent::ToolStart {
5459                tool_call_id,
5460                tool_name,
5461                arguments,
5462            } => {
5463                if !arguments.is_object()
5464                    || !matches!(tool_name.as_str(), "Read" | "Glob" | "Write" | "Task")
5465                    || tool_call_id.is_empty()
5466                    || tool_call_id.len() > 128
5467                    || self.starts.len() >= LocalToolMessages::MAX_PAIRS
5468                    || self.outcomes.contains_key(&tool_call_id)
5469                    || self
5470                        .starts
5471                        .insert(tool_call_id, (tool_name, arguments))
5472                        .is_some()
5473                {
5474                    return Err(local_tool_history_unsupported());
5475                }
5476            }
5477            AgentEvent::ToolComplete {
5478                tool_call_id,
5479                result,
5480            } => {
5481                if !result.images.is_empty() {
5482                    return Err(local_tool_history_unsupported());
5483                }
5484                self.outcome(tool_call_id, result.success, result.result)?;
5485            }
5486            AgentEvent::ToolError {
5487                tool_call_id,
5488                error,
5489            } => {
5490                self.outcome(tool_call_id, false, format!("Error: {error}"))?;
5491            }
5492            AgentEvent::ToolToken { tool_call_id, .. } => {
5493                if !self.starts.contains_key(&tool_call_id)
5494                    || self.outcomes.contains_key(&tool_call_id)
5495                {
5496                    return Err(local_tool_history_unsupported());
5497                }
5498            }
5499            AgentEvent::ToolLifecycle {
5500                tool_call_id,
5501                tool_name,
5502                is_mutating,
5503                auto_approved,
5504                phase,
5505                ..
5506            } => {
5507                if self.starts.get(&tool_call_id).map(|(name, _)| name) != Some(&tool_name)
5508                    || is_mutating != matches!(tool_name.as_str(), "Write" | "Task")
5509                    || auto_approved != !matches!(tool_name.as_str(), "Write" | "Task")
5510                    || !matches!(phase.as_str(), "begin" | "finished" | "error" | "cancelled")
5511                {
5512                    return Err(local_tool_history_unsupported());
5513                }
5514            }
5515            AgentEvent::ReasoningToken { .. } => {
5516                // Text reasoning is a stream observation. Its complete value
5517                // is preserved by the bounded typed completion, which still
5518                // rejects signatures and unsupported provider state.
5519            }
5520            AgentEvent::ContextSummarized { .. }
5521            | AgentEvent::ContextArchived { .. }
5522            | AgentEvent::ContextCompressionStatus { .. }
5523            | AgentEvent::NeedClarification { .. }
5524            | AgentEvent::SubAgentStarted { .. }
5525            | AgentEvent::SubAgentEvent { .. } => return Err(local_tool_history_unsupported()),
5526            // Worker commit events are observations, not canonical Host commits.
5527            AgentEvent::SessionHistoryCommitted { .. } | AgentEvent::MessageAppended { .. } => {
5528                return Ok(false)
5529            }
5530            _ => {}
5531        }
5532        Ok(true)
5533    }
5534
5535    fn outcome(&mut self, id: String, success: bool, text: String) -> Result<(), AgentError> {
5536        if id.is_empty()
5537            || id.len() > 128
5538            || text.len() > bamboo_subagent::proto::LocalToolMessages::MAX_BYTES
5539            || self.outcomes.len() >= bamboo_subagent::proto::LocalToolMessages::MAX_PAIRS
5540            || self.outcomes.insert(id, (success, text)).is_some()
5541        {
5542            return Err(local_tool_history_unsupported());
5543        }
5544        Ok(())
5545    }
5546
5547    fn suffix(
5548        self,
5549        host: &Session,
5550        tools: &[String],
5551        read_only: bool,
5552        terminal: Option<&str>,
5553    ) -> Result<Vec<bamboo_agent_core::Message>, AgentError> {
5554        use bamboo_subagent::proto::LocalToolMessages;
5555        let data = self.messages.ok_or_else(local_tool_history_unsupported)?;
5556        let candidate: Vec<_> = data.iter().skip_while(|m| m.role == Role::System).collect();
5557        let prefix: Vec<_> = host
5558            .messages
5559            .iter()
5560            .filter(|m| m.role != Role::System)
5561            .collect();
5562        if !host.provider_transcript.groups().is_empty()
5563            || candidate.len() <= prefix.len()
5564            || serde_json::to_value(&candidate[..prefix.len()])
5565                .map_err(|_| local_tool_history_unsupported())?
5566                != serde_json::to_value(&prefix).map_err(|_| local_tool_history_unsupported())?
5567        {
5568            return Err(local_tool_history_unsupported());
5569        }
5570        let mut ids = std::collections::HashSet::new();
5571        let mut calls = std::collections::HashSet::new();
5572        for message in &host.messages {
5573            if !ids.insert(message.id.clone()) {
5574                return Err(local_tool_history_unsupported());
5575            }
5576            for call in message.tool_calls.iter().flatten() {
5577                calls.insert(call.id.clone());
5578            }
5579            if let Some(id) = &message.tool_call_id {
5580                calls.insert(id.clone());
5581            }
5582        }
5583        let suffix: Vec<_> = candidate[prefix.len()..]
5584            .iter()
5585            .map(|message| (**message).clone())
5586            .collect();
5587        let mut pending = HashMap::new();
5588        let mut used = std::collections::HashSet::new();
5589        for message in &suffix {
5590            if !ids.insert(message.id.clone()) || message.never_compress {
5591                return Err(local_tool_history_unsupported());
5592            }
5593            match message.role {
5594                Role::Assistant => {
5595                    if !pending.is_empty()
5596                        || message.metadata.is_some()
5597                        || message.tool_call_id.is_some()
5598                        || message.tool_success.is_some()
5599                    {
5600                        return Err(local_tool_history_unsupported());
5601                    }
5602                    if let Some(batch) = &message.tool_calls {
5603                        if batch.is_empty() {
5604                            return Err(local_tool_history_unsupported());
5605                        }
5606                        for call in batch {
5607                            let arguments =
5608                                serde_json::from_str::<serde_json::Value>(&call.function.arguments)
5609                                    .ok()
5610                                    .filter(|arguments| arguments.is_object())
5611                                    .ok_or_else(local_tool_history_unsupported)?;
5612                            if call.id.is_empty()
5613                                || call.id.len() > 128
5614                                || call.tool_type != "function"
5615                                || !calls.insert(call.id.clone())
5616                                || calls.len() > LocalToolMessages::MAX_MESSAGES
5617                                || !matches!(
5618                                    call.function.name.as_str(),
5619                                    "Read" | "Glob" | "Write" | "Task"
5620                                )
5621                                || self.starts.get(&call.id).is_some_and(|(name, actual)| {
5622                                    name != &call.function.name || actual != &arguments
5623                                })
5624                            {
5625                                return Err(local_tool_history_unsupported());
5626                            }
5627                            pending.insert(call.id.clone(), call.function.name.clone());
5628                        }
5629                    } else if message.content.trim().is_empty() {
5630                        return Err(local_tool_history_unsupported());
5631                    }
5632                }
5633                Role::Tool => {
5634                    let id = message
5635                        .tool_call_id
5636                        .as_ref()
5637                        .ok_or_else(local_tool_history_unsupported)?;
5638                    let name = pending
5639                        .remove(id)
5640                        .ok_or_else(local_tool_history_unsupported)?;
5641                    let success = message
5642                        .tool_success
5643                        .ok_or_else(local_tool_history_unsupported)?;
5644                    if message.tool_calls.is_some()
5645                        || message.phase.is_some()
5646                        || self.outcomes.get(id) != Some(&(success, message.content.clone()))
5647                        || !used.insert(id.clone())
5648                        || used.len() > LocalToolMessages::MAX_PAIRS
5649                    {
5650                        return Err(local_tool_history_unsupported());
5651                    }
5652                    let denied_write = name == "Write" && (read_only || !tools.contains(&name));
5653                    if denied_write {
5654                        if success
5655                            || !(message.content.contains("native_tool_ceiling_denied")
5656                                || message
5657                                    .content
5658                                    .contains("not callable at the current conversation position"))
5659                        {
5660                            return Err(local_tool_history_unsupported());
5661                        }
5662                    } else if !tools.contains(&name)
5663                        || self.starts.get(id).map(|(actual, _)| actual) != Some(&name)
5664                    {
5665                        return Err(local_tool_history_unsupported());
5666                    }
5667                    if let Some(metadata) = &message.metadata {
5668                        let object = metadata
5669                            .as_object()
5670                            .ok_or_else(local_tool_history_unsupported)?;
5671                        if object.len() != 5
5672                            || metadata["elapsed_ms"].as_u64().is_none()
5673                            || metadata["tool_name"].as_str() != Some(name.as_str())
5674                            || metadata["is_mutating"].as_bool()
5675                                != Some(matches!(name.as_str(), "Write" | "Task"))
5676                            || metadata["auto_approved"].as_bool()
5677                                != Some(!matches!(name.as_str(), "Write" | "Task"))
5678                            || metadata["success"].as_bool() != Some(success)
5679                        {
5680                            return Err(local_tool_history_unsupported());
5681                        }
5682                    }
5683                }
5684                _ => return Err(local_tool_history_unsupported()),
5685            }
5686        }
5687        let last = suffix.last().ok_or_else(local_tool_history_unsupported)?;
5688        let question_terminal = self.ticket_yield.as_ref().is_some_and(|(id, question)| {
5689            last.role == Role::Tool
5690                && last.tool_call_id.as_ref() == Some(id)
5691                && last.tool_success == Some(true)
5692                && self.starts.get(id).is_some_and(|(name, _)| name == "Task")
5693                && terminal.is_none_or(str::is_empty)
5694                && serde_json::from_str::<serde_json::Value>(&last.content).ok()
5695                    == Some(serde_json::json!({"status":"waiting_for_answer", "request":question}))
5696        });
5697        let report_terminal = last.role == Role::Assistant
5698            && last.tool_calls.is_none()
5699            && last.phase != Some(bamboo_domain::MessagePhase::Commentary)
5700            && terminal == Some(last.content.as_str());
5701        if !pending.is_empty()
5702            || used.len() != self.outcomes.len()
5703            || self.starts.keys().any(|id| !used.contains(id))
5704            || !(report_terminal || question_terminal)
5705        {
5706            return Err(local_tool_history_unsupported());
5707        }
5708        Ok(suffix)
5709    }
5710}
5711
5712async fn commit_local_tool_history(
5713    collector: LocalToolCollector,
5714    session: &mut Session,
5715    binding: &SessionInboxRuntimeBinding,
5716    run_id: &str,
5717    selection: (&[String], bool),
5718    terminal: Option<&str>,
5719    cancel: &CancellationToken,
5720) -> Result<(), AgentError> {
5721    if cancel.is_cancelled() || !binding.router.owns_run(&session.id, run_id).await {
5722        return Err(local_tool_history_unsupported());
5723    }
5724    let latest = binding
5725        .storage
5726        .load_session(&session.id)
5727        .await
5728        .map_err(|_| local_tool_history_unsupported())?
5729        .ok_or_else(local_tool_history_unsupported)?;
5730    if latest.id != session.id
5731        || latest.kind != session.kind
5732        || latest.created_at != session.created_at
5733        || latest.root_session_id != session.root_session_id
5734        || latest.parent_session_id != session.parent_session_id
5735        || latest.spawn_depth != session.spawn_depth
5736        || latest.project_id_meta() != session.project_id_meta()
5737        || serde_json::to_value(&latest.provider_transcript)
5738            .map_err(|_| local_tool_history_unsupported())?
5739            != serde_json::to_value(&session.provider_transcript)
5740                .map_err(|_| local_tool_history_unsupported())?
5741    {
5742        return Err(local_tool_history_unsupported());
5743    }
5744    let suffix = collector.suffix(&latest, selection.0, selection.1, terminal)?;
5745    let before = session.clone();
5746    let mut staged = session.clone();
5747    staged.messages = latest.messages.clone();
5748    staged.messages.extend(suffix.iter().cloned());
5749    staged.updated_at = chrono::Utc::now();
5750    if cancel.is_cancelled() || !binding.router.owns_run(&session.id, run_id).await {
5751        return Err(local_tool_history_unsupported());
5752    }
5753    binding
5754        .persistence
5755        .checkpoint_runtime_session(&mut staged)
5756        .await
5757        .map_err(|_| local_tool_history_unsupported())?;
5758    let saved = binding
5759        .storage
5760        .load_session(&session.id)
5761        .await
5762        .map_err(|_| local_tool_history_unsupported())?
5763        .ok_or_else(local_tool_history_unsupported)?;
5764    if serde_json::to_value(&saved.provider_transcript)
5765        .map_err(|_| local_tool_history_unsupported())?
5766        != serde_json::to_value(&latest.provider_transcript)
5767            .map_err(|_| local_tool_history_unsupported())?
5768    {
5769        return Err(local_tool_history_unsupported());
5770    }
5771    let start = saved
5772        .messages
5773        .iter()
5774        .position(|message| message.id == suffix[0].id)
5775        .ok_or_else(local_tool_history_unsupported)?;
5776    if saved.messages.len() < latest.messages.len()
5777        || serde_json::to_value(&saved.messages[..latest.messages.len()])
5778            .map_err(|_| local_tool_history_unsupported())?
5779            != serde_json::to_value(&latest.messages)
5780                .map_err(|_| local_tool_history_unsupported())?
5781        || serde_json::to_value(saved.messages.get(start..start + suffix.len()))
5782            .map_err(|_| local_tool_history_unsupported())?
5783            != serde_json::to_value(Some(&suffix[..]))
5784                .map_err(|_| local_tool_history_unsupported())?
5785    {
5786        return Err(local_tool_history_unsupported());
5787    }
5788    // Append-safe reconciliation may add a concurrent Host User; it may never
5789    // rewrite the validated Host prefix or any worker row being committed.
5790    let expected = latest.messages.iter().chain(&suffix);
5791    for message in expected {
5792        let matches: Vec<_> = saved
5793            .messages
5794            .iter()
5795            .filter(|current| current.id == message.id)
5796            .collect();
5797        if matches.len() != 1
5798            || serde_json::to_value(matches[0]).map_err(|_| local_tool_history_unsupported())?
5799                != serde_json::to_value(message).map_err(|_| local_tool_history_unsupported())?
5800        {
5801            *session = before;
5802            return Err(local_tool_history_unsupported());
5803        }
5804    }
5805    if cancel.is_cancelled() || !binding.router.owns_run(&session.id, run_id).await {
5806        return Err(local_tool_history_unsupported());
5807    }
5808    staged.messages = saved.messages;
5809    *session = staged;
5810    Ok(())
5811}
5812
5813#[derive(Default)]
5814struct ReadOnlyActorCollector {
5815    start: Option<(String, serde_json::Value)>,
5816    result: Option<String>,
5817    messages: Option<Vec<bamboo_agent_core::Message>>,
5818}
5819impl ReadOnlyActorCollector {
5820    fn event(&mut self, value: &serde_json::Value) -> Result<bool, AgentError> {
5821        if self.messages.is_some() {
5822            return Err(plain_actor_unsupported());
5823        }
5824        if value["type"] == "owned_readonly_transcript" {
5825            let tail: bamboo_subagent::proto::ReadOnlyActorTranscript =
5826                serde_json::from_value(value.clone()).map_err(|_| plain_actor_unsupported())?;
5827            tail.validate().map_err(|_| plain_actor_unsupported())?;
5828            let bamboo_subagent::proto::ReadOnlyActorTranscript::Complete { messages } = tail;
5829            let typed: Vec<bamboo_agent_core::Message> = messages
5830                .iter()
5831                .map(|raw| {
5832                    let message: bamboo_agent_core::Message =
5833                        serde_json::from_value(raw.clone())
5834                            .map_err(|_| plain_actor_unsupported())?;
5835                    if serde_json::to_value(&message).map_err(|_| plain_actor_unsupported())?
5836                        != *raw
5837                    {
5838                        return Err(plain_actor_unsupported());
5839                    }
5840                    Ok(message)
5841                })
5842                .collect::<Result<_, _>>()?;
5843            let (id, args) = self
5844                .start
5845                .as_ref()
5846                .ok_or_else(|| plain_actor_unsupported())?;
5847            let call = typed[0]
5848                .tool_calls
5849                .as_ref()
5850                .and_then(|calls| {
5851                    if calls.len() == 1 {
5852                        calls.first()
5853                    } else {
5854                        None
5855                    }
5856                })
5857                .ok_or_else(|| plain_actor_unsupported())?;
5858            if call.id != *id
5859                || call.function.name != "Glob"
5860                || serde_json::from_str::<serde_json::Value>(&call.function.arguments)
5861                    .map_err(|_| plain_actor_unsupported())?
5862                    != *args
5863                || typed[1].tool_call_id.as_ref() != Some(id)
5864                || typed[1].tool_success != Some(true)
5865                || self.result.as_ref() != Some(&typed[1].content)
5866            {
5867                return Err(plain_actor_unsupported());
5868            }
5869            self.messages = Some(typed);
5870            return Ok(false);
5871        }
5872        let event: AgentEvent =
5873            serde_json::from_value(value.clone()).map_err(|_| plain_actor_unsupported())?;
5874        match event {
5875            AgentEvent::ToolStart {
5876                tool_call_id,
5877                tool_name,
5878                arguments,
5879            } => {
5880                if tool_name != "Glob"
5881                    || tool_call_id.trim().is_empty()
5882                    || self.start.is_some()
5883                    || !arguments.is_object()
5884                {
5885                    return Err(plain_actor_unsupported());
5886                }
5887                self.start = Some((tool_call_id, arguments));
5888                Ok(true)
5889            }
5890            AgentEvent::ToolComplete {
5891                tool_call_id,
5892                result,
5893            } => {
5894                if self.start.as_ref().map(|s| &s.0) != Some(&tool_call_id)
5895                    || self.result.is_some()
5896                    || !result.success
5897                    || !result.images.is_empty()
5898                {
5899                    return Err(plain_actor_unsupported());
5900                }
5901                self.result = Some(result.result);
5902                Ok(true)
5903            }
5904            AgentEvent::ToolToken { tool_call_id, .. } => {
5905                if self.start.as_ref().map(|s| &s.0) != Some(&tool_call_id) || self.result.is_some()
5906                {
5907                    return Err(plain_actor_unsupported());
5908                }
5909                Ok(true)
5910            }
5911            AgentEvent::ToolLifecycle {
5912                tool_call_id,
5913                tool_name,
5914                is_mutating,
5915                phase,
5916                error,
5917                ..
5918            } => {
5919                if tool_name != "Glob"
5920                    || is_mutating
5921                    || !matches!(phase.as_str(), "begin" | "finished")
5922                    || error.is_some()
5923                    || self.start.as_ref().map(|s| &s.0) != Some(&tool_call_id)
5924                {
5925                    return Err(plain_actor_unsupported());
5926                }
5927                Ok(true)
5928            }
5929            _ => plain_actor_event(value),
5930        }
5931    }
5932    fn finish(self, terminal: Option<&str>) -> Result<Vec<bamboo_agent_core::Message>, AgentError> {
5933        let messages = self.messages.ok_or_else(|| plain_actor_unsupported())?;
5934        if terminal != Some(messages[2].content.as_str()) || messages[2].content.trim().is_empty() {
5935            return Err(plain_actor_unsupported());
5936        }
5937        Ok(messages)
5938    }
5939}
5940
5941fn plain_actor_event(value: &serde_json::Value) -> Result<bool, AgentError> {
5942    let event: AgentEvent =
5943        serde_json::from_value(value.clone()).map_err(|_| plain_actor_unsupported())?;
5944    match event {
5945        AgentEvent::ReasoningToken { .. }
5946        | AgentEvent::ToolToken { .. }
5947        | AgentEvent::ToolStart { .. }
5948        | AgentEvent::ToolComplete { .. }
5949        | AgentEvent::ToolError { .. }
5950        | AgentEvent::ToolLifecycle { .. }
5951        | AgentEvent::ContextSummarized { .. }
5952        | AgentEvent::ContextArchived { .. }
5953        | AgentEvent::ContextCompressionStatus { .. }
5954        | AgentEvent::NeedClarification { .. }
5955        | AgentEvent::SubAgentStarted { .. }
5956        | AgentEvent::SubAgentEvent { .. }
5957        | AgentEvent::ChildApprovalRequested { .. } => Err(plain_actor_unsupported()),
5958        // Worker cache commits are not host Store commits. Publish only the
5959        // actual guarded host append after it returns a committed snapshot.
5960        AgentEvent::MessageAppended { .. }
5961        | AgentEvent::SessionHistoryCommitted { .. }
5962        | AgentEvent::VisibleMessageStart { .. }
5963        | AgentEvent::VisibleMessageDiscard { .. } => Ok(false),
5964        event if event.is_durable_change() => Ok(false),
5965        _ => Ok(true),
5966    }
5967}
5968
5969/// The `{kind,host}` placement descriptor stamped onto a child session's metadata
5970/// under `"placement"` — read back by the storage index → `SessionSummary.placement`
5971/// → the UI's machine badge. `None` for `Local` (those fall through to the DTO's
5972/// default of this backend's own host). The value is a JSON string matching
5973/// `bamboo_storage::SessionPlacement { kind, host }`.
5974fn placement_metadata(placement: &Placement, host_label: Option<&str>) -> Option<String> {
5975    // Prefer the cluster node's own label/host (its metadata) when the placement
5976    // maps to a node; else fall back to the raw endpoint host / pool name.
5977    let value = match placement {
5978        Placement::Local => return None,
5979        Placement::Remote { endpoint } => serde_json::json!({
5980            "kind": "remote",
5981            "host": host_label.map(str::to_string).unwrap_or_else(|| host_of_endpoint(endpoint)),
5982        }),
5983        Placement::Schedulable { pool } => serde_json::json!({
5984            "kind": "remote",
5985            "host": host_label.unwrap_or(pool),
5986        }),
5987    };
5988    serde_json::to_string(&value).ok()
5989}
5990
5991/// Extract the host from a `ws[s]://host:port[/path]` bus endpoint, for display.
5992fn host_of_endpoint(endpoint: &str) -> String {
5993    endpoint
5994        .trim()
5995        .trim_start_matches("wss://")
5996        .trim_start_matches("ws://")
5997        .split(['/', ':'])
5998        .next()
5999        .unwrap_or(endpoint)
6000        .to_string()
6001}
6002
6003async fn reconcile_already_admitted_claim(
6004    binding: &SessionInboxRuntimeBinding,
6005    session: &mut Session,
6006    claim: &SessionInboxClaim,
6007) -> crate::runtime::runner::Result<()> {
6008    let latest = binding
6009        .storage
6010        .load_session(&session.id)
6011        .await
6012        .map_err(|error| {
6013            AgentError::LLM(format!(
6014                "load canonical SessionInbox checkpoint for {}: {error}",
6015                session.id
6016            ))
6017        })?
6018        .ok_or_else(|| {
6019            AgentError::LLM(format!(
6020                "canonical SessionInbox target disappeared: {}",
6021                session.id
6022            ))
6023        })?;
6024    let Some(message) = latest
6025        .messages
6026        .iter()
6027        .find(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
6028        .cloned()
6029    else {
6030        return Err(AgentError::LLM(format!(
6031            "canonical admitted receipt for {} exists without transcript message {}",
6032            session.id, claim.envelope.id
6033        )));
6034    };
6035    if let Some(existing) = session
6036        .messages
6037        .iter_mut()
6038        .find(|existing| existing.id == message.id)
6039    {
6040        *existing = message;
6041    } else {
6042        session.add_message(message);
6043    }
6044    bamboo_domain::merge_session_inbox_admission(session, &latest);
6045    binding
6046        .inbox
6047        .ack(&session.id, claim)
6048        .await
6049        .map_err(|error| {
6050            AgentError::LLM(format!(
6051                "ack recovered canonical SessionInbox claim {}: {error}",
6052                claim.envelope.id
6053            ))
6054        })
6055}
6056
6057/// Checkpoint a worker-confirmed envelope into the canonical logical Session,
6058/// then create the permanent host receipt/remove its exact claim. This order is
6059/// the actor-side equivalent of the local state_bridge crash boundary.
6060async fn checkpoint_and_ack_canonical_claim(
6061    binding: &SessionInboxRuntimeBinding,
6062    session: &mut Session,
6063    claim: &SessionInboxClaim,
6064    fenced: Option<(&bamboo_storage::SessionStoreV2, &ActorActivationFence)>,
6065) -> crate::runtime::runner::Result<()> {
6066    if claim.envelope.target_session_id != session.id {
6067        return Err(AgentError::LLM(format!(
6068            "canonical SessionInbox claim target {} does not match active logical session {}",
6069            claim.envelope.target_session_id, session.id
6070        )));
6071    }
6072    if binding
6073        .inbox
6074        .was_admitted(&session.id, &claim.envelope.id)
6075        .await
6076        .map_err(|error| {
6077            AgentError::LLM(format!(
6078                "inspect canonical admitted receipt {}: {error}",
6079                claim.envelope.id
6080            ))
6081        })?
6082    {
6083        return reconcile_already_admitted_claim(binding, session, claim).await;
6084    }
6085
6086    if let Some((store, fence)) = fenced {
6087        let committed = store
6088            .confirm_actor_claim_context(
6089                concrete_file_inbox(binding)?,
6090                bamboo_storage::ActorClaimContextSeed {
6091                    fence: fence.clone(),
6092                    expected_created_at: session.created_at,
6093                    expected_messages: session.messages.clone(),
6094                    expected_provider_transcript: session.provider_transcript.clone(),
6095                    expected_admission: session.session_inbox_admission().cloned(),
6096                    claims: vec![claim.clone()],
6097                },
6098            )
6099            .await;
6100        let committed = match committed {
6101            Ok(committed) => committed,
6102            Err(error) => {
6103                // An error after atomic replacement may still have advanced
6104                // Main. Keep the claim unacked and use that canonical context
6105                // for the SDK's terminal status save.
6106                adopt_latest_actor_context(store, session).await;
6107                return Err(AgentError::LLM(format!(
6108                    "confirm fenced Actor claim context unconfirmed: {error}"
6109                )));
6110            }
6111        };
6112        bamboo_domain::merge_session_inbox_admission(session, &committed);
6113        session.messages = committed.messages;
6114        session.provider_transcript = committed.provider_transcript;
6115        session.updated_at = committed.updated_at;
6116        if !session
6117            .messages
6118            .iter()
6119            .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
6120            || !session
6121                .session_inbox_admission()
6122                .is_some_and(|cursor| cursor.contains(&claim.envelope.id))
6123        {
6124            return Err(AgentError::LLM(
6125                "fenced Actor claim confirmation lost durable proof".into(),
6126            ));
6127        }
6128        return binding
6129            .inbox
6130            .ack(&session.id, claim)
6131            .await
6132            .map_err(|error| {
6133                AgentError::LLM(format!(
6134                    "ack fenced Actor claim {} after confirmation: {error}",
6135                    claim.envelope.id
6136                ))
6137            });
6138    }
6139
6140    let transcript_has_id = session
6141        .messages
6142        .iter()
6143        .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope));
6144    if session
6145        .messages
6146        .iter()
6147        .any(|message| message.id == claim.envelope.id.as_str())
6148        && !transcript_has_id
6149    {
6150        return Err(AgentError::LLM(format!(
6151            "canonical SessionInbox id {} collides with a non-matching transcript message",
6152            claim.envelope.id
6153        )));
6154    }
6155    let cursor_has_id = session
6156        .session_inbox_admission()
6157        .is_some_and(|state| state.contains(&claim.envelope.id));
6158    if cursor_has_id && !transcript_has_id {
6159        return Err(AgentError::LLM(format!(
6160            "canonical SessionInbox cursor exists without transcript message {}",
6161            claim.envelope.id
6162        )));
6163    }
6164    let before = session.clone();
6165    if !transcript_has_id {
6166        let message = claim.envelope.to_provider_message().map_err(|error| {
6167            AgentError::LLM(format!(
6168                "translate canonical SessionInbox envelope {}: {error}",
6169                claim.envelope.id
6170            ))
6171        })?;
6172        session.add_message(message);
6173    }
6174    session
6175        .session_inbox_admission_mut()
6176        .record(claim.envelope.id.clone(), claim.generation);
6177    session.updated_at = chrono::Utc::now();
6178
6179    if let Err(error) = binding
6180        .persistence
6181        .checkpoint_runtime_session(session)
6182        .await
6183    {
6184        *session = before;
6185        return Err(AgentError::LLM(format!(
6186            "checkpoint canonical SessionInbox claim {}: {error}",
6187            claim.envelope.id
6188        )));
6189    }
6190    if !session
6191        .messages
6192        .iter()
6193        .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
6194    {
6195        *session = before;
6196        return Err(AgentError::LLM(format!(
6197            "canonical SessionInbox checkpoint lost typed transcript proof for {}",
6198            claim.envelope.id
6199        )));
6200    }
6201    binding
6202        .inbox
6203        .ack(&session.id, claim)
6204        .await
6205        .map_err(|error| {
6206            AgentError::LLM(format!(
6207                "ack canonical SessionInbox claim {} after checkpoint: {error}",
6208                claim.envelope.id
6209            ))
6210        })
6211}
6212
6213fn concrete_file_inbox(
6214    binding: &SessionInboxRuntimeBinding,
6215) -> Result<bamboo_storage::FileSessionInbox, AgentError> {
6216    binding
6217        .inbox
6218        .as_any()
6219        .and_then(|inbox| inbox.downcast_ref::<bamboo_storage::FileSessionInbox>())
6220        .cloned()
6221        .ok_or_else(|| AgentError::LLM("remote Actor requires canonical FileSessionInbox".into()))
6222}
6223
6224async fn adopt_latest_actor_context(store: &bamboo_storage::SessionStoreV2, session: &mut Session) {
6225    if let Ok(Some(latest)) = store.load_session(&session.id).await {
6226        if latest.created_at == session.created_at {
6227            bamboo_domain::merge_session_inbox_admission(session, &latest);
6228            session.messages = latest.messages;
6229            session.provider_transcript = latest.provider_transcript;
6230            session.updated_at = latest.updated_at;
6231        }
6232    }
6233}
6234
6235/// Durably seed claimed typed messages into the canonical host transcript
6236/// before dispatching them to any actor worker, while deliberately leaving the
6237/// admission cursor and `cur/` claims untouched.
6238///
6239/// This is the cross-placement lost-confirmation invariant: if worker A admits
6240/// and reasons over the batch but its confirmations are lost, a retry on worker
6241/// B receives a host snapshot already containing each stable typed message
6242/// exactly once. Worker B's local safe boundary then records/acks the same ids
6243/// without appending duplicates. Only an exact worker confirmation advances the
6244/// host cursor; only a durable cursor checkpoint precedes host ack.
6245async fn checkpoint_claim_context_before_dispatch(
6246    binding: &SessionInboxRuntimeBinding,
6247    session: &mut Session,
6248    claims: &[SessionInboxClaim],
6249    fenced: Option<(&bamboo_storage::SessionStoreV2, &ActorActivationFence)>,
6250) -> crate::runtime::runner::Result<()> {
6251    if claims.is_empty() {
6252        return Ok(());
6253    }
6254    let before = session.clone();
6255    let mut additions = Vec::new();
6256    for claim in claims {
6257        if claim.envelope.target_session_id != session.id {
6258            return Err(AgentError::LLM(format!(
6259                "canonical SessionInbox claim target {} does not match actor session {}",
6260                claim.envelope.target_session_id, session.id
6261            )));
6262        }
6263        let matching = session
6264            .messages
6265            .iter()
6266            .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope));
6267        if session
6268            .messages
6269            .iter()
6270            .any(|message| message.id == claim.envelope.id.as_str())
6271            && !matching
6272        {
6273            return Err(AgentError::LLM(format!(
6274                "canonical SessionInbox id {} collides before actor dispatch",
6275                claim.envelope.id
6276            )));
6277        }
6278        if session
6279            .session_inbox_admission()
6280            .is_some_and(|state| state.contains(&claim.envelope.id))
6281            && !matching
6282        {
6283            return Err(AgentError::LLM(format!(
6284                "canonical SessionInbox cursor exists without transcript proof for {}",
6285                claim.envelope.id
6286            )));
6287        }
6288        if !matching {
6289            let message = claim.envelope.to_provider_message().map_err(|error| {
6290                AgentError::LLM(format!(
6291                    "translate canonical SessionInbox envelope {} before actor dispatch: {error}",
6292                    claim.envelope.id
6293                ))
6294            })?;
6295            additions.push(message);
6296        }
6297    }
6298    let fenced_seed = fenced.is_some();
6299    if let Some((store, fence)) = fenced {
6300        let committed = store
6301            .seed_actor_claim_context(
6302                concrete_file_inbox(binding)?,
6303                bamboo_storage::ActorClaimContextSeed {
6304                    fence: fence.clone(),
6305                    expected_created_at: session.created_at,
6306                    expected_messages: session.messages.clone(),
6307                    expected_provider_transcript: session.provider_transcript.clone(),
6308                    expected_admission: session.session_inbox_admission().cloned(),
6309                    claims: claims.to_vec(),
6310                },
6311            )
6312            .await;
6313        let committed = match committed {
6314            Ok(committed) => committed,
6315            Err(error) => {
6316                // Publication may have occurred before an unconfirmed
6317                // readback. Do not dispatch or ACK; adopt only the current
6318                // canonical context so SDK can still save an error status.
6319                adopt_latest_actor_context(store, session).await;
6320                return Err(AgentError::LLM(format!(
6321                    "seed fenced Actor claim context unconfirmed: {error}"
6322                )));
6323            }
6324        };
6325        bamboo_domain::merge_session_inbox_admission(session, &committed);
6326        session.messages = committed.messages;
6327        session.provider_transcript = committed.provider_transcript;
6328        session.updated_at = committed.updated_at;
6329    } else {
6330        for message in additions {
6331            session.add_message(message);
6332        }
6333        session.updated_at = chrono::Utc::now();
6334        if let Err(error) = binding
6335            .persistence
6336            .checkpoint_runtime_session(session)
6337            .await
6338        {
6339            *session = before;
6340            return Err(AgentError::LLM(format!(
6341                "checkpoint canonical SessionInbox actor context: {error}"
6342            )));
6343        }
6344    }
6345    for claim in claims {
6346        if !session
6347            .messages
6348            .iter()
6349            .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
6350        {
6351            if !fenced_seed {
6352                *session = before;
6353            }
6354            return Err(AgentError::LLM(format!(
6355                "actor context checkpoint lost typed transcript proof for {}",
6356                claim.envelope.id
6357            )));
6358        }
6359    }
6360    Ok(())
6361}
6362
6363async fn claim_canonical_deliveries(
6364    binding: &SessionInboxRuntimeBinding,
6365    session: &mut Session,
6366    activation_run_id: &str,
6367    limit: usize,
6368    fenced: Option<(&bamboo_storage::SessionStoreV2, &ActorActivationFence)>,
6369) -> crate::runtime::runner::Result<Vec<(SessionInboxClaim, SessionMessageDelivery)>> {
6370    let claims = binding
6371        .inbox
6372        .claim_for_turn(&session.id, limit, Some(activation_run_id))
6373        .await
6374        .map_err(|error| {
6375            AgentError::LLM(format!(
6376                "claim canonical SessionInbox for active actor {}: {error}",
6377                session.id
6378            ))
6379        })?;
6380    if claims.is_empty() {
6381        return Ok(Vec::new());
6382    }
6383    let mut unconfirmed = Vec::with_capacity(claims.len());
6384    for claim in claims {
6385        if binding
6386            .inbox
6387            .was_admitted(&session.id, &claim.envelope.id)
6388            .await
6389            .map_err(|error| {
6390                AgentError::LLM(format!(
6391                    "inspect canonical SessionInbox claim {}: {error}",
6392                    claim.envelope.id
6393                ))
6394            })?
6395        {
6396            reconcile_already_admitted_claim(binding, session, &claim).await?;
6397            continue;
6398        }
6399        unconfirmed.push(claim);
6400    }
6401    checkpoint_claim_context_before_dispatch(binding, session, &unconfirmed, fenced).await?;
6402
6403    let mut deliveries = Vec::with_capacity(unconfirmed.len());
6404    for claim in unconfirmed {
6405        // Cursor+transcript without the permanent tombstone is the recoverable
6406        // crash window after an exact worker confirmation was checkpointed but
6407        // before host ack removed `cur/`. Finish that ack without exposing the
6408        // message to another provider run.
6409        if session
6410            .session_inbox_admission()
6411            .is_some_and(|state| state.contains(&claim.envelope.id))
6412        {
6413            binding
6414                .inbox
6415                .ack(&session.id, &claim)
6416                .await
6417                .map_err(|error| {
6418                    AgentError::LLM(format!(
6419                        "finish confirmed canonical SessionInbox ack {}: {error}",
6420                        claim.envelope.id
6421                    ))
6422                })?;
6423            continue;
6424        }
6425        let delivery = SessionMessageDelivery {
6426            target_session_id: session.id.clone(),
6427            envelope: claim.envelope.clone(),
6428            canonical_claim_generation: claim.generation,
6429            activation_run_id: activation_run_id.to_string(),
6430            activation_policy: claim.activation_policy,
6431        };
6432        deliveries.push((claim, delivery));
6433    }
6434    Ok(deliveries)
6435}
6436
6437async fn forward_next_canonical_claim(
6438    client: &mut dyn bamboo_subagent::ChildLink,
6439    binding: &SessionInboxRuntimeBinding,
6440    session: &mut Session,
6441    activation_run_id: &str,
6442    inflight: &mut VecDeque<SessionInboxClaim>,
6443    fenced: Option<(&bamboo_storage::SessionStoreV2, &ActorActivationFence)>,
6444) -> crate::runtime::runner::Result<()> {
6445    if !inflight.is_empty() {
6446        return Ok(());
6447    }
6448    let Some((claim, delivery)) =
6449        claim_canonical_deliveries(binding, session, activation_run_id, 1, fenced)
6450            .await?
6451            .pop()
6452    else {
6453        return Ok(());
6454    };
6455    client
6456        .send(ParentFrame::SessionMessage { delivery })
6457        .await
6458        .map_err(|error| {
6459            AgentError::LLM(format!(
6460                "forward canonical SessionInbox claim {} to active actor: {error}",
6461                claim.envelope.id
6462            ))
6463        })?;
6464    inflight.push_back(claim);
6465    Ok(())
6466}
6467
6468/// Borrowed and per-run-owned inputs for one actor frame pump.
6469struct ActorDriveContext<'a> {
6470    client: &'a mut dyn bamboo_subagent::ChildLink,
6471    parent_session_id: &'a str,
6472    child_session_id: &'a str,
6473    child_attempt: u32,
6474    approval_registry: Option<&'a super::approval_registry::SharedApprovalRegistry>,
6475    approval_decider: Option<&'a Arc<dyn ChildApprovalDecider>>,
6476    approval_reviewer: Option<&'a Arc<dyn ChildApprovalReviewer>>,
6477    escalation_bridge: Option<bamboo_subagent::executor::HostBridge>,
6478    event_tx: &'a mpsc::Sender<AgentEvent>,
6479    cancel_token: &'a CancellationToken,
6480    live_rx: &'a mut mpsc::UnboundedReceiver<ParentFrame>,
6481    delivery_rx: &'a mut mpsc::UnboundedReceiver<u64>,
6482    logical_session: &'a mut Session,
6483    expected_permission_posture: Option<ExpectedPermissionPosture>,
6484    expected_creation: Option<&'a bamboo_subagent::proto::ChildCreationIdentity>,
6485    session_inbox_runtime: Option<&'a SessionInboxRuntimeBinding>,
6486    actor_directory_store: Option<&'a bamboo_storage::SessionStoreV2>,
6487    canonical_subagent_tool: Option<Arc<dyn bamboo_agent_core::tools::Tool>>,
6488    ticket_service: Option<Arc<bamboo_tickets::TicketService>>,
6489    activation_run_id: Option<&'a str>,
6490    execution_epoch: u64,
6491    expected_source_actor_id: &'a str,
6492    initial_inflight_claims: VecDeque<SessionInboxClaim>,
6493    plain_actor: bool,
6494    remote_environment_lease: bool,
6495    readonly_output: Option<&'a mut Vec<bamboo_agent_core::Message>>,
6496    local_history_tools: Option<&'a [String]>,
6497    local_history_read_only: bool,
6498    strict_history_output: Option<&'a mut Option<StrictTerminalHistory>>,
6499    plain_input: Option<&'a PlainActorActivation>,
6500    canonical_activation: Option<&'a ActorActivationFence>,
6501    canonical_placement_ref: Option<&'a bamboo_domain::ActorPlacementRef>,
6502    actor_event_observer: Option<&'a dyn ActorEventObserver>,
6503    plain_run: Option<(&'a RunSpec, &'a AtomicU64)>,
6504    first_frame_timeout: Option<Duration>,
6505}
6506
6507#[derive(Debug, Clone, PartialEq, Eq)]
6508struct ExpectedPermissionPosture {
6509    policy_revision: u64,
6510    resolution: bamboo_domain::PermissionModeResolution,
6511    expected_audit_revision: Option<u64>,
6512    executor_mapping: String,
6513}
6514
6515#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6516enum PermissionPostureHandshake {
6517    /// Legacy/custom actors were dispatched without a typed posture contract.
6518    NotRequired,
6519    /// The host dispatched an exact posture and no matching, durably recorded
6520    /// worker activation has arrived yet.
6521    Awaiting,
6522    /// One worker posture matched and its host-owned audit write succeeded.
6523    Confirmed,
6524}
6525
6526impl PermissionPostureHandshake {
6527    fn new(expected: Option<&ExpectedPermissionPosture>) -> Self {
6528        if expected.is_some() {
6529            Self::Awaiting
6530        } else {
6531            Self::NotRequired
6532        }
6533    }
6534
6535    fn is_awaiting(self) -> bool {
6536        self == Self::Awaiting
6537    }
6538
6539    fn posture_was_confirmed(self) -> bool {
6540        self == Self::Confirmed
6541    }
6542}
6543
6544fn permission_posture_seed_from_event(
6545    session: &Session,
6546    event: &AgentEvent,
6547) -> Result<Option<bamboo_domain::PermissionAuditSeed>, String> {
6548    let AgentEvent::PermissionPostureActivated {
6549        session_id,
6550        policy_revision,
6551        requested_mode,
6552        effective_mode,
6553        executor_mapping,
6554    } = event
6555    else {
6556        return Ok(None);
6557    };
6558    if session_id != &session.id {
6559        return Err("permission posture event targets a different logical session".to_string());
6560    }
6561    let requested = bamboo_domain::SessionPermissionMode::from_audit_str(requested_mode)
6562        .ok_or_else(|| "permission posture event has an invalid requested mode".to_string())?;
6563    let effective = bamboo_domain::PermissionMode::from_audit_str(effective_mode)
6564        .ok_or_else(|| "permission posture event has an invalid effective mode".to_string())?;
6565    let resolution = bamboo_domain::PermissionModeResolution {
6566        requested,
6567        effective,
6568    };
6569    if !resolution.is_consistent() {
6570        return Err("permission posture event has an inconsistent mode pair".to_string());
6571    }
6572    let current_requested = session
6573        .agent_runtime_state
6574        .as_ref()
6575        .map(|state| state.effective_permission_mode())
6576        .unwrap_or_default();
6577    if current_requested != requested {
6578        return Err("permission posture event is stale for the host typed mode".to_string());
6579    }
6580    let mapping_chars = executor_mapping.chars().count();
6581    if mapping_chars == 0 || mapping_chars > bamboo_domain::MAX_PERMISSION_EXECUTOR_MAPPING_CHARS {
6582        return Err("permission posture event has an invalid executor mapping".to_string());
6583    }
6584    Ok(Some(bamboo_domain::PermissionAuditSeed::new(
6585        *policy_revision,
6586        resolution,
6587        executor_mapping,
6588    )))
6589}
6590
6591fn actor_event_logical_identity(
6592    session: &Session,
6593    parent_session_id: &str,
6594    creation: Option<&bamboo_subagent::proto::ChildCreationIdentity>,
6595) -> LogicalSessionIdentity {
6596    LogicalSessionIdentity {
6597        creation: creation.cloned(),
6598        session_id: session.id.clone(),
6599        parent_session_id: session
6600            .parent_session_id
6601            .clone()
6602            .or_else(|| Some(parent_session_id.to_string())),
6603        root_session_id: if session.root_session_id.trim().is_empty() {
6604            parent_session_id.to_string()
6605        } else {
6606            session.root_session_id.clone()
6607        },
6608    }
6609}
6610
6611fn validate_strict_history_terminal(
6612    watermark: Option<&bamboo_subagent::ActorEventWatermark>,
6613    session: &Session,
6614    parent_session_id: &str,
6615    activation_run_id: Option<&str>,
6616    execution_epoch: u64,
6617    creation: Option<&bamboo_subagent::proto::ChildCreationIdentity>,
6618    consumed_seq: u64,
6619) -> Result<(), AgentError> {
6620    let identity = actor_event_logical_identity(session, parent_session_id, creation);
6621    if watermark
6622        .zip(activation_run_id)
6623        .is_some_and(|(watermark, run_id)| {
6624            watermark.matches_consumed_run(&identity, run_id, execution_epoch, consumed_seq)
6625        })
6626    {
6627        Ok(())
6628    } else {
6629        Err(AgentError::LLM("actor_history_tail_incomplete".into()))
6630    }
6631}
6632
6633fn validate_actor_event_batch(
6634    batch: &ActorEventBatch,
6635    logical_session: &Session,
6636    parent_session_id: &str,
6637    activation_run_id: Option<&str>,
6638    execution_epoch: u64,
6639    expected_source_actor_id: &str,
6640    expected_creation: Option<&bamboo_subagent::proto::ChildCreationIdentity>,
6641) -> Result<(), AgentError> {
6642    batch
6643        .validate()
6644        .map_err(|error| AgentError::LLM(format!("actor emitted invalid event batch: {error}")))?;
6645    let identity = batch.logical_session.as_ref().ok_or_else(|| {
6646        AgentError::LLM("actor event batch omitted its logical session identity".to_string())
6647    })?;
6648    // Fence against the exact canonical identity used to build RunSpec. Some
6649    // migrated/test sessions have not yet materialized parent ancestry on the
6650    // in-memory Session, so comparing the batch directly with those raw fields
6651    // can reject the identity the host itself just dispatched.
6652    let expected_identity =
6653        actor_event_logical_identity(logical_session, parent_session_id, expected_creation);
6654    if identity != &expected_identity {
6655        return Err(AgentError::LLM(
6656            "actor event batch targets a different logical session".to_string(),
6657        ));
6658    }
6659    if batch.activation_id.as_deref() != activation_run_id {
6660        return Err(AgentError::LLM(
6661            "actor event batch belongs to a stale activation".to_string(),
6662        ));
6663    }
6664    if batch.execution_epoch != execution_epoch {
6665        return Err(AgentError::LLM(
6666            "actor event batch belongs to a stale execution epoch".to_string(),
6667        ));
6668    }
6669    if batch
6670        .source_actor_id
6671        .as_deref()
6672        .is_some_and(|source| source != expected_source_actor_id)
6673    {
6674        return Err(AgentError::LLM(
6675            "actor event batch declares a different physical actor".to_string(),
6676        ));
6677    }
6678    Ok(())
6679}
6680
6681/// The plain Actor path already owns a durable Directory activation. Check that
6682/// exact owner before admitting either batched or rolling-upgrade live frames;
6683/// worker-provided batch identity alone is never an activation authority.
6684async fn validate_directory_actor_event(
6685    store: &bamboo_storage::SessionStoreV2,
6686    fence: &ActorActivationFence,
6687    session: &Session,
6688    run_id: Option<&str>,
6689) -> Result<bamboo_domain::ActorDirectoryEntry, AgentError> {
6690    store
6691        .validate_fence(fence, chrono::Utc::now())
6692        .await
6693        .map_err(|_| AgentError::LLM("actor event owner is stale".into()))?;
6694    let entry = store
6695        .inspect_actor(&fence.actor_id)
6696        .await
6697        .map_err(|_| AgentError::LLM("actor event authority is unavailable".into()))?;
6698    if !entry.actor.matches_session(session)
6699        || entry.actor.project_id != project_id_for_actor_run(session)?.map(|id| id.to_string())
6700        || !entry.activation.as_ref().is_some_and(|current| {
6701            current.matches_fence(fence)
6702                && current.status == bamboo_domain::ActorActivationStatus::Running
6703        })
6704        || run_id.is_some_and(|id| id != fence.run_id)
6705    {
6706        return Err(AgentError::LLM("actor event authority changed".into()));
6707    }
6708    Ok(entry)
6709}
6710
6711/// The Worker supplies only a cursor. All identity and scope inputs come from
6712/// this Host's active drive and its canonical durable Session store.
6713async fn inspect_active_owned_tree(
6714    store: &bamboo_storage::SessionStoreV2,
6715    binding: &SessionInboxRuntimeBinding,
6716    run_id: &str,
6717    child_id: &str,
6718    parent_id: &str,
6719    logical_session: &Session,
6720    creation: &bamboo_subagent::proto::ChildCreationIdentity,
6721    plain_input: Option<&PlainActorActivation>,
6722    cursor: Option<&str>,
6723) -> Option<serde_json::Value> {
6724    use bamboo_agent_core::storage::Storage;
6725    let current = match store.load_session(child_id).await {
6726        Ok(Some(current)) => current,
6727        _ => {
6728            tracing::warn!("owned tree denied: canonical caller unavailable");
6729            return None;
6730        }
6731    };
6732    let current_project = match project_id_for_actor_run(&current) {
6733        Ok(project) => project,
6734        Err(_) => {
6735            tracing::warn!("owned tree denied: invalid canonical Project");
6736            return None;
6737        }
6738    };
6739    let logical_project = match project_id_for_actor_run(logical_session) {
6740        Ok(project) => project,
6741        Err(_) => {
6742            tracing::warn!("owned tree denied: invalid logical Project");
6743            return None;
6744        }
6745    };
6746    if current.id != child_id
6747        || current.kind != bamboo_domain::SessionKind::Child
6748        || current.parent_session_id.as_deref() != Some(parent_id)
6749        || current.parent_session_id != logical_session.parent_session_id
6750        || current.root_session_id != logical_session.root_session_id
6751        || current.created_at != creation.created_at
6752        || current.created_at != logical_session.created_at
6753        || current.spawn_depth != creation.spawn_depth
6754        || current.spawn_depth != logical_session.spawn_depth
6755        || current_project != logical_project
6756    {
6757        tracing::warn!("owned tree denied: caller lifetime or Project changed");
6758        return None;
6759    }
6760    if let Some(activation) = plain_input {
6761        validate_directory_actor_event(
6762            activation.store.as_ref(),
6763            &activation.fence,
6764            &current,
6765            Some(run_id),
6766        )
6767        .await
6768        .ok()?;
6769    } else if !binding.router.owns_run(child_id, run_id).await {
6770        tracing::warn!("owned tree denied: activation owner changed");
6771        return None;
6772    }
6773    let page = match crate::session_app::child_session::owned_tree::inspect_owned_tree(
6774        store, child_id, cursor,
6775    )
6776    .await
6777    {
6778        Ok(page) => page,
6779        Err(error) => {
6780            tracing::warn!(?error, "owned tree denied: canonical tree read failed");
6781            return None;
6782        }
6783    };
6784    let again = match store.load_session(child_id).await {
6785        Ok(Some(again)) => again,
6786        _ => {
6787            tracing::warn!("owned tree denied: canonical caller disappeared");
6788            return None;
6789        }
6790    };
6791    if again.created_at != current.created_at
6792        || again.parent_session_id != current.parent_session_id
6793        || again.root_session_id != current.root_session_id
6794        || again.spawn_depth != current.spawn_depth
6795        || project_id_for_actor_run(&again).ok()? != current_project
6796    {
6797        tracing::warn!("owned tree denied: caller changed during inspection");
6798        return None;
6799    }
6800    if let Some(activation) = plain_input {
6801        validate_directory_actor_event(
6802            activation.store.as_ref(),
6803            &activation.fence,
6804            &again,
6805            Some(run_id),
6806        )
6807        .await
6808        .ok()?;
6809    } else if !binding.router.owns_run(child_id, run_id).await {
6810        tracing::warn!("owned tree denied: activation owner changed after inspection");
6811        return None;
6812    }
6813    serde_json::to_vec(&page)
6814        .ok()
6815        .filter(|bytes| bytes.len() <= 8192)?;
6816    Some(page)
6817}
6818
6819/// Resolve the caller of a mutating nested SubAgent RPC entirely from the
6820/// Host's active drive and canonical store. The frame never supplies identity,
6821/// Project, ancestry, or a run fence.
6822async fn load_active_subagent_caller(
6823    store: &bamboo_storage::SessionStoreV2,
6824    binding: &SessionInboxRuntimeBinding,
6825    run_id: &str,
6826    child_id: &str,
6827    parent_id: &str,
6828    logical_session: &Session,
6829    creation: &bamboo_subagent::proto::ChildCreationIdentity,
6830    plain_input: Option<&PlainActorActivation>,
6831) -> Option<Session> {
6832    use bamboo_agent_core::storage::Storage;
6833    let current = store.load_session(child_id).await.ok().flatten()?;
6834    let current_project = project_id_for_actor_run(&current).ok()?;
6835    let logical_project = project_id_for_actor_run(logical_session).ok()?;
6836    if current.id != child_id
6837        || current.kind != bamboo_domain::SessionKind::Child
6838        || current.parent_session_id.as_deref() != Some(parent_id)
6839        || current.parent_session_id != logical_session.parent_session_id
6840        || current.root_session_id != logical_session.root_session_id
6841        || current.created_at != creation.created_at
6842        || current.created_at != logical_session.created_at
6843        || current.spawn_depth != creation.spawn_depth
6844        || current.spawn_depth != logical_session.spawn_depth
6845        || current_project != logical_project
6846        || bamboo_domain::ActorSession::from_session(&current).is_err()
6847    {
6848        return None;
6849    }
6850    if let Some(activation) = plain_input {
6851        validate_directory_actor_event(
6852            activation.store.as_ref(),
6853            &activation.fence,
6854            &current,
6855            Some(run_id),
6856        )
6857        .await
6858        .ok()?;
6859    } else if !binding.router.owns_run(child_id, run_id).await {
6860        return None;
6861    }
6862    Some(current)
6863}
6864
6865async fn invoke_active_subagent(
6866    tool: &dyn bamboo_agent_core::tools::Tool,
6867    caller: &Session,
6868    args: serde_json::Value,
6869    tool_call_id: &str,
6870) -> serde_json::Value {
6871    use bamboo_agent_core::tools::{ToolCtx, ToolOutcome};
6872    let mut ctx = ToolCtx::none(tool_call_id.to_owned());
6873    ctx.session_id = Some(Arc::from(caller.id.as_str()));
6874    match tool.invoke(args, ctx).await {
6875        Ok(ToolOutcome::Completed(result))
6876            if result.images.is_empty()
6877                && serde_json::to_vec(&result).is_ok_and(|bytes| bytes.len() <= 16 * 1024) =>
6878        {
6879            serde_json::json!({"result":result})
6880        }
6881        _ => serde_json::json!({
6882            "error":"SubAgent operation failed or its result exceeded the limit; inspect logical child state before retrying"
6883        }),
6884    }
6885}
6886
6887/// Commit one already-persisted Worker clarification under the Host's current
6888/// Child birth and Run fence. Only the Host supplies caller/parent identity.
6889#[allow(clippy::too_many_arguments)]
6890async fn checkpoint_active_parent_question(
6891    store: &bamboo_storage::SessionStoreV2,
6892    binding: &SessionInboxRuntimeBinding,
6893    run_id: &str,
6894    child_id: &str,
6895    parent_id: &str,
6896    logical_session: &mut Session,
6897    creation: &bamboo_subagent::proto::ChildCreationIdentity,
6898    plain_input: Option<&PlainActorActivation>,
6899    observation: ParentQuestionCheckpointV1,
6900) -> Option<String> {
6901    use bamboo_agent_core::storage::Storage;
6902    if plain_input.is_some() || observation.validate_shape().is_err() {
6903        return None;
6904    }
6905    // SessionRepository's final save and the direct-parent answer resolver
6906    // use this same Host-owned lock. Hold it across the V2 read/CAS/save so
6907    // neither can read an older Child and overwrite the checkpoint afterward.
6908    let _session_guard = binding
6909        .parent_question_lock
6910        .as_ref()?
6911        .acquire_lock(child_id)
6912        .await;
6913    let caller = load_active_subagent_caller(
6914        store,
6915        binding,
6916        run_id,
6917        child_id,
6918        parent_id,
6919        logical_session,
6920        creation,
6921        plain_input,
6922    )
6923    .await?;
6924    let parent = store.load_session(parent_id).await.ok().flatten()?;
6925    if parent.created_at > caller.created_at || !binding.router.owns_run(child_id, run_id).await {
6926        return None;
6927    }
6928    let (saved, question) = store
6929        .checkpoint_parent_question(&caller, &parent, &observation)
6930        .await
6931        .ok()
6932        .flatten()?;
6933    // The current Run's local snapshot must carry the committed canonical
6934    // prefix into finalization; sdk/spawn rechecks disk before its last save.
6935    logical_session.messages = saved.messages;
6936    logical_session.pending_question = saved.pending_question;
6937    if let Some(reason) = saved.metadata.get("runtime.suspend_reason") {
6938        logical_session
6939            .metadata
6940            .insert("runtime.suspend_reason".into(), reason.clone());
6941    }
6942    if let Some(request) = saved
6943        .metadata
6944        .get(bamboo_domain::PARENT_QUESTION_REQUEST_KEY)
6945    {
6946        logical_session.metadata.insert(
6947            bamboo_domain::PARENT_QUESTION_REQUEST_KEY.into(),
6948            request.clone(),
6949        );
6950    }
6951    logical_session.metadata.insert(
6952        "runtime.actor_parent_question_handoff".into(),
6953        "true".into(),
6954    );
6955    Some(question.id.as_str().to_owned())
6956}
6957
6958/// An actor Worker cannot see the Host's canonical descendants. At the end of
6959/// an otherwise completed Child turn, apply the same orphan-child safety net
6960/// used by the in-process root loop before publishing this Child as terminal.
6961/// The existing Host wait, including an explicit `any` policy, always wins.
6962async fn ensure_active_nested_wait(
6963    tool: &dyn bamboo_agent_core::tools::Tool,
6964    store: &bamboo_storage::SessionStoreV2,
6965    binding: &SessionInboxRuntimeBinding,
6966    run_id: &str,
6967    child_id: &str,
6968    parent_id: &str,
6969    logical_session: &Session,
6970    creation: &bamboo_subagent::proto::ChildCreationIdentity,
6971    plain_input: Option<&PlainActorActivation>,
6972) -> Result<bool, AgentError> {
6973    use bamboo_agent_core::storage::Storage;
6974    use bamboo_agent_core::tools::{ToolCtx, ToolOutcome};
6975
6976    let caller = load_active_subagent_caller(
6977        store,
6978        binding,
6979        run_id,
6980        child_id,
6981        parent_id,
6982        logical_session,
6983        creation,
6984        plain_input,
6985    )
6986    .await
6987    .ok_or_else(|| AgentError::LLM("nested wait caller is no longer active".into()))?;
6988    if caller
6989        .agent_runtime_state
6990        .as_ref()
6991        .and_then(|state| state.waiting_for_children.as_ref())
6992        .is_some()
6993    {
6994        return Ok(true);
6995    }
6996
6997    let active = store
6998        .list_child_run_statuses(child_id)
6999        .await
7000        .map_err(|error| AgentError::LLM(format!("nested child status scan failed: {error}")))?
7001        .into_iter()
7002        .filter(|(_, status)| {
7003            !status.as_deref().is_some_and(|status| {
7004                matches!(
7005                    status,
7006                    "completed" | "error" | "timeout" | "cancelled" | "skipped"
7007                )
7008            })
7009        })
7010        .map(|(id, _)| id)
7011        .collect::<Vec<_>>();
7012    if active.is_empty() {
7013        return Ok(false);
7014    }
7015
7016    let mut ctx = ToolCtx::none(format!("host-nested-orphan-wait:{run_id}"));
7017    ctx.session_id = Some(Arc::from(caller.id.as_str()));
7018    let outcome = tokio::time::timeout(
7019        Duration::from_secs(30),
7020        tool.invoke(
7021            serde_json::json!({"action":"wait", "child_session_ids":active}),
7022            ctx,
7023        ),
7024    )
7025    .await
7026    .map_err(|_| AgentError::LLM("nested wait registration timed out".into()))?
7027    .map_err(|error| AgentError::LLM(format!("nested wait registration failed: {error}")))?;
7028    let wait_registered = match outcome {
7029        ToolOutcome::Completed(result) if result.success => {
7030            let payload: serde_json::Value = serde_json::from_str(&result.result)
7031                .map_err(|_| AgentError::LLM("nested wait result is invalid".into()))?;
7032            payload["status"] == "waiting"
7033        }
7034        _ => {
7035            return Err(AgentError::LLM(
7036                "nested wait registration was not completed".into(),
7037            ))
7038        }
7039    };
7040
7041    // A grandchild may finish while the wait is being registered and clear it.
7042    // A clear wait is safe only when no canonical descendants remain active.
7043    let latest = load_active_subagent_caller(
7044        store,
7045        binding,
7046        run_id,
7047        child_id,
7048        parent_id,
7049        logical_session,
7050        creation,
7051        plain_input,
7052    )
7053    .await
7054    .ok_or_else(|| AgentError::LLM("nested wait caller changed during registration".into()))?;
7055    if latest
7056        .agent_runtime_state
7057        .as_ref()
7058        .and_then(|state| state.waiting_for_children.as_ref())
7059        .is_some()
7060    {
7061        return Ok(true);
7062    }
7063    let still_active = store
7064        .list_child_run_statuses(child_id)
7065        .await
7066        .map_err(|error| {
7067            AgentError::LLM(format!("nested child wait verification failed: {error}"))
7068        })?
7069        .into_iter()
7070        .any(|(_, status)| {
7071            !status.as_deref().is_some_and(|status| {
7072                matches!(
7073                    status,
7074                    "completed" | "error" | "timeout" | "cancelled" | "skipped"
7075                )
7076            })
7077        });
7078    if still_active {
7079        return Err(AgentError::LLM(
7080            "nested child remains active without a durable parent wait".into(),
7081        ));
7082    }
7083    // Keep the current Run's suspend intent even if the last grandchild
7084    // finished and cleared the Host wait before SDK finalization. Its outcome
7085    // has already admitted a successor; publishing this Run as completed
7086    // would release the grandparent on the Child's interim response.
7087    Ok(wait_registered)
7088}
7089
7090fn actor_event_route_error(error: ActorEventRouteError) -> AgentError {
7091    let message = match error {
7092        ActorEventRouteError::SequenceGap { .. } => {
7093            "actor event sequence gap; authoritative session snapshot is required"
7094        }
7095        ActorEventRouteError::LifecycleMissing => {
7096            "actor emitted live content before its activation lifecycle event"
7097        }
7098        ActorEventRouteError::ForgedSession => {
7099            "actor event payload targets a different canonical session"
7100        }
7101        ActorEventRouteError::ConflictingReplay => {
7102            "actor replay reused an event sequence with different content"
7103        }
7104        ActorEventRouteError::StaleAuthority => "actor event owner is stale",
7105        ActorEventRouteError::SequenceExhausted => "actor event sequence is exhausted",
7106    };
7107    AgentError::LLM(message.into())
7108}
7109
7110const MAX_DISPLAY_CALLS: usize = 4_096;
7111const MAX_NESTED_DISPLAY_DEPTH: usize = 8;
7112const MAX_DISPLAY_ID_BYTES: usize = 256;
7113const MAX_DISPLAY_HOOK_RESULT_DEPTH: usize = 16;
7114
7115fn hook_result_for_display(result: HookResult, depth: usize) -> Option<HookResult> {
7116    if depth >= MAX_DISPLAY_HOOK_RESULT_DEPTH {
7117        return None;
7118    }
7119    Some(match result {
7120        HookResult::Continue => HookResult::Continue,
7121        HookResult::Mutated => HookResult::Mutated,
7122        HookResult::Allow => HookResult::Allow,
7123        HookResult::Ask => HookResult::Ask,
7124        HookResult::Deny { .. } => HookResult::Deny {
7125            reason: "Hook denied".into(),
7126        },
7127        HookResult::InjectContext { .. } => HookResult::InjectContext {
7128            text: "Hook context hidden".into(),
7129        },
7130        HookResult::WithContext { result, .. } => HookResult::WithContext {
7131            result: Box::new(hook_result_for_display(*result, depth + 1)?),
7132            text: "Hook context hidden".into(),
7133        },
7134        HookResult::Suspend { .. } => HookResult::Suspend {
7135            reason: "Hook suspended".into(),
7136        },
7137        HookResult::Abort { .. } => HookResult::Abort {
7138            reason: "Hook aborted".into(),
7139        },
7140    })
7141}
7142
7143#[derive(Clone, Copy, PartialEq, Eq)]
7144enum DisplayCallKind {
7145    Ordinary,
7146    PrivateBrowser,
7147    Reused,
7148}
7149
7150#[derive(Clone, Copy)]
7151struct DisplayCall {
7152    kind: DisplayCallKind,
7153    started: bool,
7154}
7155
7156#[derive(Default)]
7157struct ActorEventDisplay {
7158    calls: HashMap<String, DisplayCall>,
7159    hook_names: HashMap<String, usize>,
7160    children: HashMap<(String, String), Box<ActorEventDisplay>>,
7161    // Shared by every nested child: a per-level cap alone permits an
7162    // exponential number of identities across an actor event tree.
7163    identities: Arc<AtomicUsize>,
7164    overflowed: bool,
7165    nested_overflowed: bool,
7166}
7167
7168fn is_browser_tool_name(tool_name: &str) -> bool {
7169    tool_name
7170        .trim()
7171        .rsplit("::")
7172        .next()
7173        .is_some_and(|name| name.eq_ignore_ascii_case("browser"))
7174}
7175
7176fn known_browser_action(args: &serde_json::Value) -> bool {
7177    matches!(
7178        args.get("action").and_then(serde_json::Value::as_str),
7179        Some(
7180            "tabs"
7181                | "new_tab"
7182                | "activate_tab"
7183                | "close_tab"
7184                | "navigate"
7185                | "history"
7186                | "viewport"
7187                | "snapshot"
7188                | "click"
7189                | "click_at"
7190                | "hover"
7191                | "drag"
7192                | "fill"
7193                | "select_option"
7194                | "set_file_input"
7195                | "type"
7196                | "press"
7197                | "key"
7198                | "scroll"
7199                | "download"
7200                | "screenshot"
7201                | "dialog_respond"
7202        )
7203    )
7204}
7205
7206fn is_private_browser_call(tool_name: &str, args: &serde_json::Value) -> bool {
7207    is_browser_tool_name(tool_name)
7208        && (!known_browser_action(args)
7209            || args
7210                .get("action")
7211                .and_then(serde_json::Value::as_str)
7212                .is_some_and(|action| {
7213                    action.eq_ignore_ascii_case("download") || action == "set_file_input"
7214                })
7215            || args.get("data_base64").is_some())
7216}
7217
7218fn actor_arguments_for_display(tool_name: &str, args: &serde_json::Value) -> serde_json::Value {
7219    if is_browser_tool_name(tool_name) {
7220        // A malformed or unknown action has no reliable privacy class. A
7221        // mixed-version actor may emit it before its schema rejects it.
7222        if !known_browser_action(args) {
7223            return serde_json::json!({"details":"[redacted]"});
7224        }
7225    }
7226    tool_start_arguments_for_display(tool_name, args)
7227}
7228
7229impl ActorEventDisplay {
7230    fn fail_closed(&mut self) {
7231        self.identities
7232            .store(MAX_DISPLAY_CALLS + 1, Ordering::Relaxed);
7233        self.overflowed = true;
7234        self.calls.clear();
7235        self.hook_names.clear();
7236        self.nested_overflowed = true;
7237        self.children.clear();
7238    }
7239
7240    fn reserve_identity(&mut self) -> bool {
7241        if self.identities.fetch_add(1, Ordering::Relaxed) < MAX_DISPLAY_CALLS {
7242            true
7243        } else {
7244            self.fail_closed();
7245            false
7246        }
7247    }
7248
7249    fn remember_start(&mut self, id: &str, private: bool) -> DisplayCallKind {
7250        if self.overflowed || id.is_empty() || id.len() > MAX_DISPLAY_ID_BYTES {
7251            return DisplayCallKind::Reused;
7252        }
7253        let kind = if private {
7254            DisplayCallKind::PrivateBrowser
7255        } else {
7256            DisplayCallKind::Ordinary
7257        };
7258        if let Some(call) = self.calls.get_mut(id) {
7259            if call.started || call.kind != kind {
7260                call.kind = DisplayCallKind::Reused;
7261            }
7262            call.started = true;
7263            return call.kind;
7264        }
7265        if !self.reserve_identity() {
7266            return DisplayCallKind::Reused;
7267        }
7268        self.calls.insert(
7269            id.to_string(),
7270            DisplayCall {
7271                kind,
7272                started: true,
7273            },
7274        );
7275        kind
7276    }
7277
7278    fn remember_approval(&mut self, id: &str, private: bool) -> DisplayCallKind {
7279        if self.overflowed || id.is_empty() || id.len() > MAX_DISPLAY_ID_BYTES {
7280            return DisplayCallKind::Reused;
7281        }
7282        let kind = if private {
7283            DisplayCallKind::PrivateBrowser
7284        } else {
7285            DisplayCallKind::Ordinary
7286        };
7287        if let Some(call) = self.calls.get_mut(id) {
7288            if call.kind != kind {
7289                call.kind = DisplayCallKind::Reused;
7290            }
7291            return call.kind;
7292        }
7293        if !self.reserve_identity() {
7294            return DisplayCallKind::Reused;
7295        }
7296        self.calls.insert(
7297            id.to_string(),
7298            DisplayCall {
7299                kind,
7300                started: false,
7301            },
7302        );
7303        kind
7304    }
7305
7306    fn known_kind(&self, id: &str) -> Option<DisplayCallKind> {
7307        if self.overflowed || self.identities.load(Ordering::Relaxed) > MAX_DISPLAY_CALLS {
7308            return None;
7309        }
7310        self.calls
7311            .get(id)
7312            .filter(|call| call.started)
7313            .map(|call| call.kind)
7314    }
7315
7316    fn display_hook_name(&mut self, hook_name: &str) -> Option<String> {
7317        if self.overflowed || hook_name.is_empty() || hook_name.len() > MAX_DISPLAY_ID_BYTES {
7318            self.fail_closed();
7319            return None;
7320        }
7321        if let Some(index) = self.hook_names.get(hook_name) {
7322            return Some(format!("actor-hook-{index}"));
7323        }
7324        if !self.reserve_identity() {
7325            return None;
7326        }
7327        let index = self.hook_names.len() + 1;
7328        self.hook_names.insert(hook_name.to_string(), index);
7329        Some(format!("actor-hook-{index}"))
7330    }
7331
7332    fn project(&mut self, event: AgentEvent, depth: usize) -> Option<AgentEvent> {
7333        let call_id = match &event {
7334            AgentEvent::ToolStart { tool_call_id, .. }
7335            | AgentEvent::ToolApprovalRequested { tool_call_id, .. }
7336            | AgentEvent::ToolToken { tool_call_id, .. }
7337            | AgentEvent::ToolComplete { tool_call_id, .. }
7338            | AgentEvent::ToolError { tool_call_id, .. }
7339            | AgentEvent::ToolLifecycle { tool_call_id, .. } => Some(tool_call_id),
7340            _ => None,
7341        };
7342        if call_id.is_some_and(|id| id.is_empty() || id.len() > MAX_DISPLAY_ID_BYTES) {
7343            self.fail_closed();
7344            return None;
7345        }
7346        if self.identities.load(Ordering::Relaxed) > MAX_DISPLAY_CALLS {
7347            self.fail_closed();
7348        }
7349        Some(match event {
7350            AgentEvent::ToolStart {
7351                tool_call_id,
7352                tool_name,
7353                arguments,
7354            } => {
7355                let private = is_private_browser_call(&tool_name, &arguments);
7356                let kind = self.remember_start(&tool_call_id, private);
7357                let arguments = if kind == DisplayCallKind::Reused {
7358                    serde_json::json!({"details":"[redacted]"})
7359                } else {
7360                    actor_arguments_for_display(&tool_name, &arguments)
7361                };
7362                AgentEvent::ToolStart {
7363                    tool_call_id,
7364                    tool_name: match kind {
7365                        DisplayCallKind::Reused => "unknown".into(),
7366                        _ if is_browser_tool_name(&tool_name) => "browser".into(),
7367                        _ => tool_name,
7368                    },
7369                    arguments,
7370                }
7371            }
7372            AgentEvent::ToolApprovalRequested {
7373                tool_call_id,
7374                tool_name,
7375                parameters,
7376            } => {
7377                let private = is_private_browser_call(&tool_name, &parameters);
7378                let kind = self.remember_approval(&tool_call_id, private);
7379                let parameters = if kind == DisplayCallKind::Reused {
7380                    serde_json::json!({"details":"[redacted]"})
7381                } else {
7382                    actor_arguments_for_display(&tool_name, &parameters)
7383                };
7384                AgentEvent::ToolApprovalRequested {
7385                    tool_call_id,
7386                    tool_name: match kind {
7387                        DisplayCallKind::Reused => "unknown".into(),
7388                        _ if is_browser_tool_name(&tool_name) => "browser".into(),
7389                        _ => tool_name,
7390                    },
7391                    parameters,
7392                }
7393            }
7394            AgentEvent::ToolToken {
7395                tool_call_id,
7396                content,
7397            } => {
7398                let content = match self.known_kind(&tool_call_id) {
7399                    Some(DisplayCallKind::Ordinary) => content,
7400                    Some(DisplayCallKind::PrivateBrowser) => "Browser tool output hidden".into(),
7401                    _ => "Tool output hidden".into(),
7402                };
7403                AgentEvent::ToolToken {
7404                    tool_call_id,
7405                    content,
7406                }
7407            }
7408            AgentEvent::ToolComplete {
7409                tool_call_id,
7410                result,
7411            } => {
7412                let result = match self.known_kind(&tool_call_id) {
7413                    Some(DisplayCallKind::Ordinary) => result,
7414                    Some(DisplayCallKind::PrivateBrowser) => {
7415                        bamboo_agent_core::tools::ToolResult::text(
7416                            result.success,
7417                            "Browser tool result hidden",
7418                        )
7419                    }
7420                    _ => bamboo_agent_core::tools::ToolResult::text(
7421                        result.success,
7422                        "Tool result hidden",
7423                    ),
7424                };
7425                AgentEvent::ToolComplete {
7426                    tool_call_id,
7427                    result,
7428                }
7429            }
7430            AgentEvent::ToolError {
7431                tool_call_id,
7432                error,
7433            } => {
7434                let error = match self.known_kind(&tool_call_id) {
7435                    Some(DisplayCallKind::Ordinary) => error,
7436                    Some(DisplayCallKind::PrivateBrowser) => "Browser tool failed".into(),
7437                    _ => "Tool failed".into(),
7438                };
7439                AgentEvent::ToolError {
7440                    tool_call_id,
7441                    error,
7442                }
7443            }
7444            AgentEvent::ToolLifecycle {
7445                tool_call_id,
7446                tool_name,
7447                phase,
7448                elapsed_ms,
7449                is_mutating,
7450                auto_approved,
7451                summary,
7452                error,
7453            } => {
7454                let kind = self.known_kind(&tool_call_id);
7455                let (summary, error) = match kind {
7456                    Some(DisplayCallKind::Ordinary) => (summary, error),
7457                    Some(DisplayCallKind::PrivateBrowser) => (
7458                        summary.map(|_| "Browser tool activity hidden".into()),
7459                        error.map(|_| "Browser tool failed".into()),
7460                    ),
7461                    _ => (
7462                        summary.map(|_| "Tool activity hidden".into()),
7463                        error.map(|_| "Tool failed".into()),
7464                    ),
7465                };
7466                AgentEvent::ToolLifecycle {
7467                    tool_call_id,
7468                    tool_name: match kind {
7469                        Some(DisplayCallKind::Ordinary) => tool_name,
7470                        Some(DisplayCallKind::PrivateBrowser) => "browser".into(),
7471                        _ => "unknown".into(),
7472                    },
7473                    phase: if kind == Some(DisplayCallKind::Ordinary)
7474                        || matches!(phase.as_str(), "begin" | "finished" | "error" | "cancelled")
7475                    {
7476                        phase
7477                    } else {
7478                        "event".into()
7479                    },
7480                    elapsed_ms,
7481                    is_mutating,
7482                    auto_approved,
7483                    summary,
7484                    error,
7485                }
7486            }
7487            AgentEvent::HookLifecycle {
7488                hook_name,
7489                point,
7490                phase,
7491                duration_ms,
7492                decision,
7493            } => {
7494                let Some(decision) = hook_result_for_display(decision, 0) else {
7495                    self.fail_closed();
7496                    return None;
7497                };
7498                let hook_name = self.display_hook_name(&hook_name)?;
7499                let phase = match phase.as_str() {
7500                    "completed" | "started" | "error" => phase,
7501                    _ => "event".into(),
7502                };
7503                AgentEvent::HookLifecycle {
7504                    hook_name,
7505                    point,
7506                    phase,
7507                    duration_ms,
7508                    decision,
7509                }
7510            }
7511            AgentEvent::ChildApprovalRequested {
7512                child_session_id,
7513                request_id,
7514                tool_name,
7515                permission,
7516                resource,
7517            } => {
7518                let (tool_name, permission, resource) = if is_browser_tool_name(&tool_name) {
7519                    (
7520                        "browser".into(),
7521                        "Browser interaction approval".into(),
7522                        "[redacted]".into(),
7523                    )
7524                } else {
7525                    (tool_name, permission, resource)
7526                };
7527                AgentEvent::ChildApprovalRequested {
7528                    child_session_id,
7529                    request_id,
7530                    tool_name,
7531                    permission,
7532                    resource,
7533                }
7534            }
7535            AgentEvent::ChildApprovalChanged { tool_name, .. }
7536                if is_browser_tool_name(&tool_name) =>
7537            {
7538                // This durable approval delta is host-owned. An actor-supplied
7539                // copy is neither authoritative nor safe to mirror to the
7540                // account feed; the host publishes its own record separately.
7541                return None;
7542            }
7543            AgentEvent::SubAgentEvent {
7544                parent_session_id,
7545                child_session_id,
7546                event,
7547            } => {
7548                if depth >= MAX_NESTED_DISPLAY_DEPTH || self.nested_overflowed {
7549                    return None;
7550                }
7551                if parent_session_id.is_empty()
7552                    || child_session_id.is_empty()
7553                    || parent_session_id.len() > MAX_DISPLAY_ID_BYTES
7554                    || child_session_id.len() > MAX_DISPLAY_ID_BYTES
7555                {
7556                    self.fail_closed();
7557                    return None;
7558                }
7559                let key = (parent_session_id.clone(), child_session_id.clone());
7560                if !self.children.contains_key(&key) && !self.reserve_identity() {
7561                    return None;
7562                }
7563                let identities = self.identities.clone();
7564                let child = self.children.entry(key).or_insert_with(|| {
7565                    Box::new(ActorEventDisplay {
7566                        identities,
7567                        ..Default::default()
7568                    })
7569                });
7570                let event = child.project(*event, depth + 1)?;
7571                if self.identities.load(Ordering::Relaxed) > MAX_DISPLAY_CALLS {
7572                    self.fail_closed();
7573                    return None;
7574                }
7575                AgentEvent::SubAgentEvent {
7576                    parent_session_id,
7577                    child_session_id,
7578                    event: Box::new(event),
7579                }
7580            }
7581            other => other,
7582        })
7583    }
7584}
7585
7586#[allow(clippy::too_many_arguments)]
7587async fn process_actor_event(
7588    event: serde_json::Value,
7589    strict_permission_events: bool,
7590    permission_handshake: &mut PermissionPostureHandshake,
7591    expected_permission_posture: Option<&ExpectedPermissionPosture>,
7592    session_inbox_runtime: Option<&SessionInboxRuntimeBinding>,
7593    logical_session: &mut Session,
7594    event_tx: &mpsc::Sender<AgentEvent>,
7595    display: &mut ActorEventDisplay,
7596) -> crate::runtime::runner::Result<()> {
7597    let event = match serde_json::from_value::<AgentEvent>(event) {
7598        Ok(event) => event,
7599        Err(_) if strict_permission_events => {
7600            // Serde errors can quote an unrecognized variant or field value.
7601            // The actor event is untrusted display input, including browser
7602            // download metadata, so never echo its decode details to SSE.
7603            return Err(AgentError::LLM(
7604                "actor emitted malformed AgentEvent under a typed permission posture contract"
7605                    .to_string(),
7606            ));
7607        }
7608        Err(_) => {
7609            // A legacy actor can omit a malformed ToolStart that reuses an
7610            // earlier public call ID. Its later result must not inherit the
7611            // stale public display classification.
7612            display.fail_closed();
7613            return Ok(());
7614        }
7615    };
7616    process_typed_actor_event(
7617        event,
7618        permission_handshake,
7619        expected_permission_posture,
7620        session_inbox_runtime,
7621        logical_session,
7622        event_tx,
7623        display,
7624    )
7625    .await
7626}
7627
7628#[allow(clippy::too_many_arguments)]
7629async fn process_typed_actor_event(
7630    event: AgentEvent,
7631    permission_handshake: &mut PermissionPostureHandshake,
7632    expected_permission_posture: Option<&ExpectedPermissionPosture>,
7633    session_inbox_runtime: Option<&SessionInboxRuntimeBinding>,
7634    logical_session: &mut Session,
7635    event_tx: &mpsc::Sender<AgentEvent>,
7636    display: &mut ActorEventDisplay,
7637) -> crate::runtime::runner::Result<()> {
7638    if matches!(&event, AgentEvent::PermissionPostureActivated { .. }) {
7639        if permission_handshake.posture_was_confirmed() {
7640            return Err(AgentError::LLM(
7641                "actor emitted a duplicate permission posture activation".to_string(),
7642            ));
7643        }
7644        let seed = permission_posture_seed_from_event(logical_session, &event)
7645            .map_err(AgentError::LLM)?
7646            .ok_or_else(|| {
7647                AgentError::LLM(
7648                    "actor permission posture event did not decode as a posture".to_string(),
7649                )
7650            })?;
7651        if let Some(expected) = expected_permission_posture {
7652            if seed.policy_revision != expected.policy_revision
7653                || seed.resolution != expected.resolution
7654            {
7655                return Err(AgentError::LLM(
7656                    "permission posture event does not match the host-dispatched policy"
7657                        .to_string(),
7658                ));
7659            }
7660            if seed.executor_mapping() != expected.executor_mapping {
7661                return Err(AgentError::LLM(
7662                    "permission posture event does not match the host-dispatched executor mapping"
7663                        .to_string(),
7664                ));
7665            }
7666        }
7667        if let Some(binding) = session_inbox_runtime {
7668            let saved = binding
7669                .persistence
7670                .record_permission_posture_activation(
7671                    &logical_session.id,
7672                    expected_permission_posture
7673                        .and_then(|expected| expected.expected_audit_revision),
7674                    &seed,
7675                )
7676                .await
7677                .map_err(|error| {
7678                    AgentError::LLM(format!(
7679                        "persist child permission posture bootstrap: {error}"
7680                    ))
7681                })?
7682                .ok_or_else(|| {
7683                    AgentError::LLM(
7684                        "persist child permission posture bootstrap: session not found".to_string(),
7685                    )
7686                })?;
7687            let snapshot = bamboo_domain::PermissionAuditSnapshot::from_metadata(&saved.metadata)
7688                .ok_or_else(|| {
7689                AgentError::LLM(
7690                    "persisted child permission posture audit is incomplete".to_string(),
7691                )
7692            })?;
7693            snapshot.write_to(&mut logical_session.metadata);
7694        } else {
7695            // In-memory/custom actor embeddings still use a host-owned clock.
7696            // Durable server paths always take the atomic branch.
7697            bamboo_domain::record_permission_audit(&mut logical_session.metadata, &seed, None)
7698                .map_err(|error| {
7699                    AgentError::LLM(format!(
7700                        "record in-memory child permission posture: {error}"
7701                    ))
7702                })?;
7703        }
7704        // Confirmation is deliberately last: matching alone is not enough.
7705        // The host-owned audit write must succeed first.
7706        *permission_handshake = PermissionPostureHandshake::Confirmed;
7707    } else if permission_handshake.is_awaiting() {
7708        return Err(AgentError::LLM(
7709            "actor emitted an execution event before permission posture confirmation".to_string(),
7710        ));
7711    }
7712    if let Some(event) = display.project(event, 0) {
7713        let _ = event_tx.send(event).await;
7714    }
7715    Ok(())
7716}
7717
7718/// Pump child frames -> parent events until a terminal frame (or cancellation).
7719/// On success, yields the actor's final result text (for session write-back).
7720/// `live_rx` carries in-band frames (steering messages) from the live registry.
7721///
7722/// `escalation_bridge` (#68) is the per-run escalation host bridge CAPTURED BY
7723/// VALUE at spawn time in `execute_external_child` (NOT read live here): when a
7724/// non-bypass child re-proxies an approval request, this owned bridge routes it
7725/// UP to the parent run. Owning it for the call's lifetime is what lets a
7726/// fire-and-forget grandchild that outlives its spawning run still escalate to
7727/// the correct (then-current) parent bridge rather than a stale/overwritten one.
7728async fn drive(context: ActorDriveContext<'_>) -> crate::runtime::runner::Result<Option<String>> {
7729    let ActorDriveContext {
7730        client,
7731        parent_session_id,
7732        child_session_id,
7733        child_attempt,
7734        approval_registry,
7735        approval_decider,
7736        approval_reviewer,
7737        escalation_bridge,
7738        event_tx,
7739        cancel_token,
7740        live_rx,
7741        delivery_rx,
7742        logical_session,
7743        mut expected_permission_posture,
7744        expected_creation,
7745        session_inbox_runtime,
7746        actor_directory_store,
7747        canonical_subagent_tool,
7748        ticket_service,
7749        activation_run_id,
7750        execution_epoch,
7751        expected_source_actor_id,
7752        initial_inflight_claims,
7753        first_frame_timeout,
7754        plain_actor,
7755        remote_environment_lease,
7756        mut readonly_output,
7757        local_history_tools,
7758        local_history_read_only,
7759        mut strict_history_output,
7760        plain_input,
7761        canonical_activation,
7762        canonical_placement_ref,
7763        actor_event_observer,
7764        plain_run,
7765    } = context;
7766
7767    // A projection belongs only to this Host-validated Run. A loaded Session
7768    // or a prior attempt may carry a stale marker; only the canonical wait
7769    // checks in the terminal branch below may set it again.
7770    logical_session
7771        .metadata
7772        .remove("runtime.actor_nested_wait_handoff");
7773
7774    // First-frame watchdog: a live worker emits its first frame (run-started /
7775    // first token) within seconds; total silence past the deadline means the
7776    // worker is dead (e.g. a pooled worker that exited right after checkout), so
7777    // its Run sits queued forever. We trip ONLY before the first frame — once any
7778    // frame arrives the worker is proven live and a legitimately long run (a slow
7779    // tool between tokens) never trips it.
7780    let mut got_first_frame = false;
7781    let mut first_frame_watch = first_frame_timeout.map(|d| Box::pin(tokio::time::sleep(d)));
7782    let mut inflight_claims = initial_inflight_claims;
7783    let mut owned_input = plain_input.and_then(|activation| activation.initial_input.clone());
7784    // Retry/recovery already consumes its one input; only a fresh activation
7785    // can continue twice. Neither budget restarts the Actor lease or watchdog.
7786    let continuation_limit = if owned_input.is_some() { 1 } else { 2 };
7787    let mut continuations = usize::from(owned_input.is_some());
7788    let mut released_input: Option<(
7789        SessionInboxOwnedClaim,
7790        bamboo_subagent::proto::InitialInputRelease,
7791    )> = None;
7792    let mut current_epoch = execution_epoch;
7793    let approval_epoch = Arc::new(AtomicU64::new(execution_epoch));
7794    let approval_cancel = cancel_token.child_token();
7795    let _approval_lifetime = approval_cancel.clone().drop_guard();
7796    let mut approval_scopes: HashMap<String, (serde_json::Value, ChildApprovalScope)> =
7797        HashMap::new();
7798    let mut owned_tree_requests = 0usize;
7799    let mut canonical_subagent_requests = 0usize;
7800    let mut committed_plan_calls = HashMap::new();
7801    // Once this Run registers a canonical Child wait, a fast completion may
7802    // clear it before the Worker sends Terminal. Keep the current Run
7803    // nonterminal so its already admitted outcome belongs to the successor.
7804    let mut nested_wait_registered_this_run = false;
7805    let mut parent_question_checkpoint_id: Option<String> = None;
7806    let strict_permission_events = expected_permission_posture.is_some();
7807    let mut permission_handshake =
7808        PermissionPostureHandshake::new(expected_permission_posture.as_ref());
7809    let mut next_actor_event_seq = 1u64;
7810    let mut canonical_router: Option<ActorEventRouter> = None;
7811    let canonical_fence = plain_input
7812        .map(|activation| &activation.fence)
7813        .or(canonical_activation);
7814    let canonical_store = plain_input
7815        .map(|activation| activation.store.as_ref())
7816        .or(actor_directory_store);
7817    let remote_claim_fence = actor_directory_store.zip(canonical_activation);
7818    let mut remote_cancel_deadline: Option<tokio::time::Instant> = None;
7819    let mut display = ActorEventDisplay::default();
7820    let mut readonly = readonly_output
7821        .as_ref()
7822        .map(|_| ReadOnlyActorCollector::default());
7823    let mut local_history = local_history_tools.map(|_| LocalToolCollector::default());
7824    loop {
7825        tokio::select! {
7826            _ = cancel_token.cancelled(), if remote_cancel_deadline.is_none() => {
7827                if remote_claim_fence.is_some() {
7828                    // A remote Worker may finish after its subscriber cancels.
7829                    // Keep the same correlated frame pump alive briefly so an
7830                    // exact Cancelled Outcome can terminalize the Actor fence.
7831                    // Without that proof the activation stays live until its
7832                    // lease expires and a retry must remain blocked.
7833                    let _ = client.send(ParentFrame::Cancel).await;
7834                    remote_cancel_deadline = Some(
7835                        tokio::time::Instant::now() + Duration::from_secs(10)
7836                    );
7837                    continue;
7838                }
7839                // Direct/local links retain their existing cancel behavior.
7840                break;
7841            }
7842            _ = async {
7843                if let Some(deadline) = remote_cancel_deadline {
7844                    tokio::time::sleep_until(deadline).await;
7845                } else {
7846                    std::future::pending::<()>().await;
7847                }
7848            } => {
7849                return Err(AgentError::Cancelled);
7850            }
7851            _ = async {
7852                match first_frame_watch.as_mut() {
7853                    Some(s) => s.as_mut().await,
7854                    None => std::future::pending::<()>().await,
7855                }
7856            }, if !got_first_frame => {
7857                return Err(AgentError::WorkerUnresponsive(format!(
7858                    "child {child_session_id} produced no frame within {:?}",
7859                    first_frame_timeout.unwrap_or_default()
7860                )));
7861            }
7862            Some(_generation) = delivery_rx.recv(),
7863                if session_inbox_runtime.is_some() && activation_run_id.is_some() =>
7864            {
7865                if plain_input.is_some() {
7866                    // Keep the real Inbox pending until this Run's completed
7867                    // reply can be committed BEFORE its correction User.
7868                    continue;
7869                }
7870                forward_next_canonical_claim(
7871                    client,
7872                    session_inbox_runtime.expect("guarded"),
7873                    logical_session,
7874                    activation_run_id.expect("guarded"),
7875                    &mut inflight_claims,
7876                    remote_claim_fence,
7877                )
7878                .await?;
7879            }
7880            Some(frame) = live_rx.recv() => {
7881                if plain_actor { return Err(plain_actor_unsupported()); }
7882                // Forward in-band steering to the worker over the existing WS.
7883                if client.send(frame).await.is_err() {
7884                    tracing::warn!("live steering frame could not be sent; connection failing");
7885                }
7886            }
7887            frame = client.next_frame() => {
7888                // Any frame (event / approval / terminal / close / error) proves
7889                // the worker responded — disarm the first-frame watchdog.
7890                got_first_frame = true;
7891                first_frame_watch = None;
7892                match frame {
7893                    Ok(Some(ChildFrame::Event { event })) => {
7894                        if canonical_activation.is_some() {
7895                            return Err(AgentError::LLM("remote_actor_event_batch_required".into()));
7896                        }
7897                        if local_history.is_some() {
7898                            return Err(local_tool_history_unsupported());
7899                        }
7900                        if event.get("initial_input_control").is_some() {
7901                            let bamboo_subagent::proto::InitialInputControl::Request { request } =
7902                                bamboo_subagent::proto::InitialInputControl::decode(event).map_err(|_| plain_actor_unsupported())?
7903                            else { return Err(plain_actor_unsupported()); };
7904                            if !permission_handshake.posture_was_confirmed() || cancel_token.is_cancelled() { return Err(plain_actor_unsupported()); }
7905                            let activation = plain_input.ok_or_else(|| plain_actor_unsupported())?;
7906                            let binding = session_inbox_runtime.ok_or_else(|| plain_actor_unsupported())?;
7907                            let (run, _) = plain_run.ok_or_else(|| plain_actor_unsupported())?;
7908                            let claim = owned_input.as_ref().or_else(|| released_input.as_ref().map(|(claim, _)| claim))
7909                                .ok_or_else(|| plain_actor_unsupported())?;
7910                            let release = activation.release_initial_input(binding, logical_session, run, current_epoch, claim,
7911                                &request, &mut expected_permission_posture, released_input.as_ref().map(|(_, release)| release)).await?;
7912                            if cancel_token.is_cancelled() { return Err(AgentError::Cancelled); }
7913                            client.send(ParentFrame::InitialInputRelease { release: release.clone() }).await
7914                                .map_err(|_| AgentError::LLM("Actor initial release unconfirmed; durable input is preserved".into()))?;
7915                            released_input = Some((claim.clone(), release));
7916                            owned_input = None;
7917                            continue;
7918                        }
7919                        if expected_creation.is_some() {
7920                            return Err(AgentError::LLM("worker omitted required Child creation event identity".into()));
7921                        }
7922                        if event["type"] == bamboo_subagent::proto::LocalToolMessages::TYPE { continue; }
7923                        if event["type"] == "owned_readonly_transcript" {
7924                            if plain_actor { return Err(plain_actor_unsupported()); }
7925                            continue;
7926                        }
7927                        if plain_actor && !plain_actor_event(&event)? { continue; }
7928                        if let Some(activation) = plain_input {
7929                            validate_directory_actor_event(
7930                                activation.store.as_ref(),
7931                                &activation.fence,
7932                                logical_session,
7933                                None,
7934                            ).await?;
7935                        }
7936                        // Rolling-upgrade compatibility: old actors have no
7937                        // route/sequence metadata, but retain the same typed
7938                        // permission handshake and event validation.
7939                        process_actor_event(
7940                            event,
7941                            strict_permission_events,
7942                            &mut permission_handshake,
7943                            expected_permission_posture.as_ref(),
7944                            session_inbox_runtime,
7945                            logical_session,
7946                            event_tx,
7947                            &mut display,
7948                        )
7949                        .await?;
7950                    }
7951                    Ok(Some(ChildFrame::EventBatch { batch })) => {
7952                        validate_actor_event_batch(
7953                            &batch,
7954                            logical_session,
7955                            parent_session_id,
7956                            activation_run_id,
7957                            current_epoch,
7958                            expected_source_actor_id,
7959                            expected_creation,
7960                        )?;
7961                        let directory_entry = if let (Some(store), Some(fence)) = (canonical_store, canonical_fence) {
7962                            let entry = validate_directory_actor_event(
7963                                store, fence, logical_session, batch.activation_id.as_deref(),
7964                            ).await?;
7965                            if canonical_placement_ref.is_some_and(|expected| {
7966                                entry.activation.as_ref().and_then(|activation| activation.placement_ref.as_ref()) != Some(expected)
7967                            }) {
7968                                return Err(AgentError::LLM("remote_actor_placement_ref_changed".into()));
7969                            }
7970                            Some(entry)
7971                        } else { None };
7972                        if let (Some(entry), Some(fence)) = (&directory_entry, canonical_fence) {
7973                            if canonical_router.is_none() {
7974                                canonical_router = Some(
7975                                    ActorEventRouter::new(
7976                                        entry,
7977                                        fence,
7978                                        current_epoch,
7979                                        strict_permission_events && permission_handshake.is_awaiting(),
7980                                    )
7981                                    .map_err(actor_event_route_error)?,
7982                                );
7983                            }
7984                        }
7985                        if local_history_tools.is_some()
7986                            && (batch.first_seq != next_actor_event_seq
7987                                || batch.qos != bamboo_subagent::ActorEventQos::Durable
7988                                || batch.last_seq == u64::MAX) {
7989                            return Err(local_tool_history_unsupported());
7990                        }
7991                        if batch.last_seq < next_actor_event_seq {
7992                            continue;
7993                        }
7994                        if batch.first_seq > next_actor_event_seq {
7995                            if plain_actor {
7996                                return Err(AgentError::LLM(
7997                                    "actor event sequence gap; authoritative session snapshot is required".into(),
7998                                ));
7999                            }
8000                            tracing::warn!(
8001                                child_session_id,
8002                                expected_seq = next_actor_event_seq,
8003                                received_seq = batch.first_seq,
8004                                "actor event sequence gap; authoritative session snapshot is required"
8005                            );
8006                            // A missing ToolStart may have reused an earlier
8007                            // call ID for a private browser download. No prior
8008                            // identity remains safe for display after a gap.
8009                            display.fail_closed();
8010                        }
8011                        let skip = next_actor_event_seq
8012                            .saturating_sub(batch.first_seq)
8013                            .min(batch.events.len() as u64) as usize;
8014                        for (offset, event) in batch.events.into_iter().enumerate().skip(skip) {
8015                            let seq = batch.first_seq + offset as u64;
8016                            let mut publish = true;
8017                            if let Some(collector) = local_history.as_mut() {
8018                                if permission_handshake.is_awaiting() && event["type"] != "permission_posture_activated" {
8019                                    return Err(local_tool_history_unsupported());
8020                                }
8021                                if logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY) {
8022                                    publish = collector.ticket_event(&event, &committed_plan_calls)?;
8023                                }
8024                                publish &= collector.event(&event)?;
8025                            } else if event["type"] == bamboo_subagent::proto::LocalToolMessages::TYPE {
8026                                // Completion DATA is private and grants nothing on an unselected route.
8027                                publish = false;
8028                            }
8029                            if publish { publish = if let Some(collector) = readonly.as_mut() {
8030                                if permission_handshake.is_awaiting()
8031                                    && event["type"] != "permission_posture_activated" {
8032                                    return Err(plain_actor_unsupported());
8033                                }
8034                                collector.event(&event)?
8035                            } else if event["type"] == "owned_readonly_transcript" {
8036                                if plain_actor { return Err(plain_actor_unsupported()); }
8037                                false // A worker cache observation is not a legacy Host commit.
8038                            } else if plain_actor { plain_actor_event(&event)? } else { true };
8039                            }
8040                            if let (Some(router), Some(entry), Some(fence)) =
8041                                (canonical_router.as_mut(), directory_entry.as_ref(), canonical_fence)
8042                            {
8043                                let typed = if publish {
8044                                    Some(serde_json::from_value::<AgentEvent>(event.clone())
8045                                        .map_err(|_| plain_actor_unsupported())?)
8046                                } else {
8047                                    None
8048                                };
8049                                match router.route(entry, fence, current_epoch, seq, typed)
8050                                    .map_err(actor_event_route_error)?
8051                                {
8052                                    ActorEventRoute::Publish(envelope) => {
8053                                        let public_event = actor_event_observer
8054                                            .filter(|_| envelope.class != ActorEventClass::Ephemeral)
8055                                            .map(|_| PublicActorEvent::from(envelope.as_ref()));
8056                                        tracing::trace!(
8057                                            actor_id = %envelope.actor_id,
8058                                            event_id = %envelope.event_id,
8059                                            class = ?envelope.class,
8060                                            "publishing canonical Actor event"
8061                                        );
8062                                        process_typed_actor_event(
8063                                            envelope.payload,
8064                                            &mut permission_handshake,
8065                                            expected_permission_posture.as_ref(),
8066                                            session_inbox_runtime,
8067                                            logical_session,
8068                                            event_tx,
8069                                            &mut display,
8070                                        ).await?;
8071                                        if let (Some(observer), Some(public_event)) =
8072                                            (actor_event_observer, public_event)
8073                                        {
8074                                            observer.publish(public_event);
8075                                        }
8076                                    }
8077                                    ActorEventRoute::Duplicate | ActorEventRoute::Suppressed => {}
8078                                }
8079                                next_actor_event_seq = seq.saturating_add(1);
8080                                continue;
8081                            }
8082                            if !publish {
8083                                next_actor_event_seq = seq.saturating_add(1);
8084                                continue;
8085                            }
8086                            process_actor_event(
8087                                event,
8088                                strict_permission_events,
8089                                &mut permission_handshake,
8090                                expected_permission_posture.as_ref(),
8091                                session_inbox_runtime,
8092                                logical_session,
8093                                event_tx,
8094                                &mut display,
8095                            )
8096                            .await?;
8097                            next_actor_event_seq = seq.saturating_add(1);
8098                        }
8099                    }
8100                    Ok(Some(ChildFrame::OwnedTreeRequest { id, cursor })) => {
8101                        owned_tree_requests += 1;
8102                        if owned_tree_requests > 16 {
8103                            return Err(AgentError::LLM("owned tree request limit exceeded".into()));
8104                        }
8105                        let page = if id.len() <= 128
8106                            && !logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
8107                            && cursor.as_ref().is_none_or(|value| !value.is_empty() && value.len() <= 128)
8108                            && !cancel_token.is_cancelled()
8109                        {
8110                            match (actor_directory_store, session_inbox_runtime,
8111                                activation_run_id, expected_creation) {
8112                                (Some(store), Some(binding), Some(run_id), Some(creation)) => {
8113                                    tokio::time::timeout(
8114                                        Duration::from_secs(8),
8115                                        inspect_active_owned_tree(
8116                                            store, binding, run_id, child_session_id,
8117                                            parent_session_id, logical_session, creation,
8118                                            plain_input, cursor.as_deref(),
8119                                        ),
8120                                    ).await.ok().flatten()
8121                                }
8122                                _ => {
8123                                    tracing::warn!("owned tree denied: canonical Host scope unavailable");
8124                                    None
8125                                },
8126                            }
8127                        } else {
8128                            tracing::warn!("owned tree denied: request bounds or cancellation");
8129                            None
8130                        };
8131                        if cancel_token.is_cancelled() {
8132                            return Err(AgentError::Cancelled);
8133                        }
8134                        client.send(ParentFrame::OwnedTreeReply { id, page }).await
8135                            .map_err(|_| AgentError::LLM("owned tree reply transport closed".into()))?;
8136                    }
8137                    Ok(Some(ChildFrame::SubAgentRequest { id, tool_call_id, args })) => {
8138                        canonical_subagent_requests += 1;
8139                        if canonical_subagent_requests > 64 {
8140                            return Err(AgentError::LLM("canonical SubAgent request limit exceeded".into()));
8141                        }
8142                        let valid_shape = !id.is_empty() && id.len() <= 128
8143                            && !tool_call_id.is_empty() && tool_call_id.len() <= 128
8144                            && args.is_object()
8145                            && serde_json::to_vec(&args)
8146                                .is_ok_and(|bytes| bytes.len() <= 64 * 1024);
8147                        let registers_wait = matches!(args.get("action").and_then(|value| value.as_str()),
8148                            Some("wait"))
8149                            || (args.get("action").and_then(|value| value.as_str()) == Some("create")
8150                                && args.get("wait").and_then(|value| value.as_bool()) == Some(true));
8151                        let checkpoint_payload = args.get(PARENT_QUESTION_CHECKPOINT_ACTION).cloned();
8152                        let ticket_bootstrap_pending = valid_shape
8153                            && logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
8154                            && args == serde_json::json!({(crate::ticket_worker_plan::TICKET_PLAN_ACTION): {"read":true}})
8155                            && permission_handshake.is_awaiting()
8156                            && !cancel_token.is_cancelled();
8157                        let result = if ticket_bootstrap_pending {
8158                            // A read control can overtake the queued posture
8159                            // event on the separate lanes. Keep the handshake
8160                            // fence; the exact bootstrap read may retry briefly.
8161                            serde_json::json!({"error":crate::ticket_worker_plan::remote::TICKET_BOOTSTRAP_POSTURE_PENDING})
8162                        } else if valid_shape
8163                            && (!logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
8164                                || args.get(crate::ticket_worker_plan::TICKET_PLAN_ACTION).is_some()
8165                                || args.get(crate::ticket_worker_plan::files::TICKET_FILE_ACTION).is_some())
8166                            && !cancel_token.is_cancelled()
8167                            && !permission_handshake.is_awaiting()
8168                        {
8169                            if args.get(crate::ticket_worker_plan::TICKET_PLAN_ACTION).is_some() {
8170                                match (ticket_service.as_deref(), actor_directory_store,
8171                                    session_inbox_runtime, activation_run_id, expected_creation) {
8172                                    (Some(service), Some(store), Some(binding), Some(run_id), Some(creation)) => {
8173                                        if let Some(caller) = load_active_subagent_caller(
8174                                            store, binding, run_id, child_session_id,
8175                                            parent_session_id, logical_session, creation, plain_input,
8176                                        ).await {
8177                                            match crate::ticket_worker_plan::remote::apply_host_plan_request(service, &caller, run_id, &args, &tool_call_id) {
8178                                                Ok(projection) => {
8179                                                    logical_session.task_list = projection.task_list.clone();
8180                                                    logical_session.set_task_list_version_meta(projection.plan_revision.to_string());
8181                                                    if let Some(question) = &projection.question {
8182                                                        let raw = serde_json::to_string(question).map_err(|_| local_tool_history_unsupported())?;
8183                                                        logical_session.metadata.insert(crate::ticket_worker_plan::TICKET_QUESTION_YIELD_KEY.into(), raw);
8184                                                        if let Some(collector) = local_history.as_mut() {
8185                                                            collector.ticket_yield = Some((tool_call_id.clone(), question.clone()));
8186                                                        }
8187                                                    }
8188                                                    if let Some(task) = args[crate::ticket_worker_plan::TICKET_PLAN_ACTION].get("task") {
8189                                                        committed_plan_calls.insert(tool_call_id.clone(), task.clone());
8190                                                        if let Some(task_list) = projection.task_list.clone() {
8191                                                            let _ = event_tx.send(AgentEvent::TaskListUpdated {
8192                                                                task_list,
8193                                                                version: Some(projection.plan_revision),
8194                                                            }).await;
8195                                                        }
8196                                                    }
8197                                                    serde_json::json!({"result":projection})
8198                                                }
8199                                                Err(error) => serde_json::json!({"error":error.to_string()}),
8200                                            }
8201                                        } else { serde_json::json!({"error":"LocalPlan Child Run is no longer current"}) }
8202                                    }
8203                                    _ => serde_json::json!({"error":"LocalPlan Host authority unavailable"}),
8204                                }
8205                            } else if args.get(crate::ticket_worker_plan::files::TICKET_FILE_ACTION).is_some() {
8206                                match (ticket_service.as_deref(), actor_directory_store, session_inbox_runtime,
8207                                    activation_run_id, expected_creation, local_history_tools) {
8208                                    (Some(service),Some(store),Some(binding),Some(run_id),Some(creation),Some(ceiling)) => {
8209                                        if let Some(caller)=load_active_subagent_caller(store,binding,run_id,child_session_id,parent_session_id,logical_session,creation,plain_input).await {
8210                                            match crate::ticket_worker_plan::files::apply_host_file_request(service,&caller,run_id,&args,&tool_call_id,ceiling) {
8211                                                Ok(reply)=>{
8212                                                    let body=&args[crate::ticket_worker_plan::files::TICKET_FILE_ACTION];
8213                                                    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())?}));
8214                                                    serde_json::json!({"result":reply})
8215                                                },
8216                                                Err(error)=>serde_json::json!({"error":error.to_string()}),
8217                                            }
8218                                        } else { serde_json::json!({"error":"Ticket file Child Run is no longer current"}) }
8219                                    },
8220                                    _=>serde_json::json!({"error":"Ticket file Host authority unavailable"}),
8221                                }
8222                            } else if let Some(payload) = checkpoint_payload {
8223                                let parsed = (args.as_object().is_some_and(|object| object.len() == 1))
8224                                    .then(|| serde_json::from_value::<ParentQuestionCheckpointV1>(payload).ok())
8225                                    .flatten()
8226                                    .filter(|observation| observation.tool_call_id == tool_call_id);
8227                                match (parsed, actor_directory_store, session_inbox_runtime,
8228                                    activation_run_id, expected_creation) {
8229                                    (Some(observation), Some(store), Some(binding), Some(run_id), Some(creation)) => {
8230                                        let receipt = tokio::time::timeout(Duration::from_secs(25),
8231                                            checkpoint_active_parent_question(
8232                                                store, binding, run_id, child_session_id,
8233                                                parent_session_id, logical_session, creation,
8234                                                plain_input, observation,
8235                                            )).await.ok().flatten();
8236                                        if let Some(request_id) = receipt {
8237                                            parent_question_checkpoint_id = Some(request_id.clone());
8238                                            serde_json::json!({"result":{"request_id":request_id}})
8239                                        } else {
8240                                            serde_json::json!({"error":"canonical Child question checkpoint rejected; persisted worker question is preserved"})
8241                                        }
8242                                    }
8243                                    _ => serde_json::json!({"error":"canonical Child question checkpoint unavailable"}),
8244                                }
8245                            } else {
8246                            match (canonical_subagent_tool.as_deref(), actor_directory_store,
8247                                session_inbox_runtime, activation_run_id, expected_creation) {
8248                                (Some(tool), Some(store), Some(binding), Some(run_id), Some(creation)) => {
8249                                    if let Some(caller) = load_active_subagent_caller(
8250                                        store, binding, run_id, child_session_id,
8251                                        parent_session_id, logical_session, creation,
8252                                        plain_input,
8253                                    ).await {
8254                                        tokio::time::timeout(
8255                                            Duration::from_secs(30),
8256                                            invoke_active_subagent(tool, &caller, args, &tool_call_id),
8257                                        ).await.unwrap_or_else(|_| serde_json::json!({
8258                                            "error":"canonical SubAgent operation timed out; inspect logical child state before retrying"
8259                                        }))
8260                                    } else {
8261                                        serde_json::json!({"error":"canonical SubAgent caller is no longer active"})
8262                                    }
8263                                }
8264                                _ => serde_json::json!({"error":"canonical SubAgent Host authority unavailable"}),
8265                            }
8266                            }
8267                        } else {
8268                            serde_json::json!({"error":"canonical SubAgent request rejected"})
8269                        };
8270                        if registers_wait
8271                            && result["result"]["success"] == true
8272                            && result["result"]["result"]
8273                                .as_str()
8274                                .and_then(|value| serde_json::from_str::<serde_json::Value>(value).ok())
8275                                .is_some_and(|value| value["status"] == "waiting")
8276                        {
8277                            nested_wait_registered_this_run = true;
8278                        }
8279                        if cancel_token.is_cancelled() {
8280                            return Err(AgentError::Cancelled);
8281                        }
8282                        client.send(ParentFrame::SubAgentReply { id, result }).await
8283                            .map_err(|_| AgentError::LLM("canonical SubAgent reply transport closed".into()))?;
8284                    }
8285                    Ok(Some(ChildFrame::ApprovalRequest { id, body })) => {
8286                        if logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY) {
8287                            return Err(AgentError::LLM("Ticket Task-only Worker cannot request unrelated action authority".into()));
8288                        }
8289                        if plain_actor { return Err(plain_actor_unsupported()); }
8290                        if permission_handshake.is_awaiting() {
8291                            return Err(AgentError::LLM(
8292                                "actor requested approval before permission posture confirmation"
8293                                    .to_string(),
8294                            ));
8295                        }
8296                        // Phase 2: a worker proxied a gated-tool approval back to
8297                        // the host. The WORKER side is live — its executor installs
8298                        // a per-run task-local `ApprovalProxy` (subagent_worker.rs)
8299                        // that calls `host.approval_call`, so this frame arrives
8300                        // when a child hits `ConfirmationRequired`.
8301                        if let Some(reviewer) = approval_reviewer
8302                            .cloned()
8303                            .or_else(child_approval_reviewer)
8304                        {
8305                            // Phase 6, Part B: a BYPASSED parent worker
8306                            // model-reviews its children's forced-ask (dangerous)
8307                            // actions. The review is an LLM call, so run it OFF
8308                            // the frame pump in a spawned task and deliver the
8309                            // verdict async via the live channel — the pump keeps
8310                            // forwarding events and the agent loop never blocks. A
8311                            // timeout denies a hung review so the child can't hang.
8312                            let child = child_session_id.to_string();
8313                            let parent = parent_session_id.to_string();
8314                            let req_id = id.clone();
8315                            let body = body.clone();
8316                            let registry = approval_registry.cloned();
8317                            let Some((binding, run)) = session_inbox_runtime.zip(activation_run_id) else {
8318                                // Legacy/custom reviewers retain their original boolean seam;
8319                                // the canonical server reviewer denies without a live scope.
8320                                tokio::spawn(async move {
8321                                    let approved = tokio::time::timeout(CHILD_APPROVAL_TIMEOUT,
8322                                        reviewer.review(&parent, &child, &body)).await.unwrap_or(false);
8323                                    super::live::deliver_approval_scoped(registry.as_ref(), &child,
8324                                        child_attempt, &req_id, approved);
8325                                });
8326                                continue;
8327                            };
8328                            let generation = body.pointer("/permission_request/request_generation")
8329                                .and_then(serde_json::Value::as_str).unwrap_or("").to_string();
8330                            let scope = if let Some((original, scope)) = approval_scopes.get(&generation) {
8331                                if original != &body || scope.stamp()["reply"] != req_id {
8332                                    return Err(AgentError::LLM("approval generation changed in live scope".into()));
8333                                }
8334                                scope.clone()
8335                            } else {
8336                                if approval_scopes.len() >= 64 {
8337                                    return Err(AgentError::LLM("approval live scope limit exceeded".into()));
8338                                }
8339                                let scope = ChildApprovalScope::new(&parent, &child,
8340                                    (child_attempt, run, current_epoch, &req_id, chrono::Utc::now() + chrono::Duration::seconds(240)), binding.router.clone(),
8341                                    approval_cancel.clone(), approval_epoch.clone());
8342                                approval_scopes.insert(generation, (body.clone(), scope.clone()));
8343                                scope
8344                            };
8345                            tokio::spawn(async move {
8346                                let result = tokio::time::timeout(
8347                                    CHILD_APPROVAL_TIMEOUT,
8348                                    reviewer.review_scoped(&parent, &child, &body, &scope),
8349                                ).await.unwrap_or(ChildApprovalReview::Reply(false));
8350                                if let ChildApprovalReview::Reply(approved) = result {
8351                                    if scope.is_current(&parent, &child).await {
8352                                        super::live::deliver_approval_scoped(registry.as_ref(), &child,
8353                                            child_attempt, &req_id, approved && chrono::Utc::now() < scope.deadline());
8354                                    }
8355                                }
8356                            });
8357                        } else if approval_decider.is_some() {
8358                            // A decider is wired (policy / auto): decide promptly
8359                            // and reply inline. (Must not block the pump — see the
8360                            // `ChildApprovalDecider` doc.)
8361                            let approved =
8362                                decide_child_approval(approval_decider, child_session_id, &body)
8363                                    .await;
8364                            if client
8365                                .send(ParentFrame::ApprovalReply { id, approved })
8366                                .await
8367                                .is_err()
8368                            {
8369                                tracing::warn!(
8370                                    "failed to answer approval_request; connection failing"
8371                                );
8372                            }
8373                        } else if let Some(host) = escalation_bridge.clone() {
8374                            // Non-bypass WORKER: ESCALATE up our own actor link
8375                            // (re-proxy) so the request chains to our parent — and
8376                            // up every level until a bypass level or the top
8377                            // orchestrator's model reviewer decides. With no such
8378                            // reviewer the top level fails closed. Off-loop so the
8379                            // pump never blocks; relay the reply down to the child.
8380                            let child = child_session_id.to_string();
8381                            let req_id = id.clone();
8382                            let body = body.clone();
8383                            let registry = approval_registry.cloned();
8384                            tokio::spawn(async move {
8385                                let approved = match tokio::time::timeout(
8386                                    CHILD_APPROVAL_TIMEOUT,
8387                                    host.approval_call(body),
8388                                )
8389                                .await
8390                                {
8391                                    Ok(Ok(reply)) => reply
8392                                        .get("approved")
8393                                        .and_then(|v| v.as_bool())
8394                                        .unwrap_or(false),
8395                                    // Transport error or timeout ⇒ fail closed.
8396                                    _ => false,
8397                                };
8398                                super::live::deliver_approval_scoped(
8399                                    registry.as_ref(),
8400                                    &child,
8401                                    child_attempt,
8402                                    &req_id,
8403                                    approved,
8404                                );
8405                            });
8406                        } else {
8407                            // There is no parent-agent reviewer or upstream actor
8408                            // to own this decision. Never open a manual/UI approval
8409                            // path: forced-ask is parent-reviewed or fail-closed.
8410                            tracing::warn!(
8411                                parent_session_id,
8412                                child_session_id,
8413                                request_id = %id,
8414                                "forced-ask request has no parent-agent reviewer; denying"
8415                            );
8416                            if client
8417                                .send(ParentFrame::ApprovalReply {
8418                                    id,
8419                                    approved: false,
8420                                })
8421                                .await
8422                                .is_err()
8423                            {
8424                                tracing::warn!(
8425                                    "failed to send fail-closed approval reply; connection failing"
8426                                );
8427                            }
8428                        }
8429                    }
8430                    Ok(Some(ChildFrame::SessionMessageAdmitted { confirmation })) => {
8431                        if let Some(activation) = plain_input {
8432                            if activation.initial_release_required { return Err(plain_actor_unsupported()); }
8433                            let binding = session_inbox_runtime.ok_or_else(|| plain_actor_unsupported())?;
8434                            let run_id = activation_run_id.ok_or_else(|| plain_actor_unsupported())?;
8435                            let claim = owned_input.as_ref().ok_or_else(|| plain_actor_unsupported())?;
8436                            activation.confirm_input(binding, logical_session, run_id, claim, &confirmation).await?;
8437                            owned_input = None;
8438                            continue;
8439                        }
8440                        let Some(binding) = session_inbox_runtime else {
8441                            tracing::warn!(
8442                                child_session_id,
8443                                "ignoring SessionInbox confirmation without a runtime binding"
8444                            );
8445                            continue;
8446                        };
8447                        let Some(bound_run_id) = activation_run_id else {
8448                            tracing::warn!(
8449                                child_session_id,
8450                                "ignoring SessionInbox confirmation without an activation owner"
8451                            );
8452                            continue;
8453                        };
8454                        let Some(claim) = inflight_claims.front() else {
8455                            tracing::warn!(
8456                                child_session_id,
8457                                envelope_id = %confirmation.envelope_id,
8458                                "rejecting stale SessionInbox confirmation with no in-flight canonical claim"
8459                            );
8460                            continue;
8461                        };
8462                        let exact = confirmation.target_session_id == logical_session.id
8463                            && confirmation.envelope_id == claim.envelope.id.as_str()
8464                            && confirmation.canonical_claim_generation == claim.generation
8465                            && confirmation.activation_run_id == bound_run_id;
8466                        if !exact
8467                            || !binding
8468                                .router
8469                                .owns_run(&logical_session.id, bound_run_id)
8470                                .await
8471                        {
8472                            tracing::warn!(
8473                                child_session_id,
8474                                expected_target = %logical_session.id,
8475                                received_target = %confirmation.target_session_id,
8476                                expected_envelope_id = %claim.envelope.id,
8477                                received_envelope_id = %confirmation.envelope_id,
8478                                expected_generation = claim.generation,
8479                                received_generation = confirmation.canonical_claim_generation,
8480                                expected_run_id = bound_run_id,
8481                                received_run_id = %confirmation.activation_run_id,
8482                                "rejecting stale or mismatched SessionInbox admission confirmation"
8483                            );
8484                            continue;
8485                        }
8486                        let claim = inflight_claims
8487                            .pop_front()
8488                            .expect("validated in-flight canonical claim");
8489                        // On failure the durable canonical cur file remains
8490                        // recoverable for the next owner.
8491                        checkpoint_and_ack_canonical_claim(
8492                            binding,
8493                            logical_session,
8494                            &claim,
8495                            remote_claim_fence,
8496                        )
8497                            .await?;
8498                        // Ordered single-consumer: only after the exact prior
8499                        // claim is checkpointed+acked may the driver claim and
8500                        // forward the next envelope.
8501                        if inflight_claims.is_empty() {
8502                            forward_next_canonical_claim(
8503                                client,
8504                                binding,
8505                                logical_session,
8506                                bound_run_id,
8507                                &mut inflight_claims,
8508                                remote_claim_fence,
8509                            )
8510                            .await?;
8511                        }
8512                    }
8513                    Ok(Some(ChildFrame::Terminal { status, result, error, final_event_watermark, .. })) => {
8514                        if local_history_tools.is_some() {
8515                            // Check before history commit or terminal receipt acceptance.
8516                            // Normal lossy observations do not promise a complete trace.
8517                            validate_strict_history_terminal(
8518                                final_event_watermark.as_ref(), logical_session, parent_session_id,
8519                                activation_run_id, current_epoch, expected_creation,
8520                                next_actor_event_seq - 1,
8521                            )?;
8522                        }
8523                        if remote_cancel_deadline.is_some() && status == TerminalStatus::Cancelled {
8524                            // A selected Worker terminal proves that its Run
8525                            // ended even if permission posture or an Inbox
8526                            // correction was still pending. Claims remain
8527                            // unacked for retry; only the placement fence can
8528                            // now be released after SDK saves Cancelled.
8529                            client.accept_durable_terminal(status);
8530                            return Err(AgentError::Cancelled);
8531                        }
8532                        if parent_question_checkpoint_id.is_some()
8533                            && status != TerminalStatus::Suspended
8534                        {
8535                            return Err(AgentError::LLM(
8536                                "Child question checkpoint must end in a suspended Run".into(),
8537                            ));
8538                        }
8539                        if permission_handshake.is_awaiting() {
8540                            if remote_environment_lease && status == TerminalStatus::Error {
8541                                if let Some(code) = error.as_deref().and_then(remote_environment_error_code) {
8542                                    // The Worker rejected the EnvironmentLease before it
8543                                    // could confirm its permission posture. This exact
8544                                    // correlated Outcome still proves the Run ended, so
8545                                    // retain its durable receipt and release the slot after
8546                                    // the Host records the failure. Other early terminals
8547                                    // remain unproven and keep their placement fence.
8548                                    client.accept_durable_terminal(status);
8549                                    return Err(AgentError::LLM(code.to_owned()));
8550                                }
8551                            }
8552                            return Err(AgentError::LLM(
8553                                "actor terminated before permission posture confirmation"
8554                                    .to_string(),
8555                            ));
8556                        }
8557                        if owned_input.is_some() {
8558                            return Err(AgentError::LLM("Actor terminated before confirming its durable correction; Inbox claim is retained".into()));
8559                        }
8560                        if let Some(claim) = inflight_claims.front() {
8561                            return Err(AgentError::LLM(format!(
8562                                "actor terminated before durably admitting SessionInbox message {}; canonical claim remains recoverable",
8563                                claim.envelope.id
8564                            )));
8565                        }
8566                        if status == TerminalStatus::Completed {
8567                            if let Some(collector) = local_history.take() {
8568                                commit_local_tool_history(
8569                                    collector, logical_session,
8570                                    session_inbox_runtime.ok_or_else(local_tool_history_unsupported)?,
8571                                    activation_run_id.ok_or_else(local_tool_history_unsupported)?,
8572                                    (local_history_tools.ok_or_else(local_tool_history_unsupported)?, local_history_read_only),
8573                                    result.as_deref(), cancel_token,
8574                                ).await?;
8575                            }
8576                        }
8577                        if let Some(collector) = readonly.take() {
8578                            if status == TerminalStatus::Completed {
8579                                let activation = plain_input.ok_or_else(|| plain_actor_unsupported())?;
8580                                let backlog = activation.input_inbox.inspect(&logical_session.id).await
8581                                    .map_err(|_| plain_actor_unsupported())?;
8582                                if backlog.pending != 0 || backlog.claimed != 0 || backlog.activation_pending() {
8583                                    return Err(AgentError::LLM("Glob Actor continuation is unsupported; pending Inbox input is preserved".into()));
8584                                }
8585                                *readonly_output.as_deref_mut().ok_or_else(|| plain_actor_unsupported())? =
8586                                    collector.finish(result.as_deref())?;
8587                            }
8588                        } else if let Some(activation) = plain_input {
8589                            if status == TerminalStatus::Completed {
8590                                let binding = session_inbox_runtime.ok_or_else(|| plain_actor_unsupported())?;
8591                                let run_id = activation_run_id.ok_or_else(|| plain_actor_unsupported())?;
8592                                if continuations < continuation_limit {
8593                                    if let Some((claim, epoch)) = activation.continue_input(
8594                                        client, binding, logical_session, run_id, plain_run, &mut expected_permission_posture,
8595                                        result.clone().filter(|text| !text.is_empty()).ok_or_else(|| plain_actor_unsupported())?, event_tx,
8596                                    ).await? {
8597                                        owned_input = Some(claim);
8598                                        released_input = None;
8599                                        continuations += 1;
8600                                        current_epoch = epoch;
8601                                        approval_epoch.store(epoch, Ordering::Release);
8602                                        // These are native coordinates scoped to the newly
8603                                        // correlated epoch. Public Host feed seq is untouched.
8604                                        next_actor_event_seq = 1;
8605                                        canonical_router = None;
8606                                        permission_handshake = PermissionPostureHandshake::new(expected_permission_posture.as_ref());
8607                                        display = ActorEventDisplay::default();
8608                                        continue;
8609                                    }
8610                                } else if activation.input_inbox.inspect(&logical_session.id).await
8611                                    .map_err(|_| plain_actor_unsupported())?.activation_pending() {
8612                                    if continuation_limit == 1 {
8613                                        return Err(AgentError::LLM("Only one Actor correction continuation is supported; remaining Inbox input is preserved".into()));
8614                                    }
8615                                    // The outer success path appends the last reply. An
8616                                    // explicit cap refusal must preserve it here instead,
8617                                    // without claiming/acking the excess input or saving
8618                                    // through the ordinary writer on this owned Actor.
8619                                    activation.append_reply(logical_session,
8620                                        result.clone().filter(|text| !text.is_empty()).ok_or_else(|| plain_actor_unsupported())?,
8621                                        event_tx).await?;
8622                                    return Err(AgentError::LLM("Actor correction continuation limit reached; completed reply committed and remaining Inbox input preserved".into()));
8623                                }
8624                            }
8625                        }
8626                        if status == TerminalStatus::Completed {
8627                            // Only an activation with a canonical Child creation
8628                            // identity can invoke the Host SubAgent route. Legacy
8629                            // Echo and other executors may share this runner's
8630                            // installed tool but have no nested wait to fence.
8631                            if let (Some(tool), Some(creation)) =
8632                                (canonical_subagent_tool.as_deref(), expected_creation)
8633                            {
8634                                let (Some(store), Some(binding), Some(run_id)) =
8635                                    (actor_directory_store, session_inbox_runtime,
8636                                        activation_run_id)
8637                                else {
8638                                    return Err(AgentError::LLM(
8639                                        "nested SubAgent Host authority unavailable at Child terminal"
8640                                            .into(),
8641                                    ));
8642                                };
8643                                let must_suspend = ensure_active_nested_wait(
8644                                    tool, store, binding, run_id, child_session_id,
8645                                    parent_session_id, logical_session, creation, plain_input,
8646                                ).await?;
8647                                if must_suspend || nested_wait_registered_this_run {
8648                                    // This is only a current-Run handoff fence.
8649                                    // sdk/spawn re-reads the durable Host wait;
8650                                    // copying a stale wait into this Session
8651                                    // would re-arm it after a fast grandchild
8652                                    // completion, while copying no wait would
8653                                    // erase an active registration on final save.
8654                                    logical_session.metadata.insert(
8655                                        "runtime.actor_nested_wait_handoff".into(),
8656                                        "true".into(),
8657                                    );
8658                                }
8659                            }
8660                        }
8661                        let terminal_result = match status {
8662                            TerminalStatus::Completed => Ok(result),
8663                            TerminalStatus::Cancelled => Err(AgentError::Cancelled),
8664                            TerminalStatus::Error => Err(AgentError::LLM(
8665                                error.unwrap_or_else(|| "actor child errored".to_string()),
8666                            )),
8667                            TerminalStatus::Suspended => {
8668                                use bamboo_agent_core::storage::Storage;
8669                                let (Some(request_id), Some(store), Some(binding), Some(run_id), Some(creation)) =
8670                                    (parent_question_checkpoint_id.as_ref(), actor_directory_store,
8671                                        session_inbox_runtime, activation_run_id, expected_creation)
8672                                else {
8673                                    return Err(AgentError::LLM("uncheckpointed Child suspension rejected".into()));
8674                                };
8675                                if load_active_subagent_caller(
8676                                    store, binding, run_id, child_session_id,
8677                                    parent_session_id, logical_session, creation, plain_input,
8678                                ).await.is_none() {
8679                                    return Err(AgentError::LLM("stale Child question suspension rejected".into()));
8680                                }
8681                                let id = bamboo_domain::SessionMessageId::parse(request_id.clone())
8682                                    .map_err(|_| AgentError::LLM("invalid Child question receipt".into()))?;
8683                                let child = store.load_session(child_session_id).await
8684                                    .map_err(|_| AgentError::LLM("canonical Child question unavailable".into()))?
8685                                    .ok_or_else(|| AgentError::LLM("canonical Child question missing".into()))?;
8686                                let parent = store.load_session(parent_session_id).await
8687                                    .map_err(|_| AgentError::LLM("canonical direct parent unavailable".into()))?
8688                                    .ok_or_else(|| AgentError::LLM("canonical direct parent missing".into()))?;
8689                                let pending = ParentQuestion::for_pending(&parent, &child)
8690                                    .is_some_and(|question| question.id == id);
8691                                let answered = bamboo_domain::ParentQuestionResolution::from_child(
8692                                    &parent, &child, &id,
8693                                ).is_some();
8694                                if !pending && !answered {
8695                                    return Err(AgentError::LLM("canonical Child question proof changed".into()));
8696                                }
8697                                logical_session.metadata.insert(
8698                                    "runtime.actor_parent_question_handoff".into(), "true".into(),
8699                                );
8700                                Ok(None)
8701                            }
8702                        };
8703                        if local_history_tools.is_some() {
8704                            // Failed strict Runs discard their collected suffix. A
8705                            // consumed watermark cannot certify those unapplied
8706                            // effects; retain every broker frame for recovery.
8707                            if status != TerminalStatus::Completed {
8708                                return terminal_result;
8709                            }
8710                            let proof = bamboo_storage::v2::HostTerminalCompleteness::from_verified_host(
8711                                logical_session,
8712                                activation_run_id.ok_or_else(local_tool_history_unsupported)?,
8713                                current_epoch,
8714                                next_actor_event_seq - 1,
8715                            ).map_err(|_| local_tool_history_unsupported())?;
8716                            let history = StrictTerminalHistory {
8717                                proof,
8718                                watermark: final_event_watermark.ok_or_else(local_tool_history_unsupported)?,
8719                            };
8720                            if let Some(output) = strict_history_output.as_deref_mut() {
8721                                *output = Some(history);
8722                            }
8723                        }
8724                        // This marker is set only after every Host terminal
8725                        // validation above succeeded. A surfaced broker Outcome
8726                        // that failed permission, Inbox, or question checks must
8727                        // never receive a durable ACK merely because SDK saved
8728                        // an error status. Genuine Worker Error/Cancelled frames
8729                        // are accepted terminals even though drive returns Err.
8730                        client.accept_durable_terminal(status);
8731                        if status == TerminalStatus::Completed {
8732                            if let Some(activation) = plain_input {
8733                                // Only the final epoch reaches here: continued Runs
8734                                // above reset their router and stay in the pump.
8735                                *activation.completion_router.lock().recover_poison() =
8736                                    canonical_router.take();
8737                            }
8738                        }
8739                        return terminal_result;
8740                    }
8741                    Ok(None) => {
8742                        return Err(AgentError::LLM(
8743                            "actor child closed before terminal".to_string(),
8744                        ));
8745                    }
8746                    Err(e) => {
8747                        return Err(AgentError::LLM(format!("actor transport error: {e}")));
8748                    }
8749                }
8750            }
8751        }
8752    }
8753
8754    // Only reached on cancellation: ask the child to stop (best-effort), then report cancelled.
8755    let _ = client.send(ParentFrame::Cancel).await;
8756    Err(AgentError::Cancelled)
8757}
8758
8759/// The assignment text = the child session's latest user message (falls back to its title).
8760fn project_id_for_actor_run(
8761    session: &Session,
8762) -> Result<Option<bamboo_domain::ProjectId>, AgentError> {
8763    match crate::project_context::ProjectContextResolver::session_project_identity(session) {
8764        crate::project_context::SessionProjectIdentity::Assigned(project_id) => {
8765            Ok(Some(project_id))
8766        }
8767        crate::project_context::SessionProjectIdentity::Unassigned => Ok(None),
8768        crate::project_context::SessionProjectIdentity::Invalid { raw, message } => {
8769            Err(AgentError::LLM(format!(
8770                "child session carries an invalid Project identity '{raw}': {message}"
8771            )))
8772        }
8773    }
8774}
8775
8776fn remote_broker_unavailable() -> AgentError {
8777    AgentError::LLM("remote_broker_activation_unavailable".into())
8778}
8779
8780/// Only these bounded worker admission errors may cross the fixed Remote
8781/// diagnostic boundary before permission posture confirmation. Transport and
8782/// arbitrary worker text retain the generic remote error.
8783fn remote_environment_error_code(value: &str) -> Option<&'static str> {
8784    const CODES: &[&str] = &[
8785        "remote_environment_lease_invalid",
8786        "remote_environment_snapshot_mismatch",
8787        "remote_environment_checkout_not_clean",
8788        "remote_environment_checkout_unsupported",
8789        "remote_environment_workspace_unavailable",
8790        "remote_environment_workspace_outside_git_root",
8791        "remote_environment_path_unsupported",
8792        "remote_environment_git_unavailable",
8793        "remote_environment_git_inspection_failed",
8794        "remote_environment_git_inspection_timed_out",
8795        "remote_environment_git_output_too_large",
8796        "remote_environment_snapshot_too_large",
8797        "remote_environment_snapshot_changed",
8798        "remote_environment_index_invalid",
8799        "remote_environment_index_unsupported",
8800        "remote_environment_host_path_or_grants_rejected",
8801        "remote_environment_worker_workspace_missing",
8802        "remote_environment_runspec_invalid",
8803        "remote_environment_lease_missing_or_host_path_present",
8804    ];
8805    CODES.iter().copied().find(|code| *code == value)
8806}
8807
8808// Read only: no ensure/repair/Actor initialization or subtree budget. Capture
8809// exactly the canonical chain and compare it again immediately before Run.
8810async fn remote_canonical_lineage(
8811    binding: &SessionInboxRuntimeBinding,
8812    session: &Session,
8813) -> Result<Vec<bamboo_domain::ActorSession>, AgentError> {
8814    let mut current = bamboo_domain::ActorSession::from_session(session)
8815        .map_err(|_| remote_broker_unavailable())?;
8816    let mut chain = Vec::new();
8817    loop {
8818        if chain.len() > MAX_SPAWN_DEPTH as usize {
8819            return Err(remote_broker_unavailable());
8820        }
8821        let saved = binding
8822            .storage
8823            .load_session(&current.actor_id)
8824            .await
8825            .map_err(|_| remote_broker_unavailable())?
8826            .ok_or_else(remote_broker_unavailable)?;
8827        let observed = bamboo_domain::ActorSession::from_session(&saved)
8828            .map_err(|_| remote_broker_unavailable())?;
8829        if observed.actor_id != current.actor_id
8830            || observed.session_created_at != current.session_created_at
8831            || observed.parent_actor_id != current.parent_actor_id
8832            || observed.root_actor_id != current.root_actor_id
8833            || observed.spawn_depth != current.spawn_depth
8834            || observed.project_id != current.project_id
8835        {
8836            return Err(remote_broker_unavailable());
8837        }
8838        chain.push(observed.clone());
8839        let Some(parent_id) = observed.parent_actor_id.as_ref() else {
8840            if observed.actor_id != session.root_session_id || observed.spawn_depth != 0 {
8841                return Err(remote_broker_unavailable());
8842            }
8843            return Ok(chain);
8844        };
8845        let parent = binding
8846            .storage
8847            .load_session(parent_id)
8848            .await
8849            .map_err(|_| remote_broker_unavailable())?
8850            .ok_or_else(remote_broker_unavailable)?;
8851        let parent = bamboo_domain::ActorSession::from_session(&parent)
8852            .map_err(|_| remote_broker_unavailable())?;
8853        if parent.root_actor_id != observed.root_actor_id
8854            || parent.project_id != observed.project_id
8855            || parent.spawn_depth.checked_add(1) != Some(observed.spawn_depth)
8856            || parent.session_created_at > observed.session_created_at
8857        {
8858            return Err(remote_broker_unavailable());
8859        }
8860        current = parent;
8861    }
8862}
8863
8864async fn canonical_child_creation(
8865    binding: &SessionInboxRuntimeBinding,
8866    session: &Session,
8867    job: &SpawnJob,
8868) -> Result<bamboo_subagent::proto::ChildCreationIdentity, AgentError> {
8869    let conflict = || {
8870        AgentError::LLM(
8871            "child creation identity unavailable or changed in canonical host Storage".into(),
8872        )
8873    };
8874    let current = binding
8875        .storage
8876        .load_session(&job.child_session_id)
8877        .await
8878        .map_err(|_| conflict())?
8879        .ok_or_else(conflict)?;
8880    if bamboo_domain::ActorSession::from_session(&current).is_err()
8881        || bamboo_domain::ActorSession::from_session(session).is_err()
8882        || current.kind != bamboo_domain::SessionKind::Child
8883        || session.kind != current.kind
8884        || current.id != session.id
8885        || current.id != job.child_session_id
8886        || current.parent_session_id.as_deref() != Some(job.parent_session_id.as_str())
8887        || current.parent_session_id != session.parent_session_id
8888        || current.root_session_id.trim().is_empty()
8889        || current.root_session_id != session.root_session_id
8890        || current.created_at != session.created_at
8891        || current.spawn_depth != session.spawn_depth
8892        || current.spawn_depth == 0
8893        || project_id_for_actor_run(&current)? != project_id_for_actor_run(session)?
8894    {
8895        return Err(conflict());
8896    }
8897    Ok(bamboo_subagent::proto::ChildCreationIdentity {
8898        created_at: current.created_at,
8899        spawn_depth: current.spawn_depth,
8900    })
8901}
8902
8903fn logical_identity_for_actor_run(session: &Session, job: &SpawnJob) -> LogicalSessionIdentity {
8904    LogicalSessionIdentity {
8905        creation: None,
8906        session_id: session.id.clone(),
8907        parent_session_id: session
8908            .parent_session_id
8909            .clone()
8910            .or_else(|| Some(job.parent_session_id.clone())),
8911        root_session_id: if session.root_session_id.trim().is_empty() {
8912            job.parent_session_id.clone()
8913        } else {
8914            session.root_session_id.clone()
8915        },
8916    }
8917}
8918
8919fn extract_assignment(session: &Session) -> String {
8920    session
8921        .messages
8922        .iter()
8923        .rev()
8924        .find(|m| matches!(m.role, Role::User))
8925        .map(|m| m.content.clone())
8926        .unwrap_or_else(|| {
8927            session
8928                .metadata
8929                .get("title")
8930                .cloned()
8931                .unwrap_or_else(|| "Execute task".to_string())
8932        })
8933}
8934
8935#[cfg(test)]
8936mod tests {
8937    use super::*;
8938    use crate::SessionActivationRouter;
8939    use bamboo_domain::{RuntimeSessionPersistence, SessionInboxPort, Storage};
8940
8941    include!("expired_plain_owner_tests.rs");
8942
8943    #[test]
8944    fn plain_rejection_logs_branch_and_run_without_changing_error() {
8945        let output = tempfile::NamedTempFile::new().unwrap();
8946        let subscriber = tracing_subscriber::fmt()
8947            .with_ansi(false)
8948            .without_time()
8949            .with_writer(output.reopen().unwrap())
8950            .finish();
8951        let branch_line = std::cell::Cell::new(0);
8952        let error = tracing::subscriber::with_default(subscriber, || {
8953            let span = tracing::warn_span!(
8954                "actor_child_run",
8955                child_session_id = %"diagnostic-child",
8956                activation_run_id = tracing::field::Empty
8957            );
8958            let _entered = span.enter();
8959            span.record("activation_run_id", "diagnostic-run");
8960            None::<()>
8961                .ok_or_else(|| {
8962                    let caller_line = line!() + 1;
8963                    let error = plain_actor_unsupported();
8964                    branch_line.set(caller_line);
8965                    error
8966                })
8967                .unwrap_err()
8968        });
8969        assert!(matches!(&error, AgentError::LLM(message)
8970            if message.starts_with("This Actor Child supports a fresh plain activation,")
8971                && message.ends_with("Other continuation is unsupported; durable history is preserved.")));
8972        let log = std::fs::read_to_string(output.path()).unwrap();
8973        assert!(log.contains("child_session_id=diagnostic-child"), "{log}");
8974        assert!(
8975            log.contains("activation_run_id=\"diagnostic-run\""),
8976            "{log}"
8977        );
8978        assert!(
8979            log.contains(&format!("process_id={}", std::process::id())),
8980            "{log}"
8981        );
8982        assert!(log.contains(&format!("source_file={:?}", file!())), "{log}");
8983        assert!(
8984            log.contains(&format!("source_line={}", branch_line.get())),
8985            "{log}"
8986        );
8987        assert!(!error.to_string().contains("diagnostic-run"));
8988    }
8989
8990    #[tokio::test]
8991    async fn completed_plain_continuation_requires_one_input_and_current_identity() {
8992        for case in [
8993            "valid",
8994            "two-inputs",
8995            "tool-history",
8996            "stale-lineage",
8997            "no-release",
8998        ] {
8999            let temp = tempfile::tempdir().unwrap();
9000            let store = Arc::new(
9001                bamboo_storage::SessionStoreV2::new(temp.path().into())
9002                    .await
9003                    .unwrap(),
9004            );
9005            let parent = Session::new("continuation-parent", "model");
9006            store.save_session(&parent).await.unwrap();
9007            let mut child =
9008                Session::new_child_of("continuation-child", &parent, "model", "first task");
9009            child.add_message(bamboo_agent_core::Message::assistant("first result", None));
9010            if case == "tool-history" {
9011                let mut tool = bamboo_agent_core::Message::assistant("old tool result", None);
9012                tool.role = Role::Tool;
9013                child.add_message(tool);
9014            }
9015            child.set_last_run_status("completed");
9016            store.save_session(&child).await.unwrap();
9017            store.ensure_actor(&child.id).await.unwrap();
9018            let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
9019            bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
9020            let now = chrono::Utc::now();
9021            let old = store
9022                .claim_activation(&ActorActivationClaim {
9023                    actor_id: child.id.clone(),
9024                    run_id: "first-run".into(),
9025                    lease_owner: "first-owner".into(),
9026                    lease_expires_at: now + chrono::Duration::minutes(1),
9027                    inbox_generation: 0,
9028                    placement_ref: None,
9029                    now,
9030                })
9031                .await
9032                .unwrap();
9033            store.start_activation(&old.fence(), now).await.unwrap();
9034            store
9035                .finish_activation(&old.fence(), now, ActorActivationFinish::Succeeded)
9036                .await
9037                .unwrap();
9038            let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
9039                store.clone(),
9040                Default::default(),
9041            ));
9042            for slot in 0..if case == "two-inputs" { 2 } else { 1 } {
9043                let envelope = bamboo_domain::SessionMessageEnvelope::user_input(
9044                    &child.id,
9045                    format!("new task {slot}"),
9046                );
9047                let receipt = inbox.deliver(&envelope).await.unwrap();
9048                inbox
9049                    .mark_activation_eligible(
9050                        &child.id,
9051                        receipt.generation,
9052                        bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
9053                    )
9054                    .await
9055                    .unwrap();
9056            }
9057            let storage: Arc<dyn Storage> = store.clone();
9058            let persistence = Arc::new(bamboo_storage::LockedSessionStore::new(storage));
9059            let binding = actor_binding(store.clone(), inbox, persistence);
9060            let _registration = binding
9061                .router
9062                .register_run(&child.id, "second-run")
9063                .await
9064                .unwrap();
9065            if case == "stale-lineage" {
9066                child.parent_session_id = Some("foreign-parent".into());
9067            }
9068            let result = PlainActorActivation::start(
9069                store.clone(),
9070                &child,
9071                &binding,
9072                Some("second-run"),
9073                Some(policy),
9074                true,
9075                (case != "no-release").then_some("second-worker"),
9076            )
9077            .await;
9078            if case == "valid" {
9079                let activation = result.expect("one new input on a completed plain Child");
9080                assert_eq!(activation.fence.attempt, old.attempt + 1);
9081                assert_eq!(activation.fence.actor_id, child.id);
9082                assert_eq!(activation.inbox_generation, 1);
9083                activation
9084                    .finish(ActorActivationFinish::Failed)
9085                    .await
9086                    .unwrap();
9087            } else {
9088                assert!(
9089                    result.is_err(),
9090                    "unsupported continuation {case} must fail before claim"
9091                );
9092                assert_eq!(
9093                    store
9094                        .inspect_actor(&child.id)
9095                        .await
9096                        .unwrap()
9097                        .actor
9098                        .current_attempt,
9099                    old.attempt
9100                );
9101            }
9102            let canonical = store.load_session(&child.id).await.unwrap().unwrap();
9103            assert_eq!(
9104                canonical.parent_session_id.as_deref(),
9105                Some(parent.id.as_str())
9106            );
9107            assert!(canonical
9108                .messages
9109                .iter()
9110                .any(|message| message.content == "first result"));
9111        }
9112    }
9113
9114    #[tokio::test]
9115    async fn pre_dispatch_placement_failure_fails_only_claimed_activation() {
9116        let temp = tempfile::tempdir().unwrap();
9117        let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
9118            .await
9119            .unwrap();
9120        let parent = Session::new("placement-parent", "model");
9121        let child = Session::new_child_of("placement-child", &parent, "model", "task");
9122        store.save_session(&parent).await.unwrap();
9123        store.save_session(&child).await.unwrap();
9124        store.ensure_actor(&child.id).await.unwrap();
9125        store
9126            .bind_control_plane(
9127                &child.id,
9128                bamboo_domain::ActorControlPlaneBinding {
9129                    policy_revision: None,
9130                    placement_intent: Some(bamboo_domain::ActorPlacementIntent::Pool {
9131                        pool_id: "remote-worker".into(),
9132                    }),
9133                },
9134            )
9135            .await
9136            .unwrap();
9137
9138        let claim = |run_id: &str| ActorActivationClaim {
9139            actor_id: child.id.clone(),
9140            run_id: run_id.into(),
9141            lease_owner: format!("host-{run_id}"),
9142            lease_expires_at: chrono::Utc::now() + chrono::Duration::seconds(80),
9143            inbox_generation: 0,
9144            placement_ref: Some(bamboo_domain::ActorPlacementRef {
9145                class: bamboo_domain::ActorPlacementClass::Remote,
9146                lease_id: format!("slot-{run_id}"),
9147                slot_epoch: Some(1),
9148            }),
9149            now: chrono::Utc::now(),
9150        };
9151        let first = store.claim_activation(&claim("first")).await.unwrap();
9152        store
9153            .start_activation(&first.fence(), chrono::Utc::now())
9154            .await
9155            .unwrap();
9156        assert!(store.claim_activation(&claim("retry")).await.is_err());
9157
9158        // This is the post-claim placement preflight failure path. No Run has
9159        // been dispatched, so the same ActorId can retry before lease expiry.
9160        fail_pre_dispatch_activation(&store, &first.fence())
9161            .await
9162            .unwrap();
9163        let failed = store.inspect_actor(&child.id).await.unwrap();
9164        assert_eq!(
9165            failed.activation.unwrap().status,
9166            bamboo_domain::ActorActivationStatus::Failed
9167        );
9168        let retry = store.claim_activation(&claim("retry")).await.unwrap();
9169        assert_eq!(retry.attempt, first.attempt + 1);
9170        assert!(fail_pre_dispatch_activation(&store, &first.fence())
9171            .await
9172            .is_err());
9173        assert_eq!(
9174            store.inspect_actor(&child.id).await.unwrap().activation,
9175            Some(retry)
9176        );
9177    }
9178
9179    #[tokio::test]
9180    async fn lost_run_delivery_receipt_keeps_actor_activation_live() {
9181        struct AcceptedWithoutReceipt {
9182            published: bool,
9183        }
9184
9185        #[async_trait]
9186        impl bamboo_subagent::ChildLink for AcceptedWithoutReceipt {
9187            async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
9188                assert!(matches!(frame, ParentFrame::Run(_)));
9189                self.published = true;
9190                Err(bamboo_subagent::TransportError::Protocol(
9191                    "broker delivery receipt lost after publication".into(),
9192                ))
9193            }
9194
9195            async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
9196                std::future::pending().await
9197            }
9198        }
9199
9200        let temp = tempfile::tempdir().unwrap();
9201        let store = Arc::new(
9202            bamboo_storage::SessionStoreV2::new(temp.path().into())
9203                .await
9204                .unwrap(),
9205        );
9206        let parent = Session::new("dispatch-parent", "model");
9207        let child = Session::new_child_of("dispatch-child", &parent, "model", "task");
9208        store.save_session(&parent).await.unwrap();
9209        store.save_session(&child).await.unwrap();
9210        store.ensure_actor(&child.id).await.unwrap();
9211        let now = chrono::Utc::now();
9212        let claim = ActorActivationClaim {
9213            actor_id: child.id.clone(),
9214            run_id: "dispatch-run".into(),
9215            lease_owner: "dispatch-host".into(),
9216            lease_expires_at: now + chrono::Duration::seconds(80),
9217            inbox_generation: 0,
9218            placement_ref: None,
9219            now,
9220        };
9221        let activation = store.claim_activation(&claim).await.unwrap();
9222        store
9223            .start_activation(&activation.fence(), chrono::Utc::now())
9224            .await
9225            .unwrap();
9226        let mut link = AcceptedWithoutReceipt { published: false };
9227        let mut send_invoked = false;
9228        let run = RunSpec {
9229            assignment: "task".into(),
9230            logical_session: None,
9231            project_id: None,
9232            reasoning_effort: None,
9233            permission_policy: None,
9234            messages: Vec::new(),
9235            activation_run_id: Some(claim.run_id.clone()),
9236            execution_epoch: 1,
9237            initial_session_messages: Vec::new(),
9238            secrets: Default::default(),
9239        };
9240        assert!(send_actor_run(&mut link, run, &mut send_invoked)
9241            .await
9242            .is_err());
9243        assert!(send_invoked && link.published);
9244        let slot = WorkerSlotLease {
9245            lease_id: "dispatch-slot".into(),
9246            host_ref: "dispatch-worker".into(),
9247            connection_generation: "dispatch-generation".into(),
9248            slot: 0,
9249            epoch: 1,
9250            actor_id: child.id.clone(),
9251            run_id: claim.run_id.clone(),
9252            expires_at: claim.lease_expires_at,
9253        };
9254        let mut lease = ActivePlacementLease {
9255            registry: bamboo_storage::v2::FileHostRegistry::new(temp.path().into())
9256                .await
9257                .unwrap(),
9258            actor_store: store.clone(),
9259            route: ScopedPlacementRoute {
9260                endpoint: String::new(),
9261                token: String::new(),
9262                ca_cert_file: PathBuf::new(),
9263                parent: bamboo_subagent::AgentRef {
9264                    session_id: "dispatch-parent".into(),
9265                    role: Some("host".into()),
9266                },
9267                worker_role: "worker".into(),
9268                pinned_mailbox: None,
9269                requirements: Default::default(),
9270            },
9271            mailbox: "dispatch-worker".into(),
9272            host_ref: "dispatch-worker".into(),
9273            fence: activation.fence(),
9274            placement_ref: bamboo_domain::ActorPlacementRef {
9275                class: bamboo_domain::ActorPlacementClass::Remote,
9276                lease_id: slot.lease_id.clone(),
9277                slot_epoch: Some(slot.epoch),
9278            },
9279            lease: Arc::new(tokio::sync::Mutex::new(slot)),
9280            heartbeat: tokio::spawn(std::future::pending()),
9281            run_cancel: CancellationToken::new(),
9282            lost: Arc::new(AtomicBool::new(false)),
9283            release_on_drop: AtomicBool::new(false),
9284        };
9285        assert!(!lease.abort_if_run_unsent(send_invoked).await.unwrap());
9286        assert_eq!(
9287            store
9288                .inspect_actor(&child.id)
9289                .await
9290                .unwrap()
9291                .activation
9292                .unwrap()
9293                .status,
9294            bamboo_domain::ActorActivationStatus::Running
9295        );
9296        let mut retry = claim.clone();
9297        retry.run_id = "dispatch-retry".into();
9298        retry.now = chrono::Utc::now();
9299        retry.lease_expires_at = retry.now + chrono::Duration::seconds(80);
9300        assert!(matches!(
9301            store.claim_activation(&retry).await,
9302            Err(bamboo_domain::ActorDirectoryError::Busy)
9303        ));
9304    }
9305
9306    #[test]
9307    fn accepted_cancelled_terminal_maps_to_cancelled_actor_activation() {
9308        let cancelled: Result<Option<String>, AgentError> = Err(AgentError::Cancelled);
9309        assert_eq!(
9310            remote_activation_finish(&cancelled, Some(TerminalStatus::Cancelled)),
9311            ActorActivationFinish::Cancelled
9312        );
9313        assert_eq!(
9314            remote_activation_finish(&cancelled, None),
9315            ActorActivationFinish::Failed
9316        );
9317        assert_eq!(
9318            remote_activation_finish(&cancelled, Some(TerminalStatus::Completed)),
9319            ActorActivationFinish::Failed
9320        );
9321    }
9322
9323    #[tokio::test]
9324    async fn remote_reply_is_fenced_before_terminal_status_save() {
9325        let temp = tempfile::tempdir().unwrap();
9326        let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
9327            .await
9328            .unwrap();
9329        let parent = Session::new("remote-reply-parent", "model");
9330        let mut child = Session::new_child_of("remote-reply-child", &parent, "model", "task");
9331        child.add_message(bamboo_agent_core::Message::user("task"));
9332        store.save_session(&parent).await.unwrap();
9333        store.save_session(&child).await.unwrap();
9334        store.ensure_actor(&child.id).await.unwrap();
9335        store
9336            .bind_control_plane(
9337                &child.id,
9338                bamboo_domain::ActorControlPlaneBinding {
9339                    policy_revision: None,
9340                    placement_intent: Some(bamboo_domain::ActorPlacementIntent::Pool {
9341                        pool_id: "remote-worker".into(),
9342                    }),
9343                },
9344            )
9345            .await
9346            .unwrap();
9347        let now = chrono::Utc::now();
9348        let activation = store
9349            .claim_activation(&ActorActivationClaim {
9350                actor_id: child.id.clone(),
9351                run_id: "remote-reply-run".into(),
9352                lease_owner: "remote-worker".into(),
9353                lease_expires_at: now + chrono::Duration::seconds(80),
9354                inbox_generation: 0,
9355                placement_ref: Some(bamboo_domain::ActorPlacementRef {
9356                    class: bamboo_domain::ActorPlacementClass::Remote,
9357                    lease_id: "remote-slot".into(),
9358                    slot_epoch: Some(1),
9359                }),
9360                now,
9361            })
9362            .await
9363            .unwrap();
9364        let fence = activation.fence();
9365        store.start_activation(&fence, now).await.unwrap();
9366
9367        append_fenced_remote_reply(&store, &fence, &mut child, "done")
9368            .await
9369            .unwrap();
9370        store
9371            .finish_activation(&fence, chrono::Utc::now(), ActorActivationFinish::Succeeded)
9372            .await
9373            .unwrap();
9374        child.set_last_run_status("completed");
9375        store.save_session(&child).await.unwrap();
9376        let saved = store.load_session(&child.id).await.unwrap().unwrap();
9377        assert_eq!(saved.last_run_status().as_deref(), Some("completed"));
9378        assert_eq!(saved.messages.len(), 2);
9379        assert_eq!(saved.messages.last().unwrap().content, "done");
9380        assert!(
9381            append_fenced_remote_reply(&store, &fence, &mut child, "duplicate")
9382                .await
9383                .is_err()
9384        );
9385        assert_eq!(
9386            store
9387                .load_session(&child.id)
9388                .await
9389                .unwrap()
9390                .unwrap()
9391                .messages
9392                .len(),
9393            2
9394        );
9395    }
9396
9397    async fn start_bus() -> (String, tempfile::TempDir) {
9398        let dir = tempfile::tempdir().unwrap();
9399        let core = Arc::new(bamboo_broker::BrokerCore::new(dir.path()));
9400        let server = Arc::new(bamboo_broker::BrokerServer::new(core, "t"));
9401        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9402        let addr = listener.local_addr().unwrap();
9403        tokio::spawn(async move {
9404            let _ = server.serve(listener).await;
9405        });
9406        (format!("ws://{addr}"), dir)
9407    }
9408
9409    struct DurableAckProbe {
9410        calls: Arc<AtomicUsize>,
9411        fail: bool,
9412        terminal_status: TerminalStatus,
9413        parent_mailbox: String,
9414    }
9415
9416    #[async_trait]
9417    impl bamboo_subagent::ChildLink for DurableAckProbe {
9418        async fn send(&mut self, _frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
9419            Ok(())
9420        }
9421
9422        async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
9423            Ok(None)
9424        }
9425
9426        fn has_pending_durable_terminal(&self) -> bool {
9427            true
9428        }
9429
9430        fn durable_delivery_receipt(&self) -> Option<bamboo_subagent::DurableChildDeliveryReceipt> {
9431            Some(bamboo_subagent::DurableChildDeliveryReceipt {
9432                broker_identity: "00000000-0000-4000-8000-000000000001".into(),
9433                parent_mailbox: self.parent_mailbox.clone(),
9434                correlation_id: "broker-run-1".into(),
9435                message_ids: vec!["broker-outcome-1".into()],
9436                terminal_status: self.terminal_status,
9437            })
9438        }
9439
9440        async fn acknowledge_durable_frames(&mut self) -> bamboo_subagent::TransportResult<()> {
9441            self.calls.fetch_add(1, Ordering::SeqCst);
9442            if self.fail {
9443                Err(bamboo_subagent::TransportError::Protocol(
9444                    "injected ACK delivery failure".into(),
9445                ))
9446            } else {
9447                Ok(())
9448            }
9449        }
9450    }
9451
9452    async fn insert_ack_probe(
9453        runner: &ActorChildRunner,
9454        session: &Session,
9455        calls: &Arc<AtomicUsize>,
9456        terminal_status: TerminalStatus,
9457    ) {
9458        insert_ack_probe_with_nested_wait(runner, session, calls, terminal_status, false).await;
9459    }
9460
9461    async fn insert_ack_probe_with_nested_wait(
9462        runner: &ActorChildRunner,
9463        session: &Session,
9464        calls: &Arc<AtomicUsize>,
9465        terminal_status: TerminalStatus,
9466        host_nested_wait_handoff: bool,
9467    ) {
9468        runner.pending_durable_links.lock().await.insert(
9469            (session.id.clone(), session.created_at, "run-1".into()),
9470            PendingDurableChildLink {
9471                link: Box::new(DurableAckProbe {
9472                    calls: calls.clone(),
9473                    fail: false,
9474                    terminal_status,
9475                    parent_mailbox: format!("p-{}", session.id),
9476                }),
9477                host_nested_wait_handoff,
9478                strict_history: None,
9479                strict_history_required: false,
9480                strict_history_committed: false,
9481            },
9482        );
9483    }
9484
9485    #[tokio::test]
9486    async fn broker_receipt_reconciler_repairs_only_the_proven_local_mailbox() {
9487        let (endpoint, broker_dir) = start_bus().await;
9488        let home = tempfile::tempdir().unwrap();
9489        let store = Arc::new(
9490            bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
9491                .await
9492                .unwrap(),
9493        );
9494        let root = Session::new("receipt-root", "model");
9495        store.save_session(&root).await.unwrap();
9496        let mut sender = bamboo_broker::BrokerClient::connect(
9497            &endpoint,
9498            bamboo_subagent::AgentRef {
9499                session_id: "receipt-worker".into(),
9500                role: None,
9501            },
9502            "t",
9503        )
9504        .await
9505        .unwrap();
9506        let broker_identity = sender.durable_broker_identity().await.unwrap();
9507        let wrong_broker_identity = if broker_identity == "00000000-0000-4000-8000-000000000001" {
9508            "00000000-0000-4000-8000-000000000002"
9509        } else {
9510            "00000000-0000-4000-8000-000000000001"
9511        };
9512
9513        for (id, receipt_mailbox, receipt_broker) in [
9514            ("receipt-good", "p-receipt-good", broker_identity.as_str()),
9515            (
9516                "receipt-wrong-mailbox",
9517                "p-another-child",
9518                broker_identity.as_str(),
9519            ),
9520            (
9521                "receipt-wrong-broker",
9522                "p-receipt-wrong-broker",
9523                wrong_broker_identity,
9524            ),
9525            (
9526                "receipt-local-role-changed",
9527                "p-receipt-local-role-changed",
9528                broker_identity.as_str(),
9529            ),
9530            (
9531                "receipt-remote-unproven",
9532                "remote-parent-unproven",
9533                broker_identity.as_str(),
9534            ),
9535        ] {
9536            let mut child = Session::new_child_of(id, &root, "model", "task");
9537            child
9538                .metadata
9539                .insert("runtime.kind".into(), "external".into());
9540            child
9541                .metadata
9542                .insert("external.protocol".into(), "actor".into());
9543            child.metadata.insert(
9544                "external.agent_id".into(),
9545                crate::external_agents::config::LOCAL_ACTOR_AGENT_ID.into(),
9546            );
9547            if id == "receipt-local-role-changed" || id == "receipt-remote-unproven" {
9548                child
9549                    .metadata
9550                    .insert("subagent_type".into(), "remote-worker".into());
9551            }
9552            child.add_message(bamboo_agent_core::Message::user("work"));
9553            store.save_session(&child).await.unwrap();
9554            let message_id = bamboo_subagent::MsgId::new();
9555            sender
9556                .deliver(
9557                    &format!("p-{id}"),
9558                    bamboo_subagent::InboxMessage {
9559                        id: message_id.clone(),
9560                        from: bamboo_subagent::AgentRef {
9561                            session_id: "receipt-worker".into(),
9562                            role: None,
9563                        },
9564                        kind: bamboo_subagent::InboxKind::Outcome,
9565                        body: serde_json::to_value(bamboo_subagent::ChildOutcome::completed(
9566                            "done",
9567                        ))
9568                        .unwrap(),
9569                        created_at: chrono::Utc::now(),
9570                        correlation_id: Some(bamboo_subagent::MsgId::new()),
9571                    },
9572                )
9573                .await
9574                .unwrap();
9575            // The Host can checkpoint only a frame already surfaced from the
9576            // broker, which moves its Maildir file from new/ into cur/.
9577            let mailbox =
9578                bamboo_subagent::Mailbox::at(broker_dir.path().join(format!("mailboxes/p-{id}")));
9579            assert_eq!(mailbox.drain().await.unwrap().len(), 1);
9580            child.add_message(bamboo_agent_core::Message::assistant("done", None));
9581            child.set_last_run_status("completed");
9582            if id == "receipt-good" {
9583                let prior_ids = child.messages[..child.messages.len() - 1]
9584                    .iter()
9585                    .map(|message| message.id.clone())
9586                    .collect();
9587                crate::execution::ChildCompletionSource::prepare(&mut child, "run-1", &prior_ids);
9588                assert!(
9589                    crate::execution::ChildCompletionSource::defer_for_durable_delivery(
9590                        &mut child, "run-1"
9591                    )
9592                    .unwrap()
9593                );
9594                let proof = bamboo_storage::v2::HostTerminalCompleteness::from_verified_host(
9595                    &child, "run-1", 7, 1,
9596                )
9597                .unwrap();
9598                store
9599                    .prepare_broker_terminal_receipt_with_completeness(
9600                        &child,
9601                        bamboo_storage::v2::BrokerTerminalRoute {
9602                            activation_run_id: "run-1",
9603                            broker_identity: receipt_broker,
9604                            parent_mailbox: receipt_mailbox,
9605                            broker_correlation_id: "correlation-1",
9606                            message_ids: &[message_id.as_str().to_owned()],
9607                        },
9608                        &proof,
9609                    )
9610                    .await
9611                    .unwrap();
9612                let mut staged = child.clone();
9613                staged.set_last_run_status("running");
9614                staged.clear_last_run_error();
9615                store.save_session(&staged).await.unwrap();
9616                store
9617                    .commit_broker_terminal_receipt_with_completeness(&child, "run-1", &proof)
9618                    .await
9619                    .unwrap();
9620            } else {
9621                store
9622                    .prepare_broker_terminal_receipt(
9623                        &child,
9624                        "run-1",
9625                        receipt_broker,
9626                        receipt_mailbox,
9627                        "correlation-1",
9628                        &[message_id.as_str().to_owned()],
9629                    )
9630                    .await
9631                    .unwrap();
9632                store.save_session(&child).await.unwrap();
9633            }
9634        }
9635
9636        let mut config = bamboo_config::Config::default();
9637        config.subagents_mut().broker = Some(bamboo_config::BrokerClientConfig {
9638            endpoint,
9639            token: "t".into(),
9640            ..Default::default()
9641        });
9642        config
9643            .subagents_mut()
9644            .remote_placements
9645            .push(bamboo_config::RemoteActorPlacement {
9646                role: "remote-worker".into(),
9647                endpoint: "ws://unscoped.example.invalid".into(),
9648                token_env: None,
9649                ca_cert_file: None,
9650                broker_peer: None,
9651                placement_requirements: None,
9652            });
9653        // Recover on an independently reopened Store, with no Actor executor or
9654        // provider available. Only the committed Host proof can authorize ACK.
9655        let cold_store = Arc::new(
9656            bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
9657                .await
9658                .unwrap(),
9659        );
9660        let before = cold_store
9661            .load_session("receipt-good")
9662            .await
9663            .unwrap()
9664            .unwrap();
9665        assert!(crate::execution::ChildCompletionSource::has_source_record(
9666            &before
9667        ));
9668        assert_eq!(before.last_run_status().as_deref(), Some("running"));
9669        assert!(crate::execution::ChildCompletionSource::from_committed_session(&before).is_none());
9670        let repair = BrokerTerminalReceiptReconciler::new(cold_store.clone(), &config);
9671        let report = repair.reconcile_once().await;
9672        assert_eq!(report.candidates, 5);
9673        assert_eq!(report.repaired, 2);
9674        assert_eq!(report.blocked, 3);
9675        let after = cold_store
9676            .load_session("receipt-good")
9677            .await
9678            .unwrap()
9679            .unwrap();
9680        assert!(crate::execution::ChildCompletionSource::from_committed_session(&after).is_some());
9681        assert_eq!(
9682            serde_json::to_value(before.messages).unwrap(),
9683            serde_json::to_value(after.messages).unwrap()
9684        );
9685        let retry = repair.reconcile_once().await;
9686        assert_eq!(retry.repaired, 0, "committed strict recovery is idempotent");
9687
9688        let good = bamboo_subagent::Mailbox::at(broker_dir.path().join("mailboxes/p-receipt-good"));
9689        let wrong = bamboo_subagent::Mailbox::at(
9690            broker_dir.path().join("mailboxes/p-receipt-wrong-mailbox"),
9691        );
9692        let wrong_broker = bamboo_subagent::Mailbox::at(
9693            broker_dir.path().join("mailboxes/p-receipt-wrong-broker"),
9694        );
9695        let local_role_changed = bamboo_subagent::Mailbox::at(
9696            broker_dir
9697                .path()
9698                .join("mailboxes/p-receipt-local-role-changed"),
9699        );
9700        let remote_unproven = bamboo_subagent::Mailbox::at(
9701            broker_dir
9702                .path()
9703                .join("mailboxes/p-receipt-remote-unproven"),
9704        );
9705        assert_eq!(good.pending_count().await.unwrap(), 0);
9706        assert_eq!(wrong.pending_count().await.unwrap(), 1);
9707        assert_eq!(wrong_broker.pending_count().await.unwrap(), 1);
9708        assert_eq!(local_role_changed.pending_count().await.unwrap(), 0);
9709        assert_eq!(remote_unproven.pending_count().await.unwrap(), 1);
9710        let good_child = store.load_session("receipt-good").await.unwrap().unwrap();
9711        let wrong_child = store
9712            .load_session("receipt-wrong-mailbox")
9713            .await
9714            .unwrap()
9715            .unwrap();
9716        let wrong_broker_child = store
9717            .load_session("receipt-wrong-broker")
9718            .await
9719            .unwrap()
9720            .unwrap();
9721        let local_role_changed_child = store
9722            .load_session("receipt-local-role-changed")
9723            .await
9724            .unwrap()
9725            .unwrap();
9726        let remote_unproven_child = store
9727            .load_session("receipt-remote-unproven")
9728            .await
9729            .unwrap()
9730            .unwrap();
9731        assert!(store
9732            .recover_broker_terminal_receipts(&good_child)
9733            .await
9734            .unwrap()
9735            .is_empty());
9736        assert_eq!(
9737            store
9738                .recover_broker_terminal_receipts(&wrong_child)
9739                .await
9740                .unwrap()
9741                .len(),
9742            1
9743        );
9744        assert_eq!(
9745            store
9746                .recover_broker_terminal_receipts(&wrong_broker_child)
9747                .await
9748                .unwrap()
9749                .len(),
9750            1
9751        );
9752        assert!(store
9753            .recover_broker_terminal_receipts(&local_role_changed_child)
9754            .await
9755            .unwrap()
9756            .is_empty());
9757        assert_eq!(
9758            store
9759                .recover_broker_terminal_receipts(&remote_unproven_child)
9760                .await
9761                .unwrap()
9762                .len(),
9763            1
9764        );
9765    }
9766
9767    #[tokio::test]
9768    async fn broker_receipt_confirmation_requires_exact_birth_run_and_committed_status() {
9769        let runner = ActorChildRunner::new(
9770            "test".into(),
9771            PathBuf::new(),
9772            vec![],
9773            PathBuf::new(),
9774            ExecutorSpec::BambooRuntime,
9775            vec![],
9776            "test".into(),
9777            1,
9778        );
9779        let home = tempfile::tempdir().unwrap();
9780        let store = Arc::new(
9781            bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
9782                .await
9783                .unwrap(),
9784        );
9785        let root = Session::new("ack-parent", "model");
9786        store.save_session(&root).await.unwrap();
9787        let mut child = Session::new_child_of("ack-child", &root, "model", "task");
9788        child.add_message(bamboo_agent_core::Message::user("work"));
9789        store.save_session(&child).await.unwrap();
9790        child.add_message(bamboo_agent_core::Message::assistant("done", None));
9791        child.set_last_run_status("completed");
9792        store
9793            .prepare_broker_terminal_receipt(
9794                &child,
9795                "run-1",
9796                "00000000-0000-4000-8000-000000000001",
9797                "p-ack-child",
9798                "broker-run-1",
9799                &["broker-outcome-1".into()],
9800            )
9801            .await
9802            .unwrap();
9803        store.save_session(&child).await.unwrap();
9804        runner.set_actor_directory_store(Some(store));
9805        let calls = Arc::new(AtomicUsize::new(0));
9806        insert_ack_probe(&runner, &child, &calls, TerminalStatus::Completed).await;
9807        runner
9808            .confirm_durable_child_delivery(&child, "wrong-run", true)
9809            .await
9810            .unwrap();
9811        let mut wrong_birth = child.clone();
9812        wrong_birth.created_at += chrono::Duration::seconds(1);
9813        runner
9814            .confirm_durable_child_delivery(&wrong_birth, "run-1", true)
9815            .await
9816            .unwrap();
9817        assert_eq!(runner.pending_durable_links.lock().await.len(), 1);
9818        child.set_last_run_status("completed");
9819        runner
9820            .confirm_durable_child_delivery(&child, "run-1", false)
9821            .await
9822            .unwrap();
9823        assert_eq!(calls.load(Ordering::SeqCst), 0);
9824
9825        insert_ack_probe(&runner, &child, &calls, TerminalStatus::Completed).await;
9826        child.set_last_run_status("error");
9827        runner
9828            .confirm_durable_child_delivery(&child, "run-1", true)
9829            .await
9830            .unwrap();
9831        assert_eq!(calls.load(Ordering::SeqCst), 0);
9832
9833        insert_ack_probe(&runner, &child, &calls, TerminalStatus::Completed).await;
9834        child.set_last_run_status("completed");
9835        runner
9836            .confirm_durable_child_delivery(&child, "run-1", true)
9837            .await
9838            .unwrap();
9839        assert_eq!(calls.load(Ordering::SeqCst), 1);
9840    }
9841
9842    #[tokio::test]
9843    async fn strict_completeness_gate_precedes_ack_and_ack_failure_preserves_cold_proof() {
9844        for commit in [false, true] {
9845            let runner = ActorChildRunner::new(
9846                "test".into(),
9847                PathBuf::new(),
9848                vec![],
9849                PathBuf::new(),
9850                ExecutorSpec::BambooRuntime,
9851                vec![],
9852                "test".into(),
9853                1,
9854            );
9855            let home = tempfile::tempdir().unwrap();
9856            let store = Arc::new(
9857                bamboo_storage::SessionStoreV2::new(home.path().into())
9858                    .await
9859                    .unwrap(),
9860            );
9861            let root = Session::new("strict-ack-parent", "model");
9862            store.save_session(&root).await.unwrap();
9863            let mut child = Session::new_child_of("strict-ack-child", &root, "model", "work");
9864            child.add_message(bamboo_agent_core::Message::user("work"));
9865            store.save_session(&child).await.unwrap();
9866            child.add_message(bamboo_agent_core::Message::assistant("finished", None));
9867            child.set_last_run_status("completed");
9868            let proof = bamboo_storage::v2::HostTerminalCompleteness::from_verified_host(
9869                &child, "run-1", 7, 2,
9870            )
9871            .unwrap();
9872            let creation = bamboo_subagent::proto::ChildCreationIdentity {
9873                created_at: child.created_at,
9874                spawn_depth: child.spawn_depth,
9875            };
9876            let history = StrictTerminalHistory {
9877                proof: proof.clone(),
9878                watermark: bamboo_subagent::ActorEventWatermark {
9879                    version: 1,
9880                    logical_session: Some(actor_event_logical_identity(
9881                        &child,
9882                        &root.id,
9883                        Some(&creation),
9884                    )),
9885                    activation_id: Some("run-1".into()),
9886                    execution_epoch: 7,
9887                    final_seq: 2,
9888                },
9889            };
9890            let parent_mailbox = "p-strict-ack-child";
9891            store
9892                .prepare_broker_terminal_receipt_with_completeness(
9893                    &child,
9894                    bamboo_storage::v2::BrokerTerminalRoute {
9895                        activation_run_id: "run-1",
9896                        broker_identity: "00000000-0000-4000-8000-000000000001",
9897                        parent_mailbox,
9898                        broker_correlation_id: "broker-run-1",
9899                        message_ids: &["broker-outcome-1".into()],
9900                    },
9901                    &proof,
9902                )
9903                .await
9904                .unwrap();
9905            store.save_session(&child).await.unwrap();
9906            runner.set_actor_directory_store(Some(store.clone()));
9907            let calls = Arc::new(AtomicUsize::new(0));
9908            runner.pending_durable_links.lock().await.insert(
9909                (child.id.clone(), child.created_at, "run-1".into()),
9910                PendingDurableChildLink {
9911                    link: Box::new(DurableAckProbe {
9912                        calls: calls.clone(),
9913                        fail: true,
9914                        terminal_status: TerminalStatus::Completed,
9915                        parent_mailbox: parent_mailbox.into(),
9916                    }),
9917                    host_nested_wait_handoff: false,
9918                    strict_history: Some(history),
9919                    strict_history_required: true,
9920                    strict_history_committed: false,
9921                },
9922            );
9923            if commit {
9924                runner
9925                    .commit_durable_child_delivery(&child, "run-1")
9926                    .await
9927                    .unwrap();
9928                assert_eq!(calls.load(Ordering::SeqCst), 0, "proof commit cannot ACK");
9929            }
9930            assert!(runner
9931                .confirm_durable_child_delivery(&child, "run-1", true)
9932                .await
9933                .is_err());
9934            assert_eq!(calls.load(Ordering::SeqCst), usize::from(commit));
9935            let cold = bamboo_storage::SessionStoreV2::new(home.path().into())
9936                .await
9937                .unwrap();
9938            let recovered = cold.recover_broker_terminal_receipts(&child).await;
9939            if commit {
9940                let receipts = recovered.unwrap();
9941                assert_eq!(receipts.len(), 1);
9942                assert_eq!(
9943                    receipts[0]
9944                        .terminal_completeness
9945                        .as_ref()
9946                        .unwrap()
9947                        .contiguous_applied_seq,
9948                    2
9949                );
9950                assert_eq!(
9951                    cold.load_session(&child.id)
9952                        .await
9953                        .unwrap()
9954                        .unwrap()
9955                        .last_run_status()
9956                        .as_deref(),
9957                    Some("completed")
9958                );
9959            } else {
9960                assert!(
9961                    recovered.is_err(),
9962                    "matching canonical history is not proof commitment"
9963                );
9964            }
9965            assert_eq!(
9966                serde_json::to_value(
9967                    cold.load_session(&child.id)
9968                        .await
9969                        .unwrap()
9970                        .unwrap()
9971                        .messages
9972                )
9973                .unwrap(),
9974                serde_json::to_value(&child.messages).unwrap(),
9975            );
9976        }
9977    }
9978
9979    #[tokio::test]
9980    async fn broker_receipt_confirms_canonical_worker_error_and_cancelled_status() {
9981        for (terminal_status, status) in [
9982            (TerminalStatus::Error, "error"),
9983            (TerminalStatus::Cancelled, "cancelled"),
9984        ] {
9985            let runner = ActorChildRunner::new(
9986                "test".into(),
9987                PathBuf::new(),
9988                vec![],
9989                PathBuf::new(),
9990                ExecutorSpec::BambooRuntime,
9991                vec![],
9992                "test".into(),
9993                1,
9994            );
9995            let home = tempfile::tempdir().unwrap();
9996            let store = Arc::new(
9997                bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
9998                    .await
9999                    .unwrap(),
10000            );
10001            let root = Session::new("failed-ack-parent", "model");
10002            store.save_session(&root).await.unwrap();
10003            let mut child = Session::new_child_of("failed-ack-child", &root, "model", "task");
10004            child.add_message(bamboo_agent_core::Message::user("work"));
10005            store.save_session(&child).await.unwrap();
10006            child.set_last_run_status(status);
10007            child.set_last_run_error(format!("worker {status}"));
10008            store
10009                .prepare_broker_terminal_receipt(
10010                    &child,
10011                    "run-1",
10012                    "00000000-0000-4000-8000-000000000001",
10013                    "p-failed-ack-child",
10014                    "broker-run-1",
10015                    &["broker-outcome-1".into()],
10016                )
10017                .await
10018                .unwrap();
10019            store.save_session(&child).await.unwrap();
10020            runner.set_actor_directory_store(Some(store));
10021            let calls = Arc::new(AtomicUsize::new(0));
10022            insert_ack_probe(&runner, &child, &calls, terminal_status).await;
10023            runner
10024                .confirm_durable_child_delivery(&child, "run-1", true)
10025                .await
10026                .unwrap();
10027            assert_eq!(calls.load(Ordering::SeqCst), 1, "{status}");
10028        }
10029    }
10030
10031    #[tokio::test]
10032    async fn broker_completed_terminal_projects_only_host_verified_nested_wait() {
10033        assert_eq!(
10034            effective_broker_terminal_status(TerminalStatus::Completed, true).unwrap(),
10035            "suspended"
10036        );
10037        for raw in [TerminalStatus::Error, TerminalStatus::Cancelled] {
10038            assert!(effective_broker_terminal_status(raw, true).is_err());
10039        }
10040        let runner = ActorChildRunner::new(
10041            "test".into(),
10042            PathBuf::new(),
10043            vec![],
10044            PathBuf::new(),
10045            ExecutorSpec::BambooRuntime,
10046            vec![],
10047            "test".into(),
10048            1,
10049        );
10050        let home = tempfile::tempdir().unwrap();
10051        let store = Arc::new(
10052            bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
10053                .await
10054                .unwrap(),
10055        );
10056        let root = Session::new("nested-ack-parent", "model");
10057        store.save_session(&root).await.unwrap();
10058        let mut child = Session::new_child_of("nested-ack-child", &root, "model", "task");
10059        child.add_message(bamboo_agent_core::Message::user("spawn a grandchild"));
10060        store.save_session(&child).await.unwrap();
10061        child.set_last_run_status("suspended");
10062        store
10063            .prepare_broker_terminal_receipt(
10064                &child,
10065                "run-1",
10066                "00000000-0000-4000-8000-000000000001",
10067                "p-nested-ack-child",
10068                "broker-run-1",
10069                &["broker-outcome-1".into()],
10070            )
10071            .await
10072            .unwrap();
10073        store.save_session(&child).await.unwrap();
10074        runner.set_actor_directory_store(Some(store.clone()));
10075        let calls = Arc::new(AtomicUsize::new(0));
10076
10077        insert_ack_probe(&runner, &child, &calls, TerminalStatus::Completed).await;
10078        runner
10079            .confirm_durable_child_delivery(&child, "run-1", true)
10080            .await
10081            .unwrap();
10082        assert_eq!(calls.load(Ordering::SeqCst), 0);
10083
10084        insert_ack_probe_with_nested_wait(&runner, &child, &calls, TerminalStatus::Completed, true)
10085            .await;
10086        runner
10087            .confirm_durable_child_delivery(&child, "run-1", true)
10088            .await
10089            .unwrap();
10090        assert_eq!(calls.load(Ordering::SeqCst), 1);
10091        assert_eq!(
10092            store
10093                .load_session(&child.id)
10094                .await
10095                .unwrap()
10096                .unwrap()
10097                .last_run_status()
10098                .as_deref(),
10099            Some("suspended")
10100        );
10101        assert!(store
10102            .recover_broker_terminal_receipts(&child)
10103            .await
10104            .unwrap()
10105            .is_empty());
10106    }
10107
10108    #[tokio::test]
10109    async fn host_completion_notifies_once_only_after_reply_and_finish_commit() {
10110        struct CommittedObserver {
10111            authority: PathBuf,
10112            events: std::sync::Mutex<Vec<PublicActorEvent>>,
10113        }
10114        impl ActorEventObserver for CommittedObserver {
10115            fn publish(&self, event: PublicActorEvent) {
10116                // Observe the real durable sidecar synchronously at publication.
10117                let entry: bamboo_domain::ActorDirectoryEntry =
10118                    serde_json::from_slice(&std::fs::read(&self.authority).unwrap()).unwrap();
10119                let committed = entry.activation.unwrap();
10120                assert_eq!(
10121                    committed.status,
10122                    bamboo_domain::ActorActivationStatus::Succeeded
10123                );
10124                assert_eq!(committed.activation_id, event.activation_id);
10125                assert!(committed.finished_at.is_some());
10126                self.events.lock().unwrap().push(event);
10127            }
10128        }
10129        struct CompletionLink(VecDeque<ChildFrame>);
10130        #[async_trait]
10131        impl bamboo_subagent::ChildLink for CompletionLink {
10132            async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
10133                Ok(())
10134            }
10135            async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
10136                Ok(self.0.pop_front())
10137            }
10138        }
10139        for cut in [
10140            "success",
10141            "no_terminal",
10142            "invalid_terminal",
10143            "stale",
10144            "storage_failure",
10145            "append_failure",
10146        ] {
10147            let temp = tempfile::tempdir().unwrap();
10148            let store = Arc::new(
10149                bamboo_storage::SessionStoreV2::new(temp.path().into())
10150                    .await
10151                    .unwrap(),
10152            );
10153            let root = Session::new("notify-root", "model");
10154            store.save_session(&root).await.unwrap();
10155            let mut child = Session::new_child_of("notify-child", &root, "model", "assignment");
10156            store.save_session(&child).await.unwrap();
10157            let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
10158                store.clone(),
10159                bamboo_domain::SessionInboxLimits::default(),
10160            ));
10161            let binding = actor_binding(
10162                store.clone(),
10163                inbox,
10164                Arc::new(bamboo_storage::LockedSessionStore::new(store.clone())),
10165            );
10166            let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
10167            bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
10168            let activation = PlainActorActivation::start(
10169                store.clone(),
10170                &child,
10171                &binding,
10172                Some("notify-run"),
10173                Some(policy),
10174                false,
10175                None,
10176            )
10177            .await
10178            .unwrap();
10179            let _owner = binding
10180                .router
10181                .register_run(&child.id, "notify-run")
10182                .await
10183                .unwrap();
10184            let batch = ActorEventBatch {
10185                logical_session: Some(LogicalSessionIdentity {
10186                    session_id: child.id.clone(),
10187                    parent_session_id: child.parent_session_id.clone(),
10188                    root_session_id: child.root_session_id.clone(),
10189                    creation: None,
10190                }),
10191                activation_id: Some("notify-run".into()),
10192                execution_epoch: 9,
10193                source_node_id: None,
10194                source_actor_id: Some("notify-worker".into()),
10195                first_seq: 1,
10196                last_seq: 2,
10197                qos: bamboo_subagent::ActorEventQos::Durable,
10198                events: vec![
10199                    serde_json::to_value(AgentEvent::Token {
10200                        content: "PRIVATE_PROVIDER_TOKEN".into(),
10201                    })
10202                    .unwrap(),
10203                    serde_json::to_value(AgentEvent::message_appended(
10204                        &child.id,
10205                        &bamboo_agent_core::Message::assistant("PRIVATE_WORKER_CACHE", None),
10206                    ))
10207                    .unwrap(),
10208                ],
10209            };
10210            let mut frames = VecDeque::from([ChildFrame::EventBatch { batch }]);
10211            if cut != "no_terminal" {
10212                let mut terminal = completed_actor_frame();
10213                if cut == "invalid_terminal" {
10214                    if let ChildFrame::Terminal { status, .. } = &mut terminal {
10215                        *status = TerminalStatus::Suspended;
10216                    }
10217                }
10218                frames.push_back(terminal);
10219            }
10220            let mut link = CompletionLink(frames);
10221            let (tx, mut rx) = mpsc::channel(8);
10222            let (_live, mut live_rx) = mpsc::unbounded_channel();
10223            let (_delivery, mut delivery_rx) = mpsc::unbounded_channel();
10224            let cancel = CancellationToken::new();
10225            let outcome = drive(ActorDriveContext {
10226                client: &mut link,
10227                parent_session_id: "notify-root",
10228                child_session_id: "notify-child",
10229                child_attempt: 0,
10230                approval_registry: None,
10231                approval_decider: None,
10232                approval_reviewer: None,
10233                escalation_bridge: None,
10234                event_tx: &tx,
10235                cancel_token: &cancel,
10236                live_rx: &mut live_rx,
10237                delivery_rx: &mut delivery_rx,
10238                logical_session: &mut child,
10239                expected_permission_posture: None,
10240                expected_creation: None,
10241                session_inbox_runtime: Some(&binding),
10242                actor_directory_store: Some(store.as_ref()),
10243                canonical_subagent_tool: None,
10244                ticket_service: None,
10245                activation_run_id: Some("notify-run"),
10246                execution_epoch: 9,
10247                expected_source_actor_id: "notify-worker",
10248                initial_inflight_claims: VecDeque::new(),
10249                plain_actor: true,
10250                remote_environment_lease: false,
10251                readonly_output: None,
10252                local_history_tools: None,
10253                local_history_read_only: false,
10254                strict_history_output: None,
10255                plain_input: Some(&activation),
10256                canonical_activation: None,
10257                canonical_placement_ref: None,
10258                actor_event_observer: None,
10259                plain_run: None,
10260                first_frame_timeout: Some(Duration::from_secs(1)),
10261            })
10262            .await;
10263            let admitted = !matches!(cut, "no_terminal" | "invalid_terminal");
10264            assert_eq!(outcome.is_ok(), admitted, "{cut}: {outcome:?}");
10265            assert_eq!(
10266                activation.completion_router.lock().unwrap().is_some(),
10267                admitted,
10268                "{cut}"
10269            );
10270            while rx.try_recv().is_ok() {}
10271            let authority = temp
10272                .path()
10273                .join(store.resolve_rel_path(&child.id).await.unwrap())
10274                .join("actor-authority.json");
10275            let observer = CommittedObserver {
10276                authority: authority.clone(),
10277                events: Default::default(),
10278            };
10279            if cut == "append_failure" {
10280                child.add_message(bamboo_agent_core::Message::user("uncommitted-prefix"));
10281                assert!(activation
10282                    .append_reply(&mut child, "PRIVATE_REPLY".into(), &tx)
10283                    .await
10284                    .is_err());
10285                activation
10286                    .finish(ActorActivationFinish::Failed)
10287                    .await
10288                    .unwrap();
10289                assert!(observer.events.lock().unwrap().is_empty());
10290                continue;
10291            }
10292            activation
10293                .append_reply(&mut child, "PRIVATE_REPLY".into(), &tx)
10294                .await
10295                .unwrap();
10296            assert!(
10297                rx.try_recv().is_ok(),
10298                "legacy content notification retained"
10299            );
10300            assert!(
10301                observer.events.lock().unwrap().is_empty(),
10302                "reply alone must not publish"
10303            );
10304            if cut == "stale" {
10305                store
10306                    .retire_actor(&child.id, chrono::Utc::now())
10307                    .await
10308                    .unwrap();
10309            } else if cut == "storage_failure" {
10310                // Make the canonical write path unreadable, rather than mocking
10311                // the store or allowing completion to fall back to ordinary save.
10312                std::fs::remove_file(&authority).unwrap();
10313                std::fs::create_dir(&authority).unwrap();
10314            }
10315            let result = activation
10316                .finish_with_observer(ActorActivationFinish::Succeeded, Some(&observer))
10317                .await;
10318            assert_eq!(
10319                result.is_ok(),
10320                matches!(cut, "success" | "no_terminal" | "invalid_terminal"),
10321                "{cut}"
10322            );
10323            let events = observer.events.lock().unwrap().clone();
10324            assert_eq!(events.len(), usize::from(cut == "success"), "{cut}");
10325            if let Some(event) = events.first() {
10326                assert_eq!(event.actor_id, child.id);
10327                assert_eq!(event.root_actor_id, root.id);
10328                assert_eq!(event.parent_actor_id, Some(root.id.clone()));
10329                assert_eq!(event.source_order.sequence, 3);
10330                let json = serde_json::to_string(event).unwrap();
10331                assert!(!json.contains("PRIVATE_") && !json.contains("notify-run"));
10332                let persisted = store.load_session(&child.id).await.unwrap().unwrap();
10333                assert_eq!(persisted.messages.last().unwrap().content, "PRIVATE_REPLY");
10334            }
10335            assert!(activation
10336                .finish_with_observer(ActorActivationFinish::Succeeded, Some(&observer))
10337                .await
10338                .is_err());
10339            assert_eq!(
10340                observer.events.lock().unwrap().len(),
10341                events.len(),
10342                "repeat finish cannot publish"
10343            );
10344        }
10345    }
10346
10347    async fn bind_local_control_plane(
10348        store: &bamboo_storage::SessionStoreV2,
10349        actor_id: &str,
10350        policy: &bamboo_tools::permission::PermissionConfig,
10351    ) {
10352        store
10353            .bind_control_plane(
10354                actor_id,
10355                bamboo_domain::ActorControlPlaneBinding {
10356                    policy_revision: Some(policy.policy_revision()),
10357                    placement_intent: Some(bamboo_domain::ActorPlacementIntent::Local),
10358                },
10359            )
10360            .await
10361            .unwrap();
10362    }
10363
10364    fn local_tool_sample(
10365        host: &Session,
10366        denied_write: bool,
10367    ) -> (LocalToolCollector, Vec<bamboo_agent_core::Message>) {
10368        local_tool_sample_with_reasoning(host, denied_write, None)
10369    }
10370
10371    fn local_tool_sample_with_reasoning(
10372        host: &Session,
10373        denied_write: bool,
10374        reasoning: Option<&str>,
10375    ) -> (LocalToolCollector, Vec<bamboo_agent_core::Message>) {
10376        use bamboo_agent_core::{FunctionCall, Message, ToolCall};
10377        let calls = ["Read", "Write"]
10378            .map(|name| ToolCall {
10379                id: format!("local-{name}"),
10380                tool_type: "function".into(),
10381                function: FunctionCall {
10382                    name: name.into(),
10383                    arguments: "{\"file_path\":\"answer.txt\"}".into(),
10384                },
10385            })
10386            .to_vec();
10387        let mut rows = vec![Message::assistant("checking", Some(calls))];
10388        rows[0].reasoning = reasoning.map(str::to_owned);
10389        let mut collector = LocalToolCollector::default();
10390        if let Some(reasoning) = reasoning {
10391            assert!(collector
10392                .event(
10393                    &serde_json::to_value(AgentEvent::ReasoningToken {
10394                        content: reasoning.into(),
10395                    })
10396                    .unwrap()
10397                )
10398                .unwrap());
10399        }
10400        for name in ["Read", "Write"] {
10401            let denied = name == "Write" && denied_write;
10402            let id = format!("local-{name}");
10403            if !denied {
10404                collector
10405                    .event(
10406                        &serde_json::to_value(AgentEvent::ToolStart {
10407                            tool_call_id: id.clone(),
10408                            tool_name: name.into(),
10409                            arguments: serde_json::json!({"file_path":"answer.txt"}),
10410                        })
10411                        .unwrap(),
10412                    )
10413                    .unwrap();
10414            }
10415            let text = if denied {
10416                "Error: native_tool_ceiling_denied: Write"
10417            } else {
10418                "actual file result"
10419            };
10420            let mut message = Message::tool_result_with_status(id.clone(), text, !denied);
10421            message.metadata = Some(serde_json::json!({"elapsed_ms":7,"tool_name":name,
10422                "is_mutating":name == "Write","auto_approved":name != "Write","success":!denied}));
10423            rows.push(message);
10424            let event = if denied {
10425                AgentEvent::ToolError {
10426                    tool_call_id: id,
10427                    error: "native_tool_ceiling_denied: Write".into(),
10428                }
10429            } else {
10430                AgentEvent::ToolComplete {
10431                    tool_call_id: id,
10432                    result: bamboo_agent_core::tools::ToolResult::text(true, text),
10433                }
10434            };
10435            collector
10436                .event(&serde_json::to_value(event).unwrap())
10437                .unwrap();
10438        }
10439        rows.push(Message::assistant("complete exact report", None));
10440        rows.last_mut().unwrap().reasoning = reasoning.map(str::to_owned);
10441        if let Some(reasoning) = reasoning {
10442            assert!(collector
10443                .event(
10444                    &serde_json::to_value(AgentEvent::ReasoningToken {
10445                        content: reasoning.into(),
10446                    })
10447                    .unwrap()
10448                )
10449                .unwrap());
10450        }
10451        let mut messages = host.messages.clone();
10452        if let Some(system) = messages
10453            .iter_mut()
10454            .find(|message| message.role == Role::System)
10455        {
10456            system.content = "worker-private system never imported".into();
10457        }
10458        messages.extend(rows.iter().cloned());
10459        let data = bamboo_subagent::proto::LocalToolMessages::Complete {
10460            version: 1,
10461            messages: messages
10462                .iter()
10463                .map(|message| serde_json::to_value(message).unwrap())
10464                .collect(),
10465        };
10466        assert!(!collector
10467            .event(&serde_json::to_value(data).unwrap())
10468            .unwrap());
10469        (collector, rows)
10470    }
10471
10472    #[test]
10473    fn ticket_task_event_requires_exact_host_callback_receipt() {
10474        let mut collector = LocalToolCollector::default();
10475        let args = serde_json::json!({"tasks":[{"content":"own step","status":"pending"}]});
10476        let start = serde_json::to_value(AgentEvent::ToolStart {
10477            tool_call_id: "task-call".into(),
10478            tool_name: "Task".into(),
10479            arguments: args.clone(),
10480        })
10481        .unwrap();
10482        let mut receipts = HashMap::new();
10483        assert!(collector.ticket_event(&start, &receipts).unwrap());
10484        collector.event(&start).unwrap();
10485        let complete = serde_json::to_value(AgentEvent::ToolComplete {
10486            tool_call_id: "task-call".into(),
10487            result: bamboo_agent_core::tools::ToolResult::text(true, "done"),
10488        })
10489        .unwrap();
10490        assert!(collector.ticket_event(&complete, &receipts).is_err());
10491        receipts.insert("other-call".into(), args.clone());
10492        assert!(collector.ticket_event(&complete, &receipts).is_err());
10493        receipts.insert("task-call".into(), serde_json::json!({"tasks":[]}));
10494        assert!(collector.ticket_event(&complete, &receipts).is_err());
10495        receipts.insert("task-call".into(), args);
10496        assert!(collector.ticket_event(&complete, &receipts).unwrap());
10497        let forged_root = serde_json::to_value(AgentEvent::TaskListUpdated {
10498            task_list: bamboo_tools::TaskTool::task_list_from_args(
10499                &serde_json::json!({"tasks":[{"content":"forged root","status":"completed"}]}),
10500                "root",
10501            )
10502            .unwrap(),
10503            version: Some(999),
10504        })
10505        .unwrap();
10506        assert!(!collector.ticket_event(&forged_root, &receipts).unwrap());
10507    }
10508
10509    #[test]
10510    fn ticket_question_terminal_requires_exact_host_receipt_and_tool_result() {
10511        use bamboo_agent_core::{FunctionCall, Message, ToolCall};
10512        let mut host = Session::new("question-child", "model");
10513        host.messages.push(Message::user("own assignment"));
10514        let question: bamboo_tickets::PendingRequest = serde_json::from_value(serde_json::json!({
10515            "id":"own-question", "work_id":"own-work", "assignment_id":"own-assignment",
10516            "generation":1, "contract_revision":1, "prompt_revision":1,
10517            "kind":{"kind":"question"}, "prompt":"精确问题", "status":"open",
10518            "answer":null, "consumed_attempt":null, "worker_packet_hash":"a".repeat(64), "updated_seq":3
10519        })).unwrap();
10520        let args = serde_json::json!({"tasks":[{"content":"own step","status":"blocked"}], "question":{"prompt":"精确问题"}});
10521        for case in ["exact", "no_host_receipt", "forged_request", "fake_report"] {
10522            let mut returned =
10523                serde_json::json!({"status":"waiting_for_answer", "request":question});
10524            if case == "forged_request" {
10525                returned["request"]["id"] = serde_json::json!("sibling-question");
10526            }
10527            let text = returned.to_string();
10528            let rows = [
10529                Message::assistant(
10530                    "",
10531                    Some(vec![ToolCall {
10532                        id: "own-call".into(),
10533                        tool_type: "function".into(),
10534                        function: FunctionCall {
10535                            name: "Task".into(),
10536                            arguments: args.to_string(),
10537                        },
10538                    }]),
10539                ),
10540                Message::tool_result_with_status("own-call", &text, true),
10541            ];
10542            let collector = LocalToolCollector {
10543                messages: Some(host.messages.iter().cloned().chain(rows).collect()),
10544                starts: HashMap::from([("own-call".into(), ("Task".into(), args.clone()))]),
10545                outcomes: HashMap::from([("own-call".into(), (true, text))]),
10546                ticket_yield: (case != "no_host_receipt")
10547                    .then(|| ("own-call".into(), question.clone())),
10548            };
10549            let terminal = if case == "fake_report" {
10550                Some("fake delivery")
10551            } else {
10552                Some("")
10553            };
10554            assert_eq!(
10555                collector
10556                    .suffix(&host, &["Task".into()], false, terminal)
10557                    .is_ok(),
10558                case == "exact",
10559                "{case}"
10560            );
10561        }
10562    }
10563
10564    #[test]
10565    fn local_tool_suffix_preserves_full_current_prefix_and_rejects_authority_or_collisions() {
10566        let mut host = Session::new("tool-history-prefix", "model");
10567        host.messages
10568            .push(bamboo_agent_core::Message::user("assignment"));
10569        let mut confirmed = bamboo_agent_core::Message::user("confirmed during Run");
10570        confirmed.metadata = Some(
10571            serde_json::json!({"session_message":{"id":confirmed.id,"target_session_id":host.id}}),
10572        );
10573        host.messages.push(confirmed);
10574        let tools = vec!["Read".into(), "Write".into()];
10575        let (good, rows) = local_tool_sample(&host, false);
10576        assert_eq!(
10577            serde_json::to_value(
10578                good.suffix(&host, &tools, false, Some("complete exact report"))
10579                    .unwrap()
10580            )
10581            .unwrap(),
10582            serde_json::to_value(&rows).unwrap()
10583        );
10584        for case in [
10585            "missing",
10586            "changed",
10587            "metadata",
10588            "system",
10589            "user",
10590            "collision",
10591            "dangling",
10592            "result",
10593            "terminal",
10594            "tool_metadata",
10595            "grant",
10596            "start_arguments",
10597            "ledger",
10598        ] {
10599            let (mut collector, _) = local_tool_sample(&host, false);
10600            let mut current = host.clone();
10601            let messages = collector.messages.as_mut().unwrap();
10602            match case {
10603                "missing" => {
10604                    messages.remove(host.messages.len() - 1);
10605                }
10606                "changed" => messages[0].content.push('!'),
10607                "metadata" => messages[1].metadata = None,
10608                "system" => messages.last_mut().unwrap().role = Role::System,
10609                "user" => messages.last_mut().unwrap().role = Role::User,
10610                "collision" => messages.last_mut().unwrap().id = host.messages[0].id.clone(),
10611                "dangling" => {
10612                    messages[host.messages.len() + 1].tool_call_id = Some("foreign".into())
10613                }
10614                "result" => messages[host.messages.len() + 1].content.push('!'),
10615                "tool_metadata" => {
10616                    messages[host.messages.len() + 1].metadata.as_mut().unwrap()["permission"] =
10617                        true.into()
10618                }
10619                "grant" => {
10620                    messages[host.messages.len()].tool_calls.as_mut().unwrap()[0]
10621                        .function
10622                        .name = "Bash".into()
10623                }
10624                "start_arguments" => {
10625                    collector.starts.get_mut("local-Read").unwrap().1 =
10626                        serde_json::json!({"file_path":"different.txt"})
10627                }
10628                "ledger" => {
10629                    current.append_provider_transcript_group(current.messages[0].id.clone(), None, vec![
10630                        bamboo_domain::session::provider_transcript::ProviderTranscriptItem::try_from_payload(
10631                            bamboo_domain::ProviderFamily::OpenAi,
10632                            bamboo_domain::ProviderProtocol::OpenAiResponsesV1,
10633                            bamboo_domain::session::provider_transcript::ProviderTranscriptOrigin::HostToolSearch,
10634                            bamboo_domain::session::provider_transcript::ProviderTranscriptAuthor::ToolResult,
10635                            serde_json::json!({"type":"tool_search_output","execution":"client","call_id":"search","status":"completed","tools":[]}),
10636                        ).unwrap(),
10637                    ]).unwrap();
10638                }
10639                "terminal" => {}
10640                _ => unreachable!(),
10641            }
10642            let terminal = if case == "terminal" {
10643                "different"
10644            } else {
10645                "complete exact report"
10646            };
10647            assert!(
10648                collector
10649                    .suffix(&current, &tools, false, Some(terminal))
10650                    .is_err(),
10651                "{case}"
10652            );
10653        }
10654        let (denied, _) = local_tool_sample(&host, true);
10655        assert!(denied
10656            .suffix(&host, &["Read".into()], true, Some("complete exact report"))
10657            .is_ok());
10658        let (mut contradicted, _) = local_tool_sample(&host, true);
10659        contradicted.starts.insert(
10660            "local-Write".into(),
10661            ("Read".into(), serde_json::json!({"file_path":"answer.txt"})),
10662        );
10663        assert!(contradicted
10664            .suffix(&host, &["Read".into()], true, Some("complete exact report"))
10665            .is_err());
10666        let (successful, _) = local_tool_sample(&host, false);
10667        assert!(successful
10668            .suffix(&host, &["Read".into()], true, Some("complete exact report"))
10669            .is_err());
10670        let mut empty = LocalToolCollector::default();
10671        assert!(empty
10672            .event(
10673                &serde_json::to_value(AgentEvent::ReasoningToken {
10674                    content: "private".into()
10675                })
10676                .unwrap()
10677            )
10678            .unwrap());
10679        assert!(
10680            empty
10681                .suffix(&host, &tools, false, Some("complete exact report"))
10682                .is_err(),
10683            "stream observations alone cannot commit history"
10684        );
10685        let (mut completed, _) = local_tool_sample(&host, false);
10686        assert!(completed.event(&serde_json::json!({"type":"local_client_tool_messages_v1","version":1,"messages":[]})).is_err());
10687    }
10688
10689    struct LocalToolConcurrentCheckpoint {
10690        real: SessionInboxRuntimeBinding,
10691        claim: SessionInboxClaim,
10692    }
10693    #[async_trait]
10694    impl RuntimeSessionPersistence for LocalToolConcurrentCheckpoint {
10695        async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
10696            self.real.persistence.save_runtime_session(session).await
10697        }
10698        async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
10699            // A real canonical admission wins after validation, before the
10700            // final append-safe checkpoint acquires its serialization lock.
10701            let mut latest = self.real.storage.load_session(&session.id).await?.unwrap();
10702            checkpoint_and_ack_canonical_claim(&self.real, &mut latest, &self.claim, None)
10703                .await
10704                .map_err(|_| std::io::Error::other("concurrent admission failed"))?;
10705            self.real
10706                .persistence
10707                .checkpoint_runtime_session(session)
10708                .await
10709        }
10710        async fn load_runtime_session(&self, id: &str) -> std::io::Result<Option<Session>> {
10711            self.real.storage.load_session(id).await
10712        }
10713    }
10714
10715    #[tokio::test]
10716    async fn local_tool_history_checkpoint_preserves_confirmed_input_and_cold_pairs_or_refuses() {
10717        for case in [
10718            "success",
10719            "reasoning",
10720            "concurrent_input",
10721            "checkpoint_error",
10722            "stale_prefix",
10723            "cancelled",
10724            "lost_owner",
10725        ] {
10726            let temp = tempfile::tempdir().unwrap();
10727            let store = Arc::new(
10728                bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
10729                    .await
10730                    .unwrap(),
10731            );
10732            let root = Session::new("local-tool-root", "model");
10733            store.save_session(&root).await.unwrap();
10734            let mut child = Session::new_child("local-tool-child", &root.id, "model", "child");
10735            let mut system = bamboo_agent_core::Message::user("Host-owned immutable System");
10736            system.role = Role::System;
10737            child.messages.push(system);
10738            child
10739                .messages
10740                .push(bamboo_agent_core::Message::user("assignment"));
10741            store.save_session(&child).await.unwrap();
10742            let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
10743                store.clone(),
10744                bamboo_domain::SessionInboxLimits::default(),
10745            ));
10746            let locked = Arc::new(bamboo_storage::LockedSessionStore::new(store.clone()));
10747            let binding = actor_binding(store.clone(), inbox.clone(), locked.clone());
10748            let mut owner = Some(
10749                binding
10750                    .router
10751                    .register_run(&child.id, "current-run")
10752                    .await
10753                    .unwrap(),
10754            );
10755            let envelope = bamboo_domain::SessionMessageEnvelope::user_input(
10756                &child.id,
10757                "current confirmed User",
10758            );
10759            let receipt = inbox.deliver(&envelope).await.unwrap();
10760            inbox
10761                .mark_activation_eligible(
10762                    &child.id,
10763                    receipt.generation,
10764                    bamboo_domain::SessionActivationPolicy::RespectSpecificWait,
10765                )
10766                .await
10767                .unwrap();
10768            let claim = inbox.claim(&child.id, 1).await.unwrap().remove(0);
10769            checkpoint_and_ack_canonical_claim(&binding, &mut child, &claim, None)
10770                .await
10771                .unwrap();
10772            assert!(inbox.was_admitted(&child.id, &envelope.id).await.unwrap());
10773            let before = serde_json::to_value(&child).unwrap();
10774            let reasoning = (case == "reasoning")
10775                .then_some("Complete plain reasoning before Read and the final reply 🪷.");
10776            let (collector, rows) = local_tool_sample_with_reasoning(&child, false, reasoning);
10777            let mut binding = binding;
10778            let late = bamboo_domain::SessionMessageEnvelope::user_input(
10779                &child.id,
10780                "concurrent confirmed User",
10781            );
10782            if case == "concurrent_input" {
10783                let receipt = inbox.deliver(&late).await.unwrap();
10784                inbox
10785                    .mark_activation_eligible(
10786                        &child.id,
10787                        receipt.generation,
10788                        bamboo_domain::SessionActivationPolicy::RespectSpecificWait,
10789                    )
10790                    .await
10791                    .unwrap();
10792                let late_claim = inbox.claim(&child.id, 1).await.unwrap().remove(0);
10793                binding.persistence = Arc::new(LocalToolConcurrentCheckpoint {
10794                    real: actor_binding(store.clone(), inbox.clone(), locked.clone()),
10795                    claim: late_claim,
10796                });
10797            }
10798            if case == "checkpoint_error" {
10799                binding.persistence = Arc::new(ActorFaultingPersistence {
10800                    inner: locked.clone(),
10801                    fail_checkpoint_once: AtomicBool::new(true),
10802                });
10803            }
10804            if case == "stale_prefix" {
10805                let mut latest = child.clone();
10806                latest
10807                    .messages
10808                    .push(bamboo_agent_core::Message::user("new confirmed prefix"));
10809                store.save_session(&latest).await.unwrap();
10810            }
10811            let cancel = CancellationToken::new();
10812            if case == "cancelled" {
10813                cancel.cancel();
10814            }
10815            if case == "lost_owner" {
10816                owner.take().unwrap().abandon().await;
10817            }
10818            let durable_before =
10819                serde_json::to_value(store.load_session(&child.id).await.unwrap()).unwrap();
10820            let result = commit_local_tool_history(
10821                collector,
10822                &mut child,
10823                &binding,
10824                "current-run",
10825                (&["Read".into(), "Write".into()], false),
10826                Some("complete exact report"),
10827                &cancel,
10828            )
10829            .await;
10830            let cold = bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
10831                .await
10832                .unwrap()
10833                .load_session(&child.id)
10834                .await
10835                .unwrap()
10836                .unwrap();
10837            if !matches!(case, "success" | "reasoning" | "concurrent_input") {
10838                assert!(result.is_err(), "{case}");
10839                assert_eq!(
10840                    serde_json::to_value(&child).unwrap(),
10841                    before,
10842                    "{case}: no partial mutable success"
10843                );
10844                assert_eq!(
10845                    serde_json::to_value(Some(&cold)).unwrap(),
10846                    durable_before,
10847                    "{case}: no write"
10848                );
10849                continue;
10850            }
10851            result.unwrap();
10852            assert_eq!(
10853                serde_json::to_value(&cold.messages[..3]).unwrap(),
10854                before["messages"]
10855            );
10856            let suffix_start = if case == "concurrent_input" {
10857                assert!(inbox.was_admitted(&child.id, &late.id).await.unwrap());
10858                assert_eq!(
10859                    serde_json::to_value(&cold.messages[3]).unwrap(),
10860                    serde_json::to_value(late.to_provider_message().unwrap()).unwrap()
10861                );
10862                assert!(child
10863                    .messages
10864                    .iter()
10865                    .any(|message| message.id == late.id.as_str()));
10866                4
10867            } else {
10868                3
10869            };
10870            assert_eq!(
10871                serde_json::to_value(&cold.messages[suffix_start..]).unwrap(),
10872                serde_json::to_value(rows).unwrap()
10873            );
10874            assert_eq!(
10875                cold.messages
10876                    .iter()
10877                    .filter(|message| message.id == envelope.id.as_str())
10878                    .count(),
10879                1
10880            );
10881            assert_eq!(
10882                cold.messages
10883                    .iter()
10884                    .filter(|message| message.content == "complete exact report")
10885                    .count(),
10886                1
10887            );
10888            assert_eq!(cold.metadata, child.metadata);
10889            assert!(cold.provider_transcript.is_empty());
10890        }
10891    }
10892
10893    #[tokio::test]
10894    async fn local_tool_completion_requires_current_ordered_batch_and_never_falls_back_to_text() {
10895        for case in [
10896            "legacy",
10897            "missing",
10898            "gap",
10899            "duplicate",
10900            "epoch",
10901            "source",
10902            "birth",
10903            "qos",
10904            "ticket_forged_success",
10905        ] {
10906            let mut session = Session::new_child("frame-child", "frame-root", "model", "child");
10907            session
10908                .messages
10909                .push(bamboo_agent_core::Message::user("original"));
10910            if case == "ticket_forged_success" {
10911                session.metadata.insert(
10912                    crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY.into(),
10913                    "assignment".into(),
10914                );
10915            }
10916            let before = serde_json::to_value(&session).unwrap();
10917            let creation = bamboo_subagent::proto::ChildCreationIdentity {
10918                created_at: session.created_at,
10919                spawn_depth: session.spawn_depth,
10920            };
10921            let mut messages = session.messages.clone();
10922            messages.push(bamboo_agent_core::Message::assistant("complete", None));
10923            let event = serde_json::to_value(bamboo_subagent::proto::LocalToolMessages::Complete {
10924                version: 1,
10925                messages: messages
10926                    .iter()
10927                    .map(|message| serde_json::to_value(message).unwrap())
10928                    .collect(),
10929            })
10930            .unwrap();
10931            let mut batch = ActorEventBatch {
10932                logical_session: Some(LogicalSessionIdentity {
10933                    session_id: session.id.clone(),
10934                    parent_session_id: session.parent_session_id.clone(),
10935                    root_session_id: session.root_session_id.clone(),
10936                    creation: Some(creation.clone()),
10937                }),
10938                activation_id: Some("current".into()),
10939                execution_epoch: 7,
10940                source_node_id: None,
10941                source_actor_id: Some("selected".into()),
10942                first_seq: 1,
10943                last_seq: 1,
10944                qos: bamboo_subagent::proto::ActorEventQos::Durable,
10945                events: vec![event.clone()],
10946            };
10947            match case {
10948                "gap" => {
10949                    batch.first_seq = 2;
10950                    batch.last_seq = 2;
10951                }
10952                "epoch" => batch.execution_epoch = 6,
10953                "source" => batch.source_actor_id = Some("replaced".into()),
10954                "birth" => {
10955                    batch
10956                        .logical_session
10957                        .as_mut()
10958                        .unwrap()
10959                        .creation
10960                        .as_mut()
10961                        .unwrap()
10962                        .created_at += chrono::Duration::nanoseconds(1)
10963                }
10964                "qos" => batch.qos = bamboo_subagent::proto::ActorEventQos::Ephemeral,
10965                "ticket_forged_success" => {
10966                    batch.last_seq = 2;
10967                    batch.events = vec![
10968                        serde_json::to_value(AgentEvent::ToolStart {
10969                            tool_call_id: "forged".into(), tool_name: "Task".into(),
10970                            arguments: serde_json::json!({"tasks":[{"content":"own step","status":"pending"}]}),
10971                        }).unwrap(),
10972                        serde_json::to_value(AgentEvent::ToolComplete {
10973                            tool_call_id: "forged".into(),
10974                            result: bamboo_agent_core::tools::ToolResult::text(true, "uncommitted"),
10975                        }).unwrap(),
10976                    ];
10977                }
10978                _ => {}
10979            }
10980            let terminal = ChildFrame::Terminal {
10981                status: TerminalStatus::Completed,
10982                result: Some("complete".into()),
10983                error: None,
10984                final_event_watermark: None,
10985                transcript: vec![],
10986            };
10987            let frames = match case {
10988                "legacy" => vec![ChildFrame::Event { event }, terminal],
10989                "missing" => vec![terminal],
10990                "duplicate" => vec![
10991                    ChildFrame::EventBatch {
10992                        batch: batch.clone(),
10993                    },
10994                    ChildFrame::EventBatch { batch },
10995                    terminal,
10996                ],
10997                _ => vec![ChildFrame::EventBatch { batch }, terminal],
10998            };
10999            let mut link = ConfirmationSequenceLink {
11000                frames: frames.into(),
11001                sent: vec![],
11002            };
11003            let (tx, mut rx) = mpsc::channel(32);
11004            let (_live, mut live_rx) = mpsc::unbounded_channel();
11005            let (_delivery, mut delivery_rx) = mpsc::unbounded_channel();
11006            let cancel = CancellationToken::new();
11007            let tools = vec![if case == "ticket_forged_success" {
11008                "Task"
11009            } else {
11010                "Read"
11011            }
11012            .into()];
11013            let outcome = drive(ActorDriveContext {
11014                client: &mut link,
11015                parent_session_id: "frame-root",
11016                child_session_id: "frame-child",
11017                child_attempt: 0,
11018                approval_registry: None,
11019                approval_decider: None,
11020                approval_reviewer: None,
11021                escalation_bridge: None,
11022                event_tx: &tx,
11023                cancel_token: &cancel,
11024                live_rx: &mut live_rx,
11025                delivery_rx: &mut delivery_rx,
11026                logical_session: &mut session,
11027                expected_permission_posture: None,
11028                expected_creation: Some(&creation),
11029                session_inbox_runtime: None,
11030                actor_directory_store: None,
11031                canonical_subagent_tool: None,
11032                ticket_service: None,
11033                activation_run_id: Some("current"),
11034                execution_epoch: 7,
11035                expected_source_actor_id: "selected",
11036                initial_inflight_claims: vec![].into(),
11037                plain_actor: false,
11038                remote_environment_lease: false,
11039                readonly_output: None,
11040                local_history_tools: Some(&tools),
11041                local_history_read_only: true,
11042                strict_history_output: None,
11043                plain_input: None,
11044                canonical_activation: None,
11045                canonical_placement_ref: None,
11046                actor_event_observer: None,
11047                plain_run: None,
11048                first_frame_timeout: Some(Duration::from_secs(1)),
11049            })
11050            .await;
11051            assert!(outcome.is_err(), "{case}: no last-text success fallback");
11052            if case == "ticket_forged_success" {
11053                assert!(outcome
11054                    .unwrap_err()
11055                    .to_string()
11056                    .contains("no exact Host receipt"));
11057                assert!(matches!(
11058                    rx.try_recv().unwrap(),
11059                    AgentEvent::ToolStart { .. }
11060                ));
11061            }
11062            assert_eq!(serde_json::to_value(&session).unwrap(), before, "{case}");
11063            assert!(link.sent.is_empty());
11064            assert!(rx.try_recv().is_err(), "DATA must never enter public feed");
11065        }
11066    }
11067
11068    struct HistoryTerminalProbe {
11069        frames: VecDeque<ChildFrame>,
11070        accepted: Option<TerminalStatus>,
11071        acks: usize,
11072    }
11073
11074    #[async_trait]
11075    impl bamboo_subagent::ChildLink for HistoryTerminalProbe {
11076        async fn send(&mut self, _frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
11077            Ok(())
11078        }
11079        async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
11080            Ok(self.frames.pop_front())
11081        }
11082        fn accept_durable_terminal(&mut self, status: TerminalStatus) {
11083            self.accepted = Some(status);
11084        }
11085        fn durable_delivery_receipt(&self) -> Option<bamboo_subagent::DurableChildDeliveryReceipt> {
11086            Some(bamboo_subagent::DurableChildDeliveryReceipt {
11087                broker_identity: "fixture".into(),
11088                parent_mailbox: "parent".into(),
11089                correlation_id: "current".into(),
11090                message_ids: vec!["tail".into(), "terminal".into()],
11091                terminal_status: self.accepted?,
11092            })
11093        }
11094        async fn acknowledge_durable_frames(&mut self) -> bamboo_subagent::TransportResult<()> {
11095            self.acks += 1;
11096            Ok(())
11097        }
11098    }
11099
11100    #[tokio::test]
11101    async fn strict_terminal_watermark_gates_history_commit_and_durable_acceptance() {
11102        use bamboo_subagent::ChildLink;
11103        for case in [
11104            "success",
11105            "tail",
11106            "short",
11107            "missing",
11108            "version",
11109            "activation",
11110            "epoch",
11111            "birth",
11112            "duplicate",
11113            "overlap",
11114            "reorder",
11115            "midgap",
11116            "qos",
11117            "zero_missing",
11118            "zero_valid",
11119            "error_tail",
11120            "cancelled_tail",
11121            "cancelled_valid",
11122            "cancelled_zero_valid",
11123            "error_valid",
11124            "error_zero_valid",
11125            "ordinary_lossy",
11126        ] {
11127            let temp = tempfile::tempdir().unwrap();
11128            let store = Arc::new(
11129                bamboo_storage::SessionStoreV2::new(temp.path().into())
11130                    .await
11131                    .unwrap(),
11132            );
11133            let root = Session::new("watermark-root", "model");
11134            store.save_session(&root).await.unwrap();
11135            let mut child = Session::new_child("watermark-child", &root.id, "model", "child");
11136            child
11137                .messages
11138                .push(bamboo_agent_core::Message::user("assignment"));
11139            store.save_session(&child).await.unwrap();
11140            let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
11141                store.clone(),
11142                bamboo_domain::SessionInboxLimits::default(),
11143            ));
11144            let locked = Arc::new(bamboo_storage::LockedSessionStore::new(store.clone()));
11145            let binding = actor_binding(store.clone(), inbox, locked);
11146            let _owner = binding
11147                .router
11148                .register_run(&child.id, "current")
11149                .await
11150                .unwrap();
11151            let before = serde_json::to_value(&child).unwrap();
11152            let creation = bamboo_subagent::proto::ChildCreationIdentity {
11153                created_at: child.created_at,
11154                spawn_depth: child.spawn_depth,
11155            };
11156            let identity = actor_event_logical_identity(&child, &root.id, Some(&creation));
11157            let mut rows = child.messages.clone();
11158            rows.push(bamboo_agent_core::Message::assistant("complete", None));
11159            let data = serde_json::to_value(bamboo_subagent::proto::LocalToolMessages::Complete {
11160                version: 1,
11161                messages: rows
11162                    .iter()
11163                    .map(|row| serde_json::to_value(row).unwrap())
11164                    .collect(),
11165            })
11166            .unwrap();
11167            let batch = |seq, event, qos| ActorEventBatch {
11168                logical_session: Some(identity.clone()),
11169                activation_id: Some("current".into()),
11170                execution_epoch: 7,
11171                source_node_id: None,
11172                source_actor_id: Some("selected".into()),
11173                first_seq: seq,
11174                last_seq: seq,
11175                qos,
11176                events: vec![event],
11177            };
11178            let durable = bamboo_subagent::ActorEventQos::Durable;
11179            let token = serde_json::json!({"type":"token", "content":"observation"});
11180            let mut frames = vec![ChildFrame::EventBatch {
11181                batch: batch(1, data.clone(), durable),
11182            }];
11183            let mut marker = bamboo_subagent::ActorEventWatermark {
11184                version: 1,
11185                logical_session: Some(identity.clone()),
11186                activation_id: Some("current".into()),
11187                execution_epoch: 7,
11188                final_seq: 1,
11189            };
11190            match case {
11191                "tail" | "error_tail" | "cancelled_tail" => marker.final_seq = 2,
11192                "short" => marker.final_seq = 0,
11193                "version" => marker.version = 99,
11194                "activation" => marker.activation_id = Some("old".into()),
11195                "epoch" => marker.execution_epoch = 6,
11196                "birth" => {
11197                    marker
11198                        .logical_session
11199                        .as_mut()
11200                        .unwrap()
11201                        .creation
11202                        .as_mut()
11203                        .unwrap()
11204                        .created_at += chrono::Duration::nanoseconds(1)
11205                }
11206                "duplicate" => frames.push(frames[0].clone()),
11207                "overlap" | "midgap" | "qos" => {
11208                    frames = vec![
11209                        ChildFrame::EventBatch {
11210                            batch: batch(
11211                                1,
11212                                token.clone(),
11213                                if case == "qos" {
11214                                    bamboo_subagent::ActorEventQos::Ephemeral
11215                                } else {
11216                                    durable
11217                                },
11218                            ),
11219                        },
11220                        ChildFrame::EventBatch {
11221                            batch: batch(
11222                                if case == "overlap" {
11223                                    1
11224                                } else if case == "midgap" {
11225                                    3
11226                                } else {
11227                                    2
11228                                },
11229                                data,
11230                                durable,
11231                            ),
11232                        },
11233                    ];
11234                    marker.final_seq = 2;
11235                }
11236                "reorder" => {
11237                    frames = vec![
11238                        ChildFrame::EventBatch {
11239                            batch: batch(2, data, durable),
11240                        },
11241                        ChildFrame::EventBatch {
11242                            batch: batch(1, token, durable),
11243                        },
11244                    ]
11245                }
11246                "zero_missing" | "zero_valid" | "error_zero_valid" | "cancelled_zero_valid" => {
11247                    frames.clear();
11248                    marker.final_seq = 0;
11249                }
11250                "ordinary_lossy" => {
11251                    frames = vec![ChildFrame::EventBatch {
11252                        batch: batch(3, token, bamboo_subagent::ActorEventQos::Ephemeral),
11253                    }]
11254                }
11255                _ => {}
11256            }
11257            let status = match case {
11258                "error_tail" | "error_valid" | "error_zero_valid" => TerminalStatus::Error,
11259                "cancelled_tail" | "cancelled_valid" | "cancelled_zero_valid" => {
11260                    TerminalStatus::Cancelled
11261                }
11262                _ => TerminalStatus::Completed,
11263            };
11264            frames.push(ChildFrame::Terminal {
11265                status,
11266                result: Some("complete".into()),
11267                error: Some("worker failed".into()),
11268                transcript: vec![],
11269                final_event_watermark: (!matches!(
11270                    case,
11271                    "missing" | "zero_missing" | "ordinary_lossy"
11272                ))
11273                .then_some(marker),
11274            });
11275            let mut link = HistoryTerminalProbe {
11276                frames: frames.into(),
11277                accepted: None,
11278                acks: 0,
11279            };
11280            let (tx, mut rx) = mpsc::channel(32);
11281            let (_live, mut live_rx) = mpsc::unbounded_channel();
11282            let (_delivery, mut delivery_rx) = mpsc::unbounded_channel();
11283            let cancel = CancellationToken::new();
11284            let tools = vec!["Read".into()];
11285            let mut strict_history = None;
11286            let outcome = drive(ActorDriveContext {
11287                client: &mut link,
11288                parent_session_id: &root.id,
11289                child_session_id: "watermark-child",
11290                child_attempt: 0,
11291                approval_registry: None,
11292                approval_decider: None,
11293                approval_reviewer: None,
11294                escalation_bridge: None,
11295                event_tx: &tx,
11296                cancel_token: &cancel,
11297                live_rx: &mut live_rx,
11298                delivery_rx: &mut delivery_rx,
11299                logical_session: &mut child,
11300                expected_permission_posture: None,
11301                expected_creation: Some(&creation),
11302                session_inbox_runtime: Some(&binding),
11303                actor_directory_store: None,
11304                canonical_subagent_tool: None,
11305                ticket_service: None,
11306                activation_run_id: Some("current"),
11307                execution_epoch: 7,
11308                expected_source_actor_id: "selected",
11309                initial_inflight_claims: vec![].into(),
11310                plain_actor: false,
11311                remote_environment_lease: false,
11312                readonly_output: None,
11313                local_history_tools: (case != "ordinary_lossy").then_some(&tools),
11314                local_history_read_only: true,
11315                strict_history_output: Some(&mut strict_history),
11316                plain_input: None,
11317                canonical_activation: None,
11318                canonical_placement_ref: None,
11319                actor_event_observer: None,
11320                plain_run: None,
11321                first_frame_timeout: Some(Duration::from_secs(1)),
11322            })
11323            .await;
11324            assert_eq!(strict_history.is_some(), case == "success", "{case}");
11325            if let Some(history) = strict_history {
11326                assert_eq!(history.proof.execution_epoch(), 7);
11327                assert_eq!(history.proof.contiguous_applied_seq(), 1);
11328                assert_eq!(history.watermark.final_seq, 1);
11329            }
11330            let cold = store.load_session(&child.id).await.unwrap().unwrap();
11331            if matches!(case, "success" | "ordinary_lossy") {
11332                assert_eq!(outcome.unwrap().as_deref(), Some("complete"), "{case}");
11333            } else {
11334                let error = outcome.unwrap_err();
11335                if matches!(case, "cancelled_valid" | "cancelled_zero_valid") {
11336                    assert!(matches!(&error, AgentError::Cancelled), "{case}: {error}");
11337                }
11338                let error = error.to_string();
11339                if !matches!(
11340                    case,
11341                    "duplicate"
11342                        | "overlap"
11343                        | "reorder"
11344                        | "midgap"
11345                        | "qos"
11346                        | "zero_valid"
11347                        | "error_valid"
11348                        | "error_zero_valid"
11349                        | "cancelled_valid"
11350                        | "cancelled_zero_valid"
11351                ) {
11352                    assert!(
11353                        error.contains("actor_history_tail_incomplete"),
11354                        "{case}: {error}"
11355                    );
11356                }
11357            }
11358            if case == "success" {
11359                assert_eq!(
11360                    serde_json::to_value(&cold.messages).unwrap(),
11361                    serde_json::to_value(rows).unwrap()
11362                );
11363            } else {
11364                assert_eq!(
11365                    serde_json::to_value(&cold).unwrap(),
11366                    before,
11367                    "{case}: no cold history commit"
11368                );
11369                assert_eq!(
11370                    serde_json::to_value(&child).unwrap(),
11371                    before,
11372                    "{case}: no mutable history commit"
11373                );
11374            }
11375            let accepted = matches!(case, "success" | "ordinary_lossy");
11376            assert_eq!(link.accepted.is_some(), accepted, "{case}");
11377            assert_eq!(
11378                link.durable_delivery_receipt().is_some(),
11379                accepted,
11380                "{case}"
11381            );
11382            assert_eq!(link.acks, 0, "frame pump never ACKs uncheckpointed history");
11383            while let Ok(event) = rx.try_recv() {
11384                assert!(
11385                    matches!(event, AgentEvent::Token { .. }),
11386                    "typed DATA cannot leak"
11387                );
11388            }
11389        }
11390    }
11391
11392    fn processless_pool_worker(mailbox_id: &str) -> PooledWorker {
11393        PooledWorker {
11394            worker: SpawnedChild::remote(AgentRecord {
11395                agent_id: mailbox_id.to_string(),
11396                role: "test".to_string(),
11397                labels: Vec::new(),
11398                endpoint: "ws://127.0.0.1:1".to_string(),
11399                pid: 0,
11400                version: String::new(),
11401                started_at: chrono::Utc::now(),
11402                lease_expires_at: chrono::Utc::now(),
11403            }),
11404            mailbox_id: mailbox_id.to_string(),
11405            parked_at: None,
11406        }
11407    }
11408
11409    #[test]
11410    fn parked_worker_deadline_is_inclusive_and_ignores_checked_out_workers() {
11411        let now = Instant::now();
11412        let timeout = Duration::from_secs(300);
11413        assert!(!parked_worker_expired(None, now, timeout));
11414        assert!(!parked_worker_expired(
11415            Some(now - timeout + Duration::from_millis(1)),
11416            now,
11417            timeout
11418        ));
11419        assert!(parked_worker_expired(Some(now - timeout), now, timeout));
11420    }
11421
11422    #[test]
11423    fn idle_pool_global_cap_bounds_distinct_fingerprints() {
11424        let mut pool = WorkerPool::new();
11425        pool.insert(
11426            "fingerprint-a".to_string(),
11427            vec![processless_pool_worker("a")],
11428        );
11429        pool.insert(
11430            "fingerprint-b".to_string(),
11431            vec![processless_pool_worker("b")],
11432        );
11433
11434        assert_eq!(
11435            idle_pool_limit_reason(&pool, "fingerprint-c", 4, 2),
11436            Some("pool_global_limit")
11437        );
11438        assert_eq!(idle_pool_limit_reason(&pool, "fingerprint-c", 4, 3), None);
11439        assert_eq!(
11440            idle_pool_limit_reason(&pool, "fingerprint-a", 1, 8),
11441            Some("pool_per_key_limit")
11442        );
11443    }
11444
11445    #[test]
11446    fn pool_sweep_removes_dead_workers_and_empty_fingerprint_buckets() {
11447        let mut pool = WorkerPool::new();
11448        pool.insert(
11449            "fingerprint-a".to_string(),
11450            vec![processless_pool_worker("dead")],
11451        );
11452
11453        let reaped = take_reapable_workers(
11454            &mut pool,
11455            Instant::now(),
11456            Duration::from_secs(POOLED_IDLE_TIMEOUT_SECS),
11457        );
11458
11459        assert!(pool.is_empty());
11460        assert_eq!(reaped.len(), 1);
11461        assert_eq!(reaped[0].1, PoolReapReason::ProcessExited);
11462    }
11463
11464    #[test]
11465    fn actor_preflight_counts_only_current_session_scoped_denies() {
11466        let config = bamboo_tools::permission::PermissionConfig::new();
11467        let secret_matcher = "TOP_SECRET_ACTOR_DENY_MATCHER";
11468        config.deny_scoped_session_permission(
11469            "target-session",
11470            bamboo_tools::permission::PermissionType::ExecuteCommand,
11471            secret_matcher,
11472        );
11473        config.deny_scoped_session_permission(
11474            "other-session",
11475            bamboo_tools::permission::PermissionType::WriteFile,
11476            "/other/**",
11477        );
11478
11479        assert_eq!(
11480            active_scoped_session_deny_count(&config, "target-session"),
11481            1
11482        );
11483        assert_eq!(
11484            active_scoped_session_deny_count(&config, "clean-session"),
11485            0
11486        );
11487        let error = ensure_no_active_scoped_session_denies(&config, "target-session")
11488            .unwrap_err()
11489            .to_string();
11490        assert!(!error.contains(secret_matcher));
11491        ensure_no_active_scoped_session_denies(&config, "clean-session")
11492            .expect("another session's deny must not block this activation");
11493    }
11494
11495    #[test]
11496    fn permission_posture_event_rejects_oversized_executor_mapping() {
11497        let session = Session::new("mapping-bound", "model");
11498        let event = AgentEvent::PermissionPostureActivated {
11499            session_id: session.id.clone(),
11500            policy_revision: 1,
11501            requested_mode: "default".to_string(),
11502            effective_mode: "default".to_string(),
11503            executor_mapping: "x".repeat(bamboo_domain::MAX_PERMISSION_EXECUTOR_MAPPING_CHARS + 1),
11504        };
11505
11506        assert!(permission_posture_seed_from_event(&session, &event)
11507            .unwrap_err()
11508            .contains("executor mapping"));
11509    }
11510
11511    #[test]
11512    fn remote_audit_revision_and_timestamp_fields_cannot_poison_host_audit() {
11513        let mut session = Session::new("host-resigns-audit", "model");
11514        let hostile_timestamp = "9".repeat(1024);
11515        let event: AgentEvent = serde_json::from_value(serde_json::json!({
11516            "type": "permission_posture_activated",
11517            "session_id": session.id.clone(),
11518            "policy_revision": 31,
11519            "requested_mode": "default",
11520            "effective_mode": "default",
11521            "executor_mapping": "codex_exec:approval_policy=never",
11522            "audit_revision": u64::MAX,
11523            "transitioned_at": hostile_timestamp,
11524        }))
11525        .expect("unknown remote audit fields are ignored by the typed event");
11526        let seed = permission_posture_seed_from_event(&session, &event)
11527            .unwrap()
11528            .expect("permission event");
11529
11530        let host_revision =
11531            bamboo_domain::record_permission_audit(&mut session.metadata, &seed, None).unwrap();
11532        let host_audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
11533            .expect("host-generated complete audit");
11534        assert_eq!(host_audit.audit_revision, host_revision);
11535        assert!(host_audit.audit_revision < bamboo_domain::MAX_PERMISSION_AUDIT_REVISION);
11536        assert_ne!(host_audit.transitioned_at, hostile_timestamp);
11537        assert!(chrono::DateTime::parse_from_rfc3339(&host_audit.transitioned_at).is_ok());
11538    }
11539
11540    struct ActorFaultingPersistence {
11541        inner: Arc<bamboo_storage::LockedSessionStore>,
11542        fail_checkpoint_once: std::sync::atomic::AtomicBool,
11543    }
11544
11545    #[async_trait]
11546    impl RuntimeSessionPersistence for ActorFaultingPersistence {
11547        async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
11548            self.inner.merge_save_runtime(session).await
11549        }
11550
11551        async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
11552            if self
11553                .fail_checkpoint_once
11554                .swap(false, std::sync::atomic::Ordering::SeqCst)
11555            {
11556                return Err(std::io::Error::other("injected actor checkpoint failure"));
11557            }
11558            self.inner.checkpoint_runtime_session(session).await
11559        }
11560
11561        async fn load_runtime_session(&self, session_id: &str) -> std::io::Result<Option<Session>> {
11562            self.inner.storage().load_session(session_id).await
11563        }
11564    }
11565
11566    struct ActorFailBeforeAckInbox {
11567        inner: Arc<dyn SessionInboxPort>,
11568        fail_once: std::sync::atomic::AtomicBool,
11569    }
11570
11571    #[async_trait]
11572    impl SessionInboxPort for ActorFailBeforeAckInbox {
11573        async fn deliver(
11574            &self,
11575            envelope: &bamboo_domain::SessionMessageEnvelope,
11576        ) -> Result<bamboo_domain::SessionInboxReceipt, bamboo_domain::SessionInboxError> {
11577            self.inner.deliver(envelope).await
11578        }
11579
11580        async fn mark_activation_eligible(
11581            &self,
11582            target_session_id: &str,
11583            generation: u64,
11584            policy: bamboo_domain::SessionActivationPolicy,
11585        ) -> Result<(), bamboo_domain::SessionInboxError> {
11586            self.inner
11587                .mark_activation_eligible(target_session_id, generation, policy)
11588                .await
11589        }
11590
11591        async fn claim(
11592            &self,
11593            target_session_id: &str,
11594            limit: usize,
11595        ) -> Result<Vec<SessionInboxClaim>, bamboo_domain::SessionInboxError> {
11596            self.inner.claim(target_session_id, limit).await
11597        }
11598
11599        async fn was_admitted(
11600            &self,
11601            target_session_id: &str,
11602            id: &bamboo_domain::SessionMessageId,
11603        ) -> Result<bool, bamboo_domain::SessionInboxError> {
11604            self.inner.was_admitted(target_session_id, id).await
11605        }
11606
11607        async fn ack(
11608            &self,
11609            target_session_id: &str,
11610            claim: &SessionInboxClaim,
11611        ) -> Result<(), bamboo_domain::SessionInboxError> {
11612            if self
11613                .fail_once
11614                .swap(false, std::sync::atomic::Ordering::SeqCst)
11615            {
11616                return Err(bamboo_domain::SessionInboxError::Storage(
11617                    "injected actor pre-ack failure".to_string(),
11618                ));
11619            }
11620            self.inner.ack(target_session_id, claim).await
11621        }
11622
11623        async fn inspect(
11624            &self,
11625            target_session_id: &str,
11626        ) -> Result<bamboo_domain::SessionInboxBacklog, bamboo_domain::SessionInboxError> {
11627            self.inner.inspect(target_session_id).await
11628        }
11629    }
11630
11631    async fn actor_inbox_fixture(
11632        session_id: &str,
11633    ) -> (
11634        tempfile::TempDir,
11635        Arc<bamboo_storage::SessionStoreV2>,
11636        Arc<bamboo_storage::LockedSessionStore>,
11637        Arc<dyn SessionInboxPort>,
11638        Session,
11639        SessionInboxClaim,
11640    ) {
11641        let temp = tempfile::tempdir().unwrap();
11642        let store = Arc::new(
11643            bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
11644                .await
11645                .unwrap(),
11646        );
11647        let storage: Arc<dyn Storage> = store.clone();
11648        let locked = Arc::new(bamboo_storage::LockedSessionStore::new(storage));
11649        let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
11650            store.clone(),
11651            bamboo_domain::SessionInboxLimits::default(),
11652        ));
11653        let session = Session::new(session_id, "model");
11654        store.save_session(&session).await.unwrap();
11655        let mut envelope =
11656            bamboo_domain::SessionMessageEnvelope::user_input(session_id, "actor follow-up");
11657        envelope.id =
11658            bamboo_domain::SessionMessageId::parse(format!("{session_id}-message")).unwrap();
11659        let receipt = inbox.deliver(&envelope).await.unwrap();
11660        inbox
11661            .mark_activation_eligible(
11662                session_id,
11663                receipt.generation,
11664                bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
11665            )
11666            .await
11667            .unwrap();
11668        let claim = inbox.claim(session_id, 1).await.unwrap().remove(0);
11669        (temp, store, locked, inbox, session, claim)
11670    }
11671
11672    fn actor_binding(
11673        store: Arc<bamboo_storage::SessionStoreV2>,
11674        inbox: Arc<dyn SessionInboxPort>,
11675        persistence: Arc<dyn RuntimeSessionPersistence>,
11676    ) -> SessionInboxRuntimeBinding {
11677        let storage: Arc<dyn Storage> = store;
11678        SessionInboxRuntimeBinding {
11679            router: SessionActivationRouter::new(),
11680            inbox,
11681            storage,
11682            persistence,
11683            parent_question_lock: None,
11684        }
11685    }
11686
11687    #[tokio::test]
11688    async fn remote_claim_seed_rejects_unsupported_inbox_before_dispatch() {
11689        let (_temp, store, locked, inbox, mut session, claim) =
11690            actor_inbox_fixture("unsupported-remote-inbox").await;
11691        let wrapped: Arc<dyn SessionInboxPort> = Arc::new(ActorFailBeforeAckInbox {
11692            inner: inbox.clone(),
11693            fail_once: std::sync::atomic::AtomicBool::new(false),
11694        });
11695        let binding = actor_binding(store.clone(), wrapped, locked);
11696        let fence = ActorActivationFence {
11697            schema_version: 1,
11698            actor_id: session.id.clone(),
11699            activation_id: "unsupported-inbox-activation".into(),
11700            attempt: 1,
11701            run_id: "unsupported-inbox-run".into(),
11702            lease_owner: "unsupported-inbox-host".into(),
11703            lease_epoch: 1,
11704        };
11705        let before = session.clone();
11706
11707        let error = checkpoint_claim_context_before_dispatch(
11708            &binding,
11709            &mut session,
11710            &[claim],
11711            Some((store.as_ref(), &fence)),
11712        )
11713        .await
11714        .unwrap_err();
11715        assert!(error
11716            .to_string()
11717            .contains("requires canonical FileSessionInbox"));
11718        assert_eq!(
11719            serde_json::to_value(&session.messages).unwrap(),
11720            serde_json::to_value(&before.messages).unwrap()
11721        );
11722        assert_eq!(
11723            serde_json::to_value(
11724                &store
11725                    .load_session(&session.id)
11726                    .await
11727                    .unwrap()
11728                    .unwrap()
11729                    .messages
11730            )
11731            .unwrap(),
11732            serde_json::to_value(&before.messages).unwrap()
11733        );
11734        assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
11735    }
11736
11737    #[tokio::test]
11738    async fn remote_broker_lineage_rejects_missing_parent_and_project_drift() {
11739        let (_temp, store, locked, inbox, parent, _claim) =
11740            actor_inbox_fixture("remote-parent").await;
11741        let child = Session::new_child_of("remote-child", &parent, "model", "child");
11742        store.save_session(&child).await.unwrap();
11743        let binding = actor_binding(store.clone(), inbox, locked);
11744        let first = remote_canonical_lineage(&binding, &child).await.unwrap();
11745        assert_eq!(first.len(), 2);
11746        let mut changed = child.clone();
11747        changed.set_project_id_meta("different-project");
11748        assert!(remote_canonical_lineage(&binding, &changed).await.is_err());
11749        store.delete_session(&parent.id).await.unwrap();
11750        assert!(remote_canonical_lineage(&binding, &child).await.is_err());
11751    }
11752
11753    #[tokio::test]
11754    async fn child_creation_requires_canonical_host_lifetime_not_cached_candidate() {
11755        let (_temp, store, locked, inbox, parent, _claim) =
11756            actor_inbox_fixture("creation-parent").await;
11757        let mut child = Session::new_child_of("creation-child", &parent, "model", "child");
11758        child.set_project_id_meta("creation-project");
11759        store.save_session(&child).await.unwrap();
11760        let binding = actor_binding(store.clone(), inbox, locked);
11761        let job = SpawnJob {
11762            parent_session_id: parent.id.clone(),
11763            child_session_id: child.id.clone(),
11764            model: "model".into(),
11765            disabled_tools: None,
11766        };
11767        let creation = canonical_child_creation(&binding, &child, &job)
11768            .await
11769            .unwrap();
11770        assert_eq!(creation.created_at, child.created_at);
11771        assert_eq!(creation.spawn_depth, child.spawn_depth);
11772        for damage in 0..7 {
11773            let mut held = child.clone();
11774            match damage {
11775                0 => held.created_at += chrono::Duration::nanoseconds(1),
11776                1 => held.parent_session_id = Some("foreign-parent".into()),
11777                2 => held.root_session_id = "foreign-root".into(),
11778                3 => held.spawn_depth += 1,
11779                4 => held.set_project_id_meta("foreign-project"),
11780                5 => held.kind = bamboo_domain::SessionKind::Root,
11781                _ => held.id = " creation-child ".into(),
11782            }
11783            assert!(
11784                canonical_child_creation(&binding, &held, &job)
11785                    .await
11786                    .is_err(),
11787                "damage {damage}"
11788            );
11789        }
11790        store.delete_session(&child.id).await.unwrap();
11791        assert!(canonical_child_creation(&binding, &child, &job)
11792            .await
11793            .is_err());
11794        let mut replacement = Session::new_child_of(&child.id, &parent, "model", "replacement");
11795        replacement.set_project_id_meta("creation-project");
11796        assert_ne!(replacement.created_at, child.created_at);
11797        store.save_session(&replacement).await.unwrap();
11798        assert!(
11799            canonical_child_creation(&binding, &child, &job)
11800                .await
11801                .is_err(),
11802            "old held spec cannot adopt replacement birth"
11803        );
11804        canonical_child_creation(&binding, &replacement, &job)
11805            .await
11806            .unwrap();
11807    }
11808
11809    #[tokio::test]
11810    async fn actor_immediate_claims_keep_exact_policy_across_mixed_order_ack_and_restart() {
11811        use bamboo_domain::{SessionActivationPolicy, SessionInboxLimits, SessionMessageEnvelope};
11812        for policies in [
11813            [
11814                SessionActivationPolicy::RespectSpecificWait,
11815                SessionActivationPolicy::InterruptSpecificWait,
11816            ],
11817            [
11818                SessionActivationPolicy::InterruptSpecificWait,
11819                SessionActivationPolicy::RespectSpecificWait,
11820            ],
11821        ] {
11822            let temp = tempfile::tempdir().unwrap();
11823            let store = Arc::new(
11824                bamboo_storage::SessionStoreV2::new(temp.path().into())
11825                    .await
11826                    .unwrap(),
11827            );
11828            let session_id = "actor-mixed-intent-policy";
11829            store
11830                .save_session(&Session::new(session_id, "model"))
11831                .await
11832                .unwrap();
11833            let inbox = bamboo_storage::FileSessionInbox::new(store, SessionInboxLimits::default());
11834            let staged = SessionMessageEnvelope::user_input(session_id, "staged sibling");
11835            let staged_receipt = inbox.deliver(&staged).await.unwrap();
11836            let mut sent = Vec::new();
11837            for policy in policies {
11838                let envelope = SessionMessageEnvelope::user_input(session_id, "immediate");
11839                let receipt = inbox
11840                    .deliver_with_activation_intent(&envelope, policy, None)
11841                    .await
11842                    .unwrap();
11843                sent.push((envelope, policy, receipt));
11844            }
11845            let restarted = Arc::new(
11846                bamboo_storage::SessionStoreV2::new(temp.path().into())
11847                    .await
11848                    .unwrap(),
11849            );
11850            let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
11851                restarted.clone(),
11852                SessionInboxLimits::default(),
11853            ));
11854            let binding = actor_binding(
11855                restarted.clone(),
11856                inbox.clone(),
11857                Arc::new(bamboo_storage::LockedSessionStore::new(restarted.clone())),
11858            );
11859            let mut session = restarted.load_session(session_id).await.unwrap().unwrap();
11860            let pairs =
11861                claim_canonical_deliveries(&binding, &mut session, "run-1", usize::MAX, None)
11862                    .await
11863                    .unwrap();
11864            assert_eq!(pairs.len(), 2, "the staged sibling has no permission");
11865            for ((claim, delivery), (envelope, policy, receipt)) in pairs.iter().zip(&sent) {
11866                assert_eq!(claim.envelope.id, envelope.id);
11867                assert_eq!(claim.generation, receipt.generation);
11868                assert_eq!(claim.activation_policy, *policy);
11869                assert_eq!(delivery.activation_policy, *policy);
11870                checkpoint_and_ack_canonical_claim(&binding, &mut session, claim, None)
11871                    .await
11872                    .unwrap();
11873            }
11874            let restarted = Arc::new(
11875                bamboo_storage::SessionStoreV2::new(temp.path().into())
11876                    .await
11877                    .unwrap(),
11878            );
11879            let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
11880                restarted.clone(),
11881                SessionInboxLimits::default(),
11882            ));
11883            let binding = actor_binding(
11884                restarted.clone(),
11885                inbox.clone(),
11886                Arc::new(bamboo_storage::LockedSessionStore::new(restarted.clone())),
11887            );
11888            session = restarted.load_session(session_id).await.unwrap().unwrap();
11889            for (envelope, policy, receipt) in &sent {
11890                assert_eq!(
11891                    inbox
11892                        .deliver_with_activation_intent(envelope, *policy, None)
11893                        .await
11894                        .unwrap(),
11895                    *receipt
11896                );
11897            }
11898            assert!(
11899                claim_canonical_deliveries(&binding, &mut session, "run-2", usize::MAX, None)
11900                    .await
11901                    .unwrap()
11902                    .is_empty()
11903            );
11904            inbox
11905                .mark_activation_eligible(
11906                    session_id,
11907                    staged_receipt.generation,
11908                    SessionActivationPolicy::RespectSpecificWait,
11909                )
11910                .await
11911                .unwrap();
11912            let pairs =
11913                claim_canonical_deliveries(&binding, &mut session, "run-2", usize::MAX, None)
11914                    .await
11915                    .unwrap();
11916            assert_eq!(pairs.len(), 1);
11917            assert_eq!(pairs[0].0.envelope.id, staged.id);
11918            assert_eq!(
11919                pairs[0].1.activation_policy,
11920                SessionActivationPolicy::RespectSpecificWait
11921            );
11922        }
11923    }
11924
11925    #[tokio::test]
11926    async fn actor_mismatched_typed_marker_id_collision_never_acks() {
11927        let (_temp, store, locked, inbox, mut session, claim) =
11928            actor_inbox_fixture("actor-live-id-collision").await;
11929        let mut forged = claim.envelope.to_provider_message().unwrap();
11930        forged.metadata = Some(serde_json::json!({
11931            "session_message": {
11932                "id": claim.envelope.id,
11933                "target_session_id": "different-session"
11934            }
11935        }));
11936        session.add_message(forged);
11937        let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
11938        let binding = actor_binding(store, inbox.clone(), persistence);
11939
11940        assert!(
11941            checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
11942                .await
11943                .is_err()
11944        );
11945        assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
11946        assert!(!inbox
11947            .was_admitted(&session.id, &claim.envelope.id)
11948            .await
11949            .unwrap());
11950    }
11951
11952    #[tokio::test]
11953    async fn actor_concurrent_durable_id_collision_after_claim_never_acks() {
11954        let (_temp, store, locked, inbox, mut session, claim) =
11955            actor_inbox_fixture("actor-durable-id-collision").await;
11956        let mut concurrent = store.load_session(&session.id).await.unwrap().unwrap();
11957        let mut forged = bamboo_agent_core::Message::user("concurrent actor collision");
11958        forged.id = claim.envelope.id.to_string();
11959        concurrent.add_message(forged);
11960        store.save_session(&concurrent).await.unwrap();
11961        let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
11962        let binding = actor_binding(store.clone(), inbox.clone(), persistence);
11963
11964        assert!(
11965            checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
11966                .await
11967                .is_err()
11968        );
11969        assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
11970        assert!(!inbox
11971            .was_admitted(&session.id, &claim.envelope.id)
11972            .await
11973            .unwrap());
11974        let durable = store.load_session(&session.id).await.unwrap().unwrap();
11975        assert!(!durable.messages.iter().any(|message| {
11976            bamboo_domain::is_matching_session_message(message, &claim.envelope)
11977        }));
11978    }
11979
11980    #[tokio::test]
11981    async fn actor_concurrent_durable_typed_body_mismatch_never_acks() {
11982        let (_temp, store, locked, inbox, mut session, claim) =
11983            actor_inbox_fixture("actor-durable-typed-body-collision").await;
11984        let mut different = claim.envelope.clone();
11985        different.body = bamboo_domain::SessionMessageBody::Content(
11986            bamboo_domain::SessionMessageContent::text("forged actor body"),
11987        );
11988        let mut concurrent = store.load_session(&session.id).await.unwrap().unwrap();
11989        concurrent.add_message(different.to_provider_message().unwrap());
11990        store.save_session(&concurrent).await.unwrap();
11991        let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
11992        let binding = actor_binding(store.clone(), inbox.clone(), persistence);
11993
11994        assert!(
11995            checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
11996                .await
11997                .is_err()
11998        );
11999        assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12000        assert!(!inbox
12001            .was_admitted(&session.id, &claim.envelope.id)
12002            .await
12003            .unwrap());
12004        let durable = store.load_session(&session.id).await.unwrap().unwrap();
12005        assert!(!durable
12006            .messages
12007            .iter()
12008            .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope)));
12009    }
12010
12011    #[tokio::test]
12012    async fn actor_checkpoint_failure_rolls_back_and_restart_admits_once() {
12013        let (_temp, store, locked, inbox, mut session, claim) =
12014            actor_inbox_fixture("actor-checkpoint-failure").await;
12015        let envelope_id = claim.envelope.id.clone();
12016        let fault: Arc<dyn RuntimeSessionPersistence> = Arc::new(ActorFaultingPersistence {
12017            inner: locked.clone(),
12018            fail_checkpoint_once: std::sync::atomic::AtomicBool::new(true),
12019        });
12020        let binding = actor_binding(store.clone(), inbox.clone(), fault);
12021
12022        assert!(
12023            checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12024                .await
12025                .is_err()
12026        );
12027        assert!(!session
12028            .messages
12029            .iter()
12030            .any(|message| message.id == envelope_id.as_str()));
12031        assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12032        assert!(!inbox.was_admitted(&session.id, &envelope_id).await.unwrap());
12033
12034        let reopened: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12035            store.clone(),
12036            bamboo_domain::SessionInboxLimits::default(),
12037        ));
12038        let recovered = reopened.claim(&session.id, 1).await.unwrap().remove(0);
12039        let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12040        let binding = actor_binding(store.clone(), reopened.clone(), persistence);
12041        let mut restarted = store.load_session(&session.id).await.unwrap().unwrap();
12042        checkpoint_and_ack_canonical_claim(&binding, &mut restarted, &recovered, None)
12043            .await
12044            .unwrap();
12045        assert_eq!(
12046            restarted
12047                .messages
12048                .iter()
12049                .filter(|message| message.id == envelope_id.as_str())
12050                .count(),
12051            1
12052        );
12053        assert!(reopened
12054            .was_admitted(&session.id, &envelope_id)
12055            .await
12056            .unwrap());
12057        let backlog = reopened.inspect(&session.id).await.unwrap();
12058        assert_eq!(backlog.pending + backlog.claimed, 0);
12059    }
12060
12061    #[tokio::test]
12062    async fn actor_checkpoint_success_pre_ack_failure_recovers_without_duplicate() {
12063        let (_temp, store, locked, real_inbox, mut session, claim) =
12064            actor_inbox_fixture("actor-pre-ack-failure").await;
12065        let envelope_id = claim.envelope.id.clone();
12066        let faulted: Arc<dyn SessionInboxPort> = Arc::new(ActorFailBeforeAckInbox {
12067            inner: real_inbox.clone(),
12068            fail_once: std::sync::atomic::AtomicBool::new(true),
12069        });
12070        let persistence: Arc<dyn RuntimeSessionPersistence> = locked.clone();
12071        let binding = actor_binding(store.clone(), faulted, persistence);
12072
12073        assert!(
12074            checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12075                .await
12076                .is_err()
12077        );
12078        let durable = store.load_session(&session.id).await.unwrap().unwrap();
12079        assert_eq!(
12080            durable
12081                .messages
12082                .iter()
12083                .filter(|message| message.id == envelope_id.as_str())
12084                .count(),
12085            1
12086        );
12087        assert_eq!(real_inbox.inspect(&session.id).await.unwrap().claimed, 1);
12088        assert!(!real_inbox
12089            .was_admitted(&session.id, &envelope_id)
12090            .await
12091            .unwrap());
12092
12093        let reopened: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12094            store.clone(),
12095            bamboo_domain::SessionInboxLimits::default(),
12096        ));
12097        let recovered = reopened.claim(&session.id, 1).await.unwrap().remove(0);
12098        let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12099        let binding = actor_binding(store.clone(), reopened.clone(), persistence);
12100        let mut restarted = durable;
12101        checkpoint_and_ack_canonical_claim(&binding, &mut restarted, &recovered, None)
12102            .await
12103            .unwrap();
12104        assert_eq!(
12105            restarted
12106                .messages
12107                .iter()
12108                .filter(|message| message.id == envelope_id.as_str())
12109                .count(),
12110            1
12111        );
12112        assert!(reopened
12113            .was_admitted(&session.id, &envelope_id)
12114            .await
12115            .unwrap());
12116        let backlog = reopened.inspect(&session.id).await.unwrap();
12117        assert_eq!(backlog.pending + backlog.claimed, 0);
12118    }
12119
12120    struct ConfirmationSequenceLink {
12121        frames: VecDeque<ChildFrame>,
12122        sent: Vec<ParentFrame>,
12123    }
12124
12125    #[async_trait]
12126    impl bamboo_subagent::ChildLink for ConfirmationSequenceLink {
12127        async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
12128            self.sent.push(frame);
12129            Ok(())
12130        }
12131
12132        async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
12133            match self.frames.pop_front() {
12134                Some(frame) => Ok(Some(frame)),
12135                None => std::future::pending().await,
12136            }
12137        }
12138    }
12139
12140    #[tokio::test]
12141    async fn failed_actor_retry_checkpoints_new_input_and_rejects_old_or_stale_state() {
12142        for case in [
12143            "accepted",
12144            "readonly-refusal",
12145            "prefix-refusal",
12146            "old-claim",
12147            "old-checkpoint",
12148            "succeeded",
12149            "changed-birth",
12150            "oversize",
12151        ] {
12152            let temp = tempfile::tempdir().unwrap();
12153            let store = Arc::new(
12154                bamboo_storage::SessionStoreV2::new(temp.path().into())
12155                    .await
12156                    .unwrap(),
12157            );
12158            let parent = Session::new("retry-parent", "model");
12159            store.save_session(&parent).await.unwrap();
12160            let mut child =
12161                Session::new_child_of("retry-child", &parent, "model", "original assignment");
12162            child.add_message(bamboo_agent_core::Message::user("original assignment"));
12163            store.save_session(&child).await.unwrap();
12164            let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
12165            bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
12166            let locked = Arc::new(bamboo_storage::LockedSessionStore::new(store.clone()));
12167            let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12168                store.clone(),
12169                bamboo_domain::SessionInboxLimits::default(),
12170            ));
12171            let binding = actor_binding(store.clone(), inbox, locked);
12172            let registration = binding
12173                .router
12174                .register_run(&child.id, "first-run")
12175                .await
12176                .unwrap();
12177            let first = PlainActorActivation::start(
12178                store.clone(),
12179                &child,
12180                &binding,
12181                Some("first-run"),
12182                Some(policy.clone()),
12183                true,
12184                None,
12185            )
12186            .await
12187            .unwrap();
12188            if matches!(case, "old-claim" | "old-checkpoint") {
12189                let old = bamboo_domain::SessionMessageEnvelope::user_input(
12190                    &child.id,
12191                    "unconfirmed old input",
12192                );
12193                let receipt = first.input_inbox.deliver(&old).await.unwrap();
12194                first
12195                    .input_inbox
12196                    .mark_activation_eligible(
12197                        &child.id,
12198                        receipt.generation,
12199                        bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
12200                    )
12201                    .await
12202                    .unwrap();
12203                let claim = first
12204                    .claim_input(&binding, &child, "first-run")
12205                    .await
12206                    .unwrap()
12207                    .unwrap();
12208                if case == "old-checkpoint" {
12209                    first
12210                        .checkpoint_input(&mut child, "first-run", &claim)
12211                        .await
12212                        .unwrap();
12213                }
12214            }
12215            first
12216                .finish(if case == "succeeded" {
12217                    ActorActivationFinish::Succeeded
12218                } else {
12219                    ActorActivationFinish::Failed
12220                })
12221                .await
12222                .unwrap();
12223            drop(registration);
12224            let envelope = bamboo_domain::SessionMessageEnvelope::user_input(
12225                &child.id,
12226                if case == "oversize" {
12227                    "x".repeat(8193)
12228                } else {
12229                    "NEW_CORRECTION".into()
12230                },
12231            );
12232            let receipt = first.input_inbox.deliver(&envelope).await.unwrap();
12233            first
12234                .input_inbox
12235                .mark_activation_eligible(
12236                    &child.id,
12237                    receipt.generation,
12238                    bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
12239                )
12240                .await
12241                .unwrap();
12242            let before = store.load_session(&child.id).await.unwrap().unwrap();
12243            let expected_messages = serde_json::to_value(&before.messages).unwrap();
12244            child = before.clone();
12245            if case == "prefix-refusal" {
12246                child.messages.last_mut().unwrap().content = "stale cached prefix".into();
12247            }
12248            if case == "changed-birth" {
12249                child.created_at += chrono::Duration::milliseconds(1);
12250            }
12251            let _retry_registration = binding
12252                .router
12253                .register_run(&child.id, "retry-run")
12254                .await
12255                .unwrap();
12256            let result = PlainActorActivation::start_and_prepare(
12257                store.clone(),
12258                &mut child,
12259                &binding,
12260                Some("retry-run"),
12261                Some(policy.clone()),
12262                case != "readonly-refusal",
12263                None,
12264            )
12265            .await;
12266            if case != "accepted" {
12267                assert!(result.is_err(), "{case}: no retry Run may be dispatched");
12268                let actual = store.load_session(&before.id).await.unwrap().unwrap();
12269                assert_eq!(
12270                    serde_json::to_value(&actual.messages).unwrap(),
12271                    expected_messages,
12272                    "{case}: failed preparation preserves Main history"
12273                );
12274                assert!(
12275                    !first
12276                        .input_inbox
12277                        .was_admitted(&before.id, &envelope.id)
12278                        .await
12279                        .unwrap(),
12280                    "{case}: no ACK"
12281                );
12282                if case == "readonly-refusal" {
12283                    let entry = store.inspect_actor(&before.id).await.unwrap();
12284                    assert_eq!(entry.actor.current_attempt, 1);
12285                    assert_eq!(
12286                        entry.activation.unwrap().activation_id,
12287                        first.fence.activation_id
12288                    );
12289                    let backlog = first.input_inbox.inspect(&before.id).await.unwrap();
12290                    assert_eq!((backlog.pending, backlog.claimed), (1, 0));
12291                }
12292                if matches!(case, "prefix-refusal" | "changed-birth" | "oversize") {
12293                    assert_eq!(
12294                        first.input_inbox.inspect(&before.id).await.unwrap().claimed,
12295                        1
12296                    );
12297                    assert_eq!(
12298                        store
12299                            .inspect_actor(&before.id)
12300                            .await
12301                            .unwrap()
12302                            .activation
12303                            .unwrap()
12304                            .status,
12305                        bamboo_domain::ActorActivationStatus::Failed
12306                    );
12307                }
12308                if case == "oversize" {
12309                    let reopened = Arc::new(
12310                        bamboo_storage::SessionStoreV2::new(temp.path().into())
12311                            .await
12312                            .unwrap(),
12313                    );
12314                    let cold_inbox = bamboo_storage::FileSessionInbox::new(
12315                        reopened,
12316                        bamboo_domain::SessionInboxLimits::default(),
12317                    );
12318                    let now = chrono::Utc::now();
12319                    let leases = cold_inbox
12320                        .inspect_owned_leases(&before.id, 2, now)
12321                        .await
12322                        .unwrap();
12323                    assert_eq!(leases.len(), 1);
12324                    assert_eq!(leases[0].failure_count, 1);
12325                    assert_eq!(
12326                        leases[0].last_error_code.as_deref(),
12327                        Some("unsupported_actor_input")
12328                    );
12329                    let retry_after = leases[0].retry_after.unwrap();
12330                    assert!(retry_after > now);
12331                    let readiness = cold_inbox
12332                        .inspect_wake_readiness(&before.id, now)
12333                        .await
12334                        .unwrap();
12335                    assert!(
12336                        readiness.ready.is_none(),
12337                        "failed correction must not hot-loop"
12338                    );
12339                    assert_eq!(readiness.next_due_at, Some(retry_after));
12340                }
12341                continue;
12342            }
12343            let (retry, prepared) = result.unwrap();
12344            let (prefix, delivery) = prepared.unwrap();
12345            assert_eq!(
12346                prefix,
12347                serde_json::to_value(&before.messages)
12348                    .unwrap()
12349                    .as_array()
12350                    .unwrap()
12351                    .clone()
12352            );
12353            assert_eq!(delivery.envelope, envelope);
12354            assert_eq!(delivery.activation_run_id, "retry-run");
12355            assert_eq!(delivery.canonical_claim_generation, receipt.generation);
12356            assert!(store
12357                .validate_fence(&first.fence, chrono::Utc::now())
12358                .await
12359                .is_err());
12360            let running = store.inspect_actor(&child.id).await.unwrap();
12361            assert_eq!(running.actor.current_attempt, 2);
12362            assert_eq!(
12363                running.activation.as_ref().unwrap().status,
12364                bamboo_domain::ActorActivationStatus::Running
12365            );
12366            assert!(running.activation.unwrap().lease_epoch > first.fence.lease_epoch);
12367            let claim = retry.initial_input.as_ref().unwrap();
12368            assert!(child
12369                .session_inbox_admission()
12370                .unwrap()
12371                .contains(&envelope.id));
12372            assert!(
12373                !retry
12374                    .input_inbox
12375                    .was_admitted(&child.id, &envelope.id)
12376                    .await
12377                    .unwrap(),
12378                "checkpoint is not startup confirmation"
12379            );
12380            let mut confirmation = admission_confirmation(&child.id, &claim.claim, "retry-run");
12381            confirmation.activation_run_id = "first-run".into();
12382            assert!(retry
12383                .confirm_input(&binding, &child, "retry-run", claim, &confirmation)
12384                .await
12385                .is_err());
12386            assert!(!retry
12387                .input_inbox
12388                .was_admitted(&child.id, &envelope.id)
12389                .await
12390                .unwrap());
12391            confirmation.activation_run_id = "retry-run".into();
12392            retry
12393                .confirm_input(&binding, &child, "retry-run", claim, &confirmation)
12394                .await
12395                .unwrap();
12396            let (tx, mut rx) = mpsc::channel(1);
12397            retry
12398                .append_reply(&mut child, "RETRY_REPLY".into(), &tx)
12399                .await
12400                .unwrap();
12401            rx.recv().await.unwrap();
12402            retry
12403                .finish(ActorActivationFinish::Succeeded)
12404                .await
12405                .unwrap();
12406            let reopened = Arc::new(
12407                bamboo_storage::SessionStoreV2::new(temp.path().into())
12408                    .await
12409                    .unwrap(),
12410            );
12411            let cold = reopened.load_session(&child.id).await.unwrap().unwrap();
12412            assert_eq!(cold.created_at, before.created_at);
12413            assert_eq!(cold.messages.len(), before.messages.len() + 2);
12414            assert_eq!(
12415                cold.messages[before.messages.len()].id,
12416                envelope.id.as_str()
12417            );
12418            assert_eq!(cold.messages.last().unwrap().content, "RETRY_REPLY");
12419            assert_eq!(
12420                cold.messages
12421                    .iter()
12422                    .filter(|m| m.id == envelope.id.as_str())
12423                    .count(),
12424                1
12425            );
12426            let cold_inbox = bamboo_storage::FileSessionInbox::new(
12427                reopened,
12428                bamboo_domain::SessionInboxLimits::default(),
12429            );
12430            assert!(cold_inbox
12431                .was_admitted(&cold.id, &envelope.id)
12432                .await
12433                .unwrap());
12434            assert_eq!(cold_inbox.inspect(&cold.id).await.unwrap().claimed, 0);
12435        }
12436    }
12437
12438    #[tokio::test]
12439    async fn pre_ack_owned_recovery_requires_expiry_provenance_and_verified_already() {
12440        for case in [
12441            "accepted",
12442            "actor-live",
12443            "claim-live",
12444            "legacy",
12445            "ack",
12446            "readonly",
12447            "prefix",
12448            "duplicate",
12449            "missing-marker",
12450            "mixed",
12451            "project",
12452            "birth",
12453        ] {
12454            let temp = tempfile::tempdir().unwrap();
12455            let store = Arc::new(
12456                bamboo_storage::SessionStoreV2::new(temp.path().into())
12457                    .await
12458                    .unwrap(),
12459            );
12460            let parent = Session::new("recovery-parent", "model");
12461            store.save_session(&parent).await.unwrap();
12462            let mut child =
12463                Session::new_child_of("recovery-child", &parent, "model", "original task");
12464            child.add_message(bamboo_agent_core::Message::user("original task"));
12465            store.save_session(&child).await.unwrap();
12466            let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
12467            bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
12468            let inbox = bamboo_storage::FileSessionInbox::new(
12469                store.clone(),
12470                bamboo_domain::SessionInboxLimits::default(),
12471            );
12472            // Complete unfenced persistence before starting the setup lease window.
12473            let envelope =
12474                bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "RECOVER_SAME_INPUT");
12475            let receipt = inbox.deliver(&envelope).await.unwrap();
12476            inbox
12477                .mark_activation_eligible(
12478                    &child.id,
12479                    receipt.generation,
12480                    bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
12481                )
12482                .await
12483                .unwrap();
12484            let now = chrono::Utc::now();
12485            let cutoff = now + chrono::Duration::seconds(10);
12486            let consumer = SessionInboxConsumerId::new();
12487            let old = store
12488                .claim_activation(&ActorActivationClaim {
12489                    actor_id: child.id.clone(),
12490                    run_id: "old-run".into(),
12491                    lease_owner: consumer.as_str().into(),
12492                    lease_expires_at: if case == "actor-live" {
12493                        now + chrono::Duration::minutes(5)
12494                    } else {
12495                        cutoff
12496                    },
12497                    inbox_generation: 0,
12498                    placement_ref: if case == "legacy" {
12499                        None
12500                    } else {
12501                        Some(bamboo_domain::ActorPlacementRef {
12502                            class: bamboo_domain::ActorPlacementClass::Local,
12503                            lease_id: "owned-initial-release-v1:actual-old-worker".into(),
12504                            slot_epoch: None,
12505                        })
12506                    },
12507                    now,
12508                })
12509                .await
12510                .unwrap();
12511            store
12512                .start_activation(&old.fence(), chrono::Utc::now())
12513                .await
12514                .unwrap();
12515            let claim_now = chrono::Utc::now();
12516            let claim = inbox
12517                .claim_owned(
12518                    &child.id,
12519                    1,
12520                    Some("old-run"),
12521                    &SessionInboxLeaseRequest {
12522                        consumer,
12523                        now: claim_now,
12524                        duration: if case == "claim-live" {
12525                            chrono::Duration::minutes(5)
12526                        } else {
12527                            cutoff - claim_now
12528                        },
12529                    },
12530                )
12531                .await
12532                .unwrap()
12533                .pop()
12534                .unwrap();
12535            child = inbox
12536                .checkpoint_actor_input(bamboo_storage::ActorInputCheckpoint {
12537                    fence: old.fence(),
12538                    expected_created_at: child.created_at,
12539                    claim: claim.clone(),
12540                    expected_messages: child.messages.clone(),
12541                    expected_provider_transcript: child.provider_transcript.clone(),
12542                    expected_admission: None,
12543                })
12544                .await
12545                .unwrap()
12546                .session;
12547            // The real checkpoint must succeed under the original live authority.
12548            store
12549                .validate_fence(&old.fence(), chrono::Utc::now())
12550                .await
12551                .unwrap_or_else(|error| panic!("{case}: setup fence: {error:?}"));
12552            let setup_leases = inbox
12553                .inspect_owned_leases(&child.id, 2, chrono::Utc::now())
12554                .await
12555                .unwrap();
12556            assert_eq!(setup_leases.len(), 1, "{case}: original setup claim");
12557            assert_eq!(setup_leases[0].epoch, claim.lease.epoch, "{case}");
12558            assert_eq!(setup_leases[0].expires_at, claim.lease.expires_at, "{case}");
12559            assert!(!setup_leases[0].expired, "{case}: setup claim must be live");
12560            let setup_at = chrono::Utc::now();
12561            assert!(
12562                setup_at < old.lease_expires_at,
12563                "{case}: setup Actor expired"
12564            );
12565            assert!(
12566                setup_at < claim.lease.expires_at,
12567                "{case}: setup claim expired"
12568            );
12569            eprintln!("pre-ACK {case}: setup live at {setup_at}, cutoff {cutoff}");
12570            if case == "ack" {
12571                inbox
12572                    .ack_owned(&child.id, &claim, chrono::Utc::now())
12573                    .await
12574                    .unwrap();
12575            }
12576            if case == "mixed" {
12577                inbox
12578                    .deliver(&bamboo_domain::SessionMessageEnvelope::user_input(
12579                        &child.id,
12580                        "different input",
12581                    ))
12582                    .await
12583                    .unwrap();
12584            }
12585            let directory = store
12586                .bamboo_home_dir()
12587                .join(store.resolve_rel_path(&child.id).await.unwrap());
12588            let main = std::fs::read(directory.join("session.json")).unwrap();
12589            let canonical = child.clone();
12590            match case {
12591                "prefix" => child.messages[0].content.push_str(" stale"),
12592                "duplicate" => child.messages.push(child.messages.last().unwrap().clone()),
12593                "missing-marker" => {
12594                    child
12595                        .messages
12596                        .last_mut()
12597                        .unwrap()
12598                        .metadata
12599                        .as_mut()
12600                        .unwrap()
12601                        .as_object_mut()
12602                        .unwrap()
12603                        .remove("_bamboo_owned_input_checkpoint");
12604                }
12605                "project" => child.set_project_id_meta(bamboo_domain::ProjectId::new().to_string()),
12606                "birth" => child.created_at += chrono::Duration::nanoseconds(1),
12607                _ => {}
12608            }
12609            // One shared short deadline per case; never wait for the live controls.
12610            // No reclaim, renewal or authority-file edit occurs between these stages.
12611            let delay = (cutoff - chrono::Utc::now()).to_std().unwrap_or_default();
12612            tokio::time::sleep(delay + Duration::from_millis(10)).await;
12613            let recovery_at = chrono::Utc::now();
12614            assert!(recovery_at >= cutoff, "{case}: real expiry is required");
12615            let before_recovery = store.inspect_actor(&child.id).await.unwrap();
12616            let original = before_recovery.activation.unwrap();
12617            assert_eq!(original.fence(), old.fence(), "{case}: same authority");
12618            assert_eq!(original.lease_expires_at, old.lease_expires_at, "{case}");
12619            assert_eq!(
12620                store
12621                    .validate_fence(&old.fence(), recovery_at)
12622                    .await
12623                    .is_ok(),
12624                case == "actor-live",
12625                "{case}: Actor expiry control"
12626            );
12627            let recovery_leases = inbox
12628                .inspect_owned_leases(&child.id, 2, recovery_at)
12629                .await
12630                .unwrap();
12631            if case == "ack" {
12632                assert!(recovery_leases.is_empty(), "{case}: terminal ACK");
12633            } else {
12634                assert_eq!(recovery_leases.len(), 1, "{case}: original recovery claim");
12635                assert_eq!(recovery_leases[0].epoch, claim.lease.epoch, "{case}");
12636                assert_eq!(
12637                    recovery_leases[0].expires_at, claim.lease.expires_at,
12638                    "{case}"
12639                );
12640                assert_eq!(recovery_leases[0].expired, case != "claim-live", "{case}");
12641            }
12642            eprintln!("pre-ACK {case}: recovery expiry control at {recovery_at}");
12643            let second = Arc::new(
12644                bamboo_storage::SessionStoreV2::new(temp.path().into())
12645                    .await
12646                    .unwrap(),
12647            );
12648            let binding = actor_binding(
12649                second.clone(),
12650                Arc::new(bamboo_storage::FileSessionInbox::new(
12651                    second.clone(),
12652                    bamboo_domain::SessionInboxLimits::default(),
12653                )),
12654                Arc::new(bamboo_storage::LockedSessionStore::new(second.clone())),
12655            );
12656            let _owner = binding
12657                .router
12658                .register_run(&child.id, "replacement-run")
12659                .await
12660                .unwrap();
12661            let prepared = PlainActorActivation::start_and_prepare(
12662                second.clone(),
12663                &mut child,
12664                &binding,
12665                Some("replacement-run"),
12666                Some(policy.clone()),
12667                case != "readonly",
12668                Some("probed-new-worker"),
12669            )
12670            .await;
12671            assert_eq!(
12672                std::fs::read(directory.join("session.json")).unwrap(),
12673                main,
12674                "{case}: Already or refusal never rewrites Main"
12675            );
12676            if case != "accepted" {
12677                assert!(prepared.is_err(), "{case}");
12678                assert_eq!(
12679                    inbox.was_admitted(&child.id, &envelope.id).await.unwrap(),
12680                    case == "ack"
12681                );
12682                continue;
12683            }
12684            let (replacement, delivery) = prepared.unwrap();
12685            assert!(replacement.recovering_pre_ack);
12686            let (messages, delivery) = delivery.unwrap();
12687            let new_claim = replacement.initial_input.as_ref().unwrap();
12688            assert_eq!(new_claim.claim.envelope, claim.claim.envelope);
12689            assert_eq!(new_claim.claim.generation, claim.claim.generation);
12690            assert_eq!(
12691                new_claim.claim.activation_policy,
12692                claim.claim.activation_policy
12693            );
12694            assert!(new_claim.lease.epoch > claim.lease.epoch);
12695            assert_ne!(new_claim.lease.incarnation, claim.lease.incarnation);
12696            assert_eq!(
12697                replacement.inbox_generation, 1,
12698                "old Actor generation was zero"
12699            );
12700            assert_eq!(
12701                second
12702                    .inspect_actor(&child.id)
12703                    .await
12704                    .unwrap()
12705                    .actor
12706                    .current_attempt,
12707                2
12708            );
12709            assert!(second
12710                .validate_fence(&old.fence(), chrono::Utc::now())
12711                .await
12712                .is_err());
12713            assert!(inbox
12714                .ack_owned(&child.id, &claim, chrono::Utc::now())
12715                .await
12716                .is_err());
12717            // The original Store/claim survives the handoff. Even with the
12718            // current exact prefix and dedupe cursor, its delayed checkpoint
12719            // must fail on authority before AlreadyCheckpointed can succeed.
12720            let authority = second.inspect_actor(&child.id).await.unwrap();
12721            let leases = inbox
12722                .inspect_owned_leases(&child.id, 2, chrono::Utc::now())
12723                .await
12724                .unwrap();
12725            let delayed = inbox
12726                .checkpoint_actor_input(bamboo_storage::ActorInputCheckpoint {
12727                    fence: old.fence(),
12728                    expected_created_at: child.created_at,
12729                    claim: claim.clone(),
12730                    expected_messages: child.messages.clone(),
12731                    expected_provider_transcript: child.provider_transcript.clone(),
12732                    expected_admission: child.session_inbox_admission().cloned(),
12733                })
12734                .await;
12735            assert!(matches!(
12736                delayed,
12737                Err(bamboo_storage::ActorInputCheckpointError::Actor(
12738                    bamboo_domain::ActorDirectoryError::StaleFence
12739                ))
12740            ));
12741            assert_eq!(std::fs::read(directory.join("session.json")).unwrap(), main);
12742            assert_eq!(second.inspect_actor(&child.id).await.unwrap(), authority);
12743            assert_eq!(
12744                inbox
12745                    .inspect_owned_leases(&child.id, 2, chrono::Utc::now())
12746                    .await
12747                    .unwrap(),
12748                leases
12749            );
12750            assert!(!inbox.was_admitted(&child.id, &envelope.id).await.unwrap());
12751            assert_eq!(
12752                serde_json::to_value(&child.messages).unwrap(),
12753                serde_json::to_value(&canonical.messages).unwrap()
12754            );
12755            let mut worker: Vec<bamboo_agent_core::Message> = messages
12756                .iter()
12757                .cloned()
12758                .map(|v| serde_json::from_value(v).unwrap())
12759                .collect();
12760            let target = worker.last_mut().unwrap();
12761            assert!(target
12762                .metadata
12763                .as_ref()
12764                .unwrap()
12765                .get("_bamboo_owned_input_checkpoint")
12766                .is_none());
12767            assert!(bamboo_domain::is_matching_session_message(
12768                target, &envelope
12769            ));
12770            let mut wanted = canonical.messages.last().unwrap().clone();
12771            wanted
12772                .metadata
12773                .as_mut()
12774                .unwrap()
12775                .as_object_mut()
12776                .unwrap()
12777                .remove("_bamboo_owned_input_checkpoint");
12778            assert_eq!(
12779                serde_json::to_value(target).unwrap(),
12780                serde_json::to_value(wanted).unwrap()
12781            );
12782            let run = RunSpec {
12783                assignment: "original task".into(),
12784                logical_session: Some(LogicalSessionIdentity {
12785                    session_id: child.id.clone(),
12786                    parent_session_id: child.parent_session_id.clone(),
12787                    root_session_id: child.root_session_id.clone(),
12788                    creation: Some(bamboo_subagent::proto::ChildCreationIdentity {
12789                        created_at: child.created_at,
12790                        spawn_depth: child.spawn_depth,
12791                    }),
12792                }),
12793                project_id: None,
12794                reasoning_effort: None,
12795                permission_policy: Some(PermissionPolicyContext {
12796                    revision: policy.policy_revision(),
12797                    requested_mode: "default".into(),
12798                    effective_mode: "default".into(),
12799                    bypass_permissions: false,
12800                    auto_approve_permissions: false,
12801                    session_id: child.id.clone(),
12802                    workspace_path: child.workspace.clone(),
12803                    environment_lease: None,
12804                    inherit_session_grants: false,
12805                    policy: serde_json::to_value(policy.to_serializable()).unwrap(),
12806                }),
12807                messages,
12808                activation_run_id: Some("replacement-run".into()),
12809                execution_epoch: 2,
12810                initial_session_messages: vec![delivery.clone()],
12811                secrets: Default::default(),
12812            };
12813            let policy_revision = policy.policy_revision();
12814            let mut expected = Some(expected_default_permission_posture(policy_revision));
12815            let ChildFrame::Event { event } = permission_posture_frame(&child.id, policy_revision)
12816            else {
12817                unreachable!()
12818            };
12819            let (events, mut rx) = mpsc::channel(1);
12820            process_actor_event(
12821                event,
12822                true,
12823                &mut PermissionPostureHandshake::Awaiting,
12824                expected.as_ref(),
12825                Some(&binding),
12826                &mut child,
12827                &events,
12828                &mut ActorEventDisplay::default(),
12829            )
12830            .await
12831            .unwrap();
12832            rx.recv().await.unwrap();
12833            let request = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
12834                &run,
12835                &delivery,
12836                uuid::Uuid::new_v4().to_string(),
12837            )
12838            .unwrap();
12839            replacement
12840                .release_initial_input(
12841                    &binding,
12842                    &child,
12843                    &run,
12844                    2,
12845                    new_claim,
12846                    &request,
12847                    &mut expected,
12848                    None,
12849                )
12850                .await
12851                .unwrap();
12852            assert!(inbox.was_admitted(&child.id, &envelope.id).await.unwrap());
12853            replacement
12854                .append_reply(&mut child, "RECOVERY_REPLY".into(), &events)
12855                .await
12856                .unwrap();
12857            rx.recv().await.unwrap();
12858            replacement
12859                .finish(ActorActivationFinish::Succeeded)
12860                .await
12861                .unwrap();
12862            let cold = second.load_session(&child.id).await.unwrap().unwrap();
12863            assert_eq!(cold.created_at, canonical.created_at);
12864            assert_eq!(
12865                serde_json::to_value(&cold.messages[..canonical.messages.len()]).unwrap(),
12866                serde_json::to_value(&canonical.messages).unwrap()
12867            );
12868            assert_eq!(cold.messages.last().unwrap().content, "RECOVERY_REPLY");
12869            assert_eq!(
12870                cold.messages
12871                    .iter()
12872                    .filter(|m| m.id == envelope.id.as_str())
12873                    .count(),
12874                1
12875            );
12876        }
12877    }
12878
12879    #[cfg(unix)]
12880    #[tokio::test]
12881    async fn owned_initial_release_missing_only_new_capability_rejects_before_spawn_or_run() {
12882        use bamboo_subagent::provision::*;
12883        use std::os::unix::fs::PermissionsExt;
12884        let temp = tempfile::tempdir().unwrap();
12885        let worker = temp.path().join("mixed-worker");
12886        let spawned = temp.path().join("spawned");
12887        let run = temp.path().join("run");
12888        let probes = temp.path().join("probes");
12889        let fabric = temp.path().join("fabric");
12890        let mut report = WorkerCapabilityReport::current();
12891        report
12892            .capabilities
12893            .retain(|name| name != INITIAL_INPUT_RELEASE_WORKER_CAPABILITY);
12894        assert!(!report.supports(INITIAL_INPUT_RELEASE_WORKER_CAPABILITY));
12895        let report = serde_json::to_string(&report).unwrap();
12896        std::fs::write(&worker, format!(
12897            "#!/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"
12898        )).unwrap();
12899        std::fs::set_permissions(&worker, std::fs::Permissions::from_mode(0o700)).unwrap();
12900        let args = vec![
12901            spawned.to_str().unwrap().into(),
12902            run.to_str().unwrap().into(),
12903            probes.to_str().unwrap().into(),
12904        ];
12905        // Real subprocess probes prove the rejection cannot be an earlier missing bit.
12906        for capability in [
12907            REQUIRED_CHILD_CONTEXT_WORKER_CAPABILITY,
12908            CHILD_CREATION_IDENTITY_WORKER_CAPABILITY,
12909            NATIVE_TOOL_CEILING_WORKER_CAPABILITY,
12910            TYPED_READ_ONLY_WORKER_CAPABILITY,
12911        ] {
12912            bamboo_subagent::fleet::require_worker_capability(&worker, &args, capability)
12913                .await
12914                .unwrap();
12915        }
12916        let runner = ActorChildRunner::new(
12917            "mixed".into(),
12918            worker,
12919            args,
12920            fabric.clone(),
12921            ExecutorSpec::BambooRuntime,
12922            vec![],
12923            "openai".into(),
12924            1,
12925        );
12926        // The actual Host method remains mandatory before claim/provision/Run.
12927        let error = runner
12928            .require_initial_input_release_worker()
12929            .await
12930            .unwrap_err();
12931        assert!(error
12932            .to_string()
12933            .contains("owned initial release worker capability unconfirmed"));
12934        assert_eq!(std::fs::read_to_string(probes).unwrap().lines().count(), 5);
12935        assert!(!spawned.exists() && !run.exists() && !fabric.exists());
12936        assert!(runner.pool.lock().await.is_empty());
12937    }
12938
12939    #[tokio::test]
12940    async fn ordinary_glob_only_observes_own_initialization_signals() {
12941        let temp = tempfile::tempdir().unwrap();
12942        let home = temp.path().canonicalize().unwrap();
12943        let store = bamboo_storage::SessionStoreV2::new(home.clone())
12944            .await
12945            .unwrap();
12946        let parent = Session::new("glob-standard-parent", "model");
12947        store.save_session(&parent).await.unwrap();
12948        let child = Session::new_child_of("glob-standard-child", &parent, "model", "one Glob");
12949        store.save_session(&child).await.unwrap();
12950        let directory = home.join(store.resolve_rel_path(&child.id).await.unwrap());
12951        // An unrelated bad child would reject a full tree snapshot, but is not
12952        // part of this own-session no-replay observation.
12953        let unrelated = directory.parent().unwrap().join("unrelated");
12954        std::fs::create_dir(&unrelated).unwrap();
12955        std::fs::write(unrelated.join("session.json"), b"invalid").unwrap();
12956        require_unowned_glob(&store, &child).await.unwrap();
12957        for name in ["actor-authority.json", "actor-authority.initialized.json"] {
12958            let path = directory.join(name);
12959            std::fs::write(&path, b"invalid prior signal").unwrap();
12960            assert!(require_unowned_glob(&store, &child).await.is_err());
12961            std::fs::remove_file(&path).unwrap();
12962        }
12963        #[cfg(unix)]
12964        {
12965            let marker = directory.join("actor-authority.initialized.json");
12966            std::os::unix::fs::symlink(directory.join("missing"), &marker).unwrap();
12967            assert!(require_unowned_glob(&store, &child).await.is_err());
12968        }
12969    }
12970
12971    #[test]
12972    fn owned_glob_collector_requires_actual_ordered_tool_trace_and_typed_tail() {
12973        let call = bamboo_domain::ToolCall {
12974            id: "glob".into(),
12975            tool_type: "function".into(),
12976            function: bamboo_domain::FunctionCall {
12977                name: "Glob".into(),
12978                arguments: r#"{"pattern":"marker.txt"}"#.into(),
12979            },
12980        };
12981        let messages = vec![
12982            bamboo_agent_core::Message::assistant("", Some(vec![call])),
12983            bamboo_agent_core::Message::tool_result("glob", "marker.txt"),
12984            bamboo_agent_core::Message::assistant("done", None),
12985        ];
12986        let start = serde_json::to_value(AgentEvent::ToolStart {
12987            tool_call_id: "glob".into(),
12988            tool_name: "Glob".into(),
12989            arguments: serde_json::json!({"pattern":"marker.txt"}),
12990        })
12991        .unwrap();
12992        let complete = serde_json::to_value(AgentEvent::ToolComplete {
12993            tool_call_id: "glob".into(),
12994            result: bamboo_domain::ToolResult::text(true, "marker.txt"),
12995        })
12996        .unwrap();
12997        let tail =
12998            serde_json::to_value(bamboo_subagent::proto::ReadOnlyActorTranscript::Complete {
12999                messages: messages
13000                    .iter()
13001                    .map(serde_json::to_value)
13002                    .collect::<Result<_, _>>()
13003                    .unwrap(),
13004            })
13005            .unwrap();
13006        let mut ordered = ReadOnlyActorCollector::default();
13007        assert!(ordered.event(&start).unwrap());
13008        assert!(ordered.event(&complete).unwrap());
13009        assert!(!ordered.event(&tail).unwrap());
13010        assert!(ordered.event(&tail).is_err(), "duplicate suffix");
13011        assert_eq!(ordered.finish(Some("done")).unwrap().len(), 3);
13012        assert!(ReadOnlyActorCollector::default()
13013            .finish(Some("done"))
13014            .is_err());
13015        let mut unfinished = ReadOnlyActorCollector::default();
13016        unfinished.event(&start).unwrap();
13017        unfinished.event(&complete).unwrap();
13018        assert!(unfinished.finish(Some("done")).is_err(), "missing suffix");
13019        let mut closed = tail.clone();
13020        closed["grant"] = true.into();
13021        assert!(
13022            serde_json::from_value::<bamboo_subagent::proto::ReadOnlyActorTranscript>(closed)
13023                .is_err()
13024        );
13025        let mut oversized = tail.clone();
13026        oversized["messages"][2]["content"] = "x".repeat(64 * 1024).into();
13027        assert!(
13028            serde_json::from_value::<bamboo_subagent::proto::ReadOnlyActorTranscript>(oversized)
13029                .unwrap()
13030                .validate()
13031                .is_err()
13032        );
13033        assert!(ReadOnlyActorCollector::default().event(&complete).is_err());
13034        assert!(ReadOnlyActorCollector::default().event(&tail).is_err());
13035        let mut bad = start.clone();
13036        bad["tool_name"] = "Bash".into();
13037        assert!(ReadOnlyActorCollector::default().event(&bad).is_err());
13038        let mut duplicate = ReadOnlyActorCollector::default();
13039        duplicate.event(&start).unwrap();
13040        assert!(duplicate.event(&start).is_err());
13041        let hidden = serde_json::to_value(AgentEvent::ReasoningToken {
13042            content: "hidden".into(),
13043        })
13044        .unwrap();
13045        assert!(ReadOnlyActorCollector::default().event(&hidden).is_err());
13046        let mut mismatched = ReadOnlyActorCollector::default();
13047        mismatched.event(&start).unwrap();
13048        let mut bad = complete.clone();
13049        bad["tool_call_id"] = "foreign".into();
13050        assert!(mismatched.event(&bad).is_err());
13051        mismatched.event(&complete).unwrap();
13052        let mut changed = tail.clone();
13053        changed["messages"][1]["content"] = "fabricated".into();
13054        assert!(mismatched.event(&changed).is_err());
13055    }
13056
13057    #[cfg(unix)]
13058    #[tokio::test]
13059    async fn owned_initial_release_requires_current_prefix_ack_and_same_receipt() {
13060        let temp = tempfile::tempdir().unwrap();
13061        let store = Arc::new(
13062            bamboo_storage::SessionStoreV2::new(temp.path().into())
13063                .await
13064                .unwrap(),
13065        );
13066        let parent = Session::new("release-host-parent", "model");
13067        store.save_session(&parent).await.unwrap();
13068        let mut child = Session::new_child_of("release-host-child", &parent, "model", "assignment");
13069        store.save_session(&child).await.unwrap();
13070        let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
13071            store.clone(),
13072            bamboo_domain::SessionInboxLimits::default(),
13073        ));
13074        let binding = actor_binding(
13075            store.clone(),
13076            inbox,
13077            Arc::new(bamboo_storage::LockedSessionStore::new(store.clone())),
13078        );
13079        let _owner = binding
13080            .router
13081            .register_run(&child.id, "release-host-run")
13082            .await
13083            .unwrap();
13084        let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
13085        let revision = policy.policy_revision();
13086        bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
13087        let activation = PlainActorActivation::start(
13088            store.clone(),
13089            &child,
13090            &binding,
13091            Some("release-host-run"),
13092            Some(policy.clone()),
13093            true,
13094            Some("probed-physical-worker"),
13095        )
13096        .await
13097        .unwrap();
13098        let envelope = bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "RELEASE_ONCE");
13099        let receipt = activation.input_inbox.deliver(&envelope).await.unwrap();
13100        activation
13101            .input_inbox
13102            .mark_activation_eligible(
13103                &child.id,
13104                receipt.generation,
13105                bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
13106            )
13107            .await
13108            .unwrap();
13109        let claim = activation
13110            .claim_input(&binding, &child, "release-host-run")
13111            .await
13112            .unwrap()
13113            .unwrap();
13114        let (prefix, delivery) = activation
13115            .checkpoint_input(&mut child, "release-host-run", &claim)
13116            .await
13117            .unwrap();
13118        let mut expected = Some(expected_default_permission_posture(revision));
13119        let ChildFrame::Event { event } = permission_posture_frame(&child.id, revision) else {
13120            unreachable!()
13121        };
13122        let (events, mut events_rx) = mpsc::channel(1);
13123        process_actor_event(
13124            event,
13125            true,
13126            &mut PermissionPostureHandshake::Awaiting,
13127            expected.as_ref(),
13128            Some(&binding),
13129            &mut child,
13130            &events,
13131            &mut ActorEventDisplay::default(),
13132        )
13133        .await
13134        .unwrap();
13135        events_rx.recv().await.unwrap();
13136        let run = RunSpec {
13137            assignment: "assignment".into(),
13138            logical_session: Some(LogicalSessionIdentity {
13139                session_id: child.id.clone(),
13140                parent_session_id: child.parent_session_id.clone(),
13141                root_session_id: child.root_session_id.clone(),
13142                creation: Some(bamboo_subagent::proto::ChildCreationIdentity {
13143                    created_at: child.created_at,
13144                    spawn_depth: child.spawn_depth,
13145                }),
13146            }),
13147            project_id: None,
13148            reasoning_effort: None,
13149            permission_policy: Some(bamboo_subagent::proto::PermissionPolicyContext {
13150                revision,
13151                requested_mode: "default".into(),
13152                effective_mode: "default".into(),
13153                bypass_permissions: false,
13154                auto_approve_permissions: false,
13155                session_id: child.id.clone(),
13156                workspace_path: child.workspace.clone(),
13157                environment_lease: None,
13158                inherit_session_grants: false,
13159                policy: serde_json::to_value(policy.to_serializable()).unwrap(),
13160            }),
13161            messages: prefix,
13162            activation_run_id: Some("release-host-run".into()),
13163            execution_epoch: 9,
13164            initial_session_messages: vec![delivery.clone()],
13165            secrets: Default::default(),
13166        };
13167        let request = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
13168            &run,
13169            &delivery,
13170            uuid::Uuid::new_v4().to_string(),
13171        )
13172        .unwrap();
13173        let mut stale = child.clone();
13174        stale.add_message(bamboo_agent_core::Message::user("not canonical"));
13175        assert!(activation
13176            .release_initial_input(
13177                &binding,
13178                &stale,
13179                &run,
13180                9,
13181                &claim,
13182                &request,
13183                &mut expected,
13184                None
13185            )
13186            .await
13187            .is_err());
13188        assert!(!activation
13189            .input_inbox
13190            .was_admitted(&child.id, &envelope.id)
13191            .await
13192            .unwrap());
13193        // Keep receipt lookup readable so Already reaches the real ACK write.
13194        // Unix root can bypass these modes; this FS fault needs an unprivileged user.
13195        use std::os::unix::fs::PermissionsExt;
13196        struct RestorePermissions(std::path::PathBuf, std::fs::Permissions);
13197        impl Drop for RestorePermissions {
13198            fn drop(&mut self) {
13199                let _ = std::fs::set_permissions(&self.0, self.1.clone());
13200            }
13201        }
13202        let admitted = store
13203            .bamboo_home_dir()
13204            .join(store.resolve_rel_path(&child.id).await.unwrap())
13205            .join("inbox/admitted");
13206        std::fs::create_dir_all(&admitted).unwrap();
13207        let restore = RestorePermissions(
13208            admitted.clone(),
13209            std::fs::metadata(&admitted).unwrap().permissions(),
13210        );
13211        std::fs::set_permissions(&admitted, std::fs::Permissions::from_mode(0o555)).unwrap();
13212        let verified = activation
13213            .input_inbox
13214            .checkpoint_actor_input(bamboo_storage::ActorInputCheckpoint {
13215                fence: activation.fence.clone(),
13216                expected_created_at: child.created_at,
13217                claim: claim.clone(),
13218                expected_messages: child.messages.clone(),
13219                expected_provider_transcript: child.provider_transcript.clone(),
13220                expected_admission: child.session_inbox_admission().cloned(),
13221            })
13222            .await
13223            .unwrap();
13224        assert_eq!(
13225            verified.status,
13226            bamboo_storage::ActorInputCheckpointStatus::AlreadyCheckpointed
13227        );
13228        let error = activation
13229            .release_initial_input(
13230                &binding,
13231                &child,
13232                &run,
13233                9,
13234                &claim,
13235                &request,
13236                &mut expected,
13237                None,
13238            )
13239            .await
13240            .unwrap_err();
13241        std::fs::set_permissions(&admitted, restore.1.clone()).unwrap();
13242        drop(restore);
13243        assert!(
13244            error.to_string().contains("ACK unresolved"),
13245            "actual release error (<=512B): {}",
13246            error.to_string().chars().take(128).collect::<String>()
13247        );
13248        let release = activation
13249            .release_initial_input(
13250                &binding,
13251                &child,
13252                &run,
13253                9,
13254                &claim,
13255                &request,
13256                &mut expected,
13257                None,
13258            )
13259            .await
13260            .unwrap();
13261        assert!(activation
13262            .input_inbox
13263            .was_admitted(&child.id, &envelope.id)
13264            .await
13265            .unwrap());
13266        assert_eq!(
13267            release.expires_at,
13268            activation
13269                .actor_deadline
13270                .min(claim.lease.expires_at)
13271                .min(activation.input_deadline)
13272        );
13273        assert_eq!(
13274            activation
13275                .release_initial_input(
13276                    &binding,
13277                    &child,
13278                    &run,
13279                    9,
13280                    &claim,
13281                    &request,
13282                    &mut expected,
13283                    Some(&release)
13284                )
13285                .await
13286                .unwrap(),
13287            release
13288        );
13289        let mut wrong = request.clone();
13290        wrong.nonce = uuid::Uuid::new_v4().to_string();
13291        assert!(activation
13292            .release_initial_input(
13293                &binding,
13294                &child,
13295                &run,
13296                9,
13297                &claim,
13298                &wrong,
13299                &mut expected,
13300                Some(&release)
13301            )
13302            .await
13303            .is_err());
13304        assert!(activation
13305            .release_initial_input(
13306                &binding,
13307                &child,
13308                &run,
13309                10,
13310                &claim,
13311                &request,
13312                &mut expected,
13313                Some(&release)
13314            )
13315            .await
13316            .is_err());
13317        // A second real checkpoint/release reuses the activation, not its old epoch or nonce.
13318        let deadlines = (
13319            activation.fence.clone(),
13320            activation.actor_deadline,
13321            activation.input_deadline,
13322        );
13323        let second = bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "RELEASE_TWICE");
13324        let receipt = activation.input_inbox.deliver(&second).await.unwrap();
13325        activation
13326            .input_inbox
13327            .mark_activation_eligible(
13328                &child.id,
13329                receipt.generation,
13330                bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
13331            )
13332            .await
13333            .unwrap();
13334        let mut link = ConfirmationSequenceLink {
13335            frames: VecDeque::new(),
13336            sent: Vec::new(),
13337        };
13338        let epochs = AtomicU64::new(9);
13339        let (claim2, epoch2) = activation
13340            .continue_input(
13341                &mut link,
13342                &binding,
13343                &mut child,
13344                "release-host-run",
13345                Some((&run, &epochs)),
13346                &mut expected,
13347                "SECOND_REPLY".into(),
13348                &events,
13349            )
13350            .await
13351            .unwrap()
13352            .unwrap();
13353        events_rx.recv().await.unwrap();
13354        assert_eq!(epoch2, 10);
13355        let ParentFrame::Run(next) = &link.sent[0] else {
13356            panic!("second correlated Run")
13357        };
13358        assert_eq!(next.logical_session, run.logical_session);
13359        assert_eq!(next.activation_run_id, run.activation_run_id);
13360        assert_eq!(next.initial_session_messages.len(), 1);
13361        assert!(next
13362            .messages
13363            .iter()
13364            .any(|m| m["id"] == envelope.id.as_str()));
13365        assert!(next.messages.iter().any(|m| m["content"] == "SECOND_REPLY"));
13366        assert!(!next.messages.iter().any(|m| m["id"] == second.id.as_str()));
13367        let request2 = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
13368            next,
13369            &next.initial_session_messages[0],
13370            uuid::Uuid::new_v4().to_string(),
13371        )
13372        .unwrap();
13373        assert_ne!(request2.nonce, request.nonce);
13374        macro_rules! release_second {
13375            ($candidate:expr, $request:expr, $epoch:expr, $previous:expr) => {
13376                activation
13377                    .release_initial_input(
13378                        &binding,
13379                        $candidate,
13380                        next,
13381                        $epoch,
13382                        &claim2,
13383                        $request,
13384                        &mut expected,
13385                        $previous,
13386                    )
13387                    .await
13388            };
13389        }
13390        assert!(release_second!(&child, &request, epoch2, None).is_err());
13391        assert!(release_second!(&child, &request2, 9, None).is_err());
13392        let mut stale2 = child.clone();
13393        stale2.add_message(bamboo_agent_core::Message::user("stale second prefix"));
13394        assert!(release_second!(&stale2, &request2, epoch2, None).is_err());
13395        assert!(!activation
13396            .input_inbox
13397            .was_admitted(&child.id, &second.id)
13398            .await
13399            .unwrap());
13400        let release2 = release_second!(&child, &request2, epoch2, None).unwrap();
13401        assert_eq!(
13402            release_second!(&child, &request2, epoch2, Some(&release2)).unwrap(),
13403            release2
13404        );
13405        let mut wrong2 = request2.clone();
13406        wrong2.nonce = request.nonce.clone();
13407        assert!(release_second!(&child, &wrong2, epoch2, Some(&release2)).is_err());
13408        assert!(activation
13409            .input_inbox
13410            .was_admitted(&child.id, &second.id)
13411            .await
13412            .unwrap());
13413        let expired = bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "EXPIRED_INPUT");
13414        let receipt = activation.input_inbox.deliver(&expired).await.unwrap();
13415        activation
13416            .input_inbox
13417            .mark_activation_eligible(
13418                &child.id,
13419                receipt.generation,
13420                bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
13421            )
13422            .await
13423            .unwrap();
13424        let now = chrono::Utc::now();
13425        let expired_claim = activation
13426            .input_inbox
13427            .claim_owned(
13428                &child.id,
13429                1,
13430                Some("release-host-run"),
13431                &SessionInboxLeaseRequest {
13432                    consumer: activation.input_consumer.clone(),
13433                    now,
13434                    duration: chrono::Duration::seconds(1),
13435                },
13436            )
13437            .await
13438            .unwrap()
13439            .pop()
13440            .unwrap();
13441        let (prefix, delivery3) = activation
13442            .checkpoint_input(&mut child, "release-host-run", &expired_claim)
13443            .await
13444            .unwrap();
13445        let mut expired_run = next.clone();
13446        expired_run.execution_epoch = 11;
13447        expired_run.messages = prefix;
13448        expired_run.initial_session_messages = vec![delivery3.clone()];
13449        let expired_request = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
13450            &expired_run,
13451            &delivery3,
13452            uuid::Uuid::new_v4().to_string(),
13453        )
13454        .unwrap();
13455        tokio::time::sleep(
13456            (expired_claim.lease.expires_at - chrono::Utc::now())
13457                .to_std()
13458                .unwrap_or_default(),
13459        )
13460        .await;
13461        assert!(chrono::Utc::now() >= expired_claim.lease.expires_at);
13462        assert!(activation
13463            .release_initial_input(
13464                &binding,
13465                &child,
13466                &expired_run,
13467                11,
13468                &expired_claim,
13469                &expired_request,
13470                &mut expected,
13471                None
13472            )
13473            .await
13474            .is_err());
13475        assert!(!activation
13476            .input_inbox
13477            .was_admitted(&child.id, &expired.id)
13478            .await
13479            .unwrap());
13480        assert_eq!(
13481            (
13482                activation.fence.clone(),
13483                activation.actor_deadline,
13484                activation.input_deadline
13485            ),
13486            deadlines
13487        );
13488        assert_eq!(
13489            link.sent.len(),
13490            1,
13491            "no dispatch from rejected release checks"
13492        );
13493        let cold = store.load_session(&child.id).await.unwrap().unwrap();
13494        for id in [&envelope.id, &second.id] {
13495            assert_eq!(
13496                cold.messages.iter().filter(|m| m.id == id.as_str()).count(),
13497                1
13498            );
13499            assert!(cold.session_inbox_admission().unwrap().contains(id));
13500        }
13501        // Mutate the actual captured policy Arc; cached ACK/release is not a fresh grant.
13502        policy.deny_scoped_session_permission(
13503            &child.id,
13504            bamboo_tools::permission::PermissionType::ExecuteCommand,
13505            "blocked-action",
13506        );
13507        assert!(release_second!(&child, &request2, epoch2, Some(&release2)).is_err());
13508        assert!(activation
13509            .input_inbox
13510            .was_admitted(&child.id, &second.id)
13511            .await
13512            .unwrap());
13513        activation
13514            .finish(ActorActivationFinish::Failed)
13515            .await
13516            .unwrap();
13517        // Terminal ACK can be idempotent; stopped/expired Actor cannot grant.
13518        activation
13519            .input_inbox
13520            .ack_owned(&child.id, &claim, chrono::Utc::now())
13521            .await
13522            .unwrap();
13523        assert!(activation
13524            .release_initial_input(
13525                &binding,
13526                &child,
13527                &run,
13528                9,
13529                &claim,
13530                &request,
13531                &mut expected,
13532                Some(&release)
13533            )
13534            .await
13535            .is_err());
13536        let cold = bamboo_storage::SessionStoreV2::new(temp.path().into())
13537            .await
13538            .unwrap()
13539            .load_session(&child.id)
13540            .await
13541            .unwrap()
13542            .unwrap();
13543        assert_eq!(
13544            cold.messages
13545                .iter()
13546                .filter(|m| m.id == envelope.id.as_str())
13547                .count(),
13548            1
13549        );
13550    }
13551
13552    #[tokio::test]
13553    async fn owned_actor_input_checkpoint_precedes_dispatch_and_exact_ack() {
13554        for reject_checkpoint in [false, true] {
13555            let temp = tempfile::tempdir().unwrap();
13556            let store = Arc::new(
13557                bamboo_storage::SessionStoreV2::new(temp.path().into())
13558                    .await
13559                    .unwrap(),
13560            );
13561            let parent = Session::new("owned-input-parent", "model");
13562            store.save_session(&parent).await.unwrap();
13563            let mut child =
13564                Session::new_child_of("owned-input-child", &parent, "model", "one plain task");
13565            store.save_session(&child).await.unwrap();
13566            let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
13567            let raw_storage: Arc<dyn Storage> = store.clone();
13568            let locked = Arc::new(bamboo_storage::LockedSessionStore::new(raw_storage));
13569            let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
13570                store.clone(),
13571                bamboo_domain::SessionInboxLimits::default(),
13572            ));
13573            let binding = actor_binding(store.clone(), inbox, locked);
13574            let run_id = "owned-input-run";
13575            let _registration = binding
13576                .router
13577                .register_run(&child.id, run_id)
13578                .await
13579                .unwrap();
13580            assert!(PlainActorActivation::start(
13581                store.clone(),
13582                &child,
13583                &binding,
13584                Some(run_id),
13585                None,
13586                true,
13587                None,
13588            )
13589            .await
13590            .is_err());
13591            assert!(PlainActorActivation::start(
13592                store.clone(),
13593                &child,
13594                &binding,
13595                Some(run_id),
13596                Some(policy.clone()),
13597                true,
13598                None,
13599            )
13600            .await
13601            .is_err());
13602            assert_eq!(
13603                store
13604                    .inspect_actor(&child.id)
13605                    .await
13606                    .unwrap()
13607                    .actor
13608                    .current_attempt,
13609                0
13610            );
13611            bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
13612            let activation = PlainActorActivation::start(
13613                store.clone(),
13614                &child,
13615                &binding,
13616                Some(run_id),
13617                Some(policy.clone()),
13618                true,
13619                None,
13620            )
13621            .await
13622            .unwrap();
13623            let envelope =
13624                bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "CORRECTION_ONCE");
13625            let receipt = activation.input_inbox.deliver(&envelope).await.unwrap();
13626            activation
13627                .input_inbox
13628                .mark_activation_eligible(
13629                    &child.id,
13630                    receipt.generation,
13631                    bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
13632                )
13633                .await
13634                .unwrap();
13635            let mut link = ConfirmationSequenceLink {
13636                frames: VecDeque::new(),
13637                sent: Vec::new(),
13638            };
13639            let policy_revision = policy.policy_revision();
13640            let run = RunSpec {
13641                assignment: "one plain task".into(),
13642                logical_session: None,
13643                project_id: None,
13644                reasoning_effort: None,
13645                permission_policy: Some(PermissionPolicyContext {
13646                    revision: policy_revision,
13647                    requested_mode: "default".into(),
13648                    effective_mode: "default".into(),
13649                    bypass_permissions: false,
13650                    auto_approve_permissions: false,
13651                    session_id: child.id.clone(),
13652                    workspace_path: child.workspace.clone(),
13653                    environment_lease: None,
13654                    inherit_session_grants: false,
13655                    policy: serde_json::to_value(policy.to_serializable()).unwrap(),
13656                }),
13657                messages: vec![],
13658                activation_run_id: Some(run_id.into()),
13659                execution_epoch: 1,
13660                initial_session_messages: vec![],
13661                secrets: Default::default(),
13662            };
13663            let epochs = AtomicU64::new(1);
13664            let (tx, mut rx) = mpsc::channel(1);
13665            let mut expected = Some(expected_default_permission_posture(policy_revision));
13666            let ChildFrame::Event { event } = permission_posture_frame(&child.id, policy_revision)
13667            else {
13668                unreachable!()
13669            };
13670            process_actor_event(
13671                event,
13672                true,
13673                &mut PermissionPostureHandshake::Awaiting,
13674                expected.as_ref(),
13675                Some(&binding),
13676                &mut child,
13677                &tx,
13678                &mut ActorEventDisplay::default(),
13679            )
13680            .await
13681            .unwrap();
13682            rx.recv().await.unwrap();
13683            let first_audit =
13684                bamboo_domain::PermissionAuditSnapshot::from_metadata(&child.metadata)
13685                    .unwrap()
13686                    .audit_revision;
13687            if reject_checkpoint {
13688                // A real bounded publication channel blocks AFTER first-reply
13689                // commit, so change actual Actor authority before checkpoint.
13690                tx.send(AgentEvent::Complete {
13691                    usage: Default::default(),
13692                })
13693                .await
13694                .unwrap();
13695            }
13696            let mut operation = activation.continue_input(
13697                &mut link,
13698                &binding,
13699                &mut child,
13700                run_id,
13701                Some((&run, &epochs)),
13702                &mut expected,
13703                "FIRST_PLAIN_REPLY".into(),
13704                &tx,
13705            );
13706            if reject_checkpoint {
13707                tokio::select! {
13708                    result = &mut operation => panic!("publication barrier released early: {result:?}"),
13709                    _ = async {
13710                        tokio::time::timeout(Duration::from_secs(5), async {
13711                            loop {
13712                                let actual = store.load_session("owned-input-child").await.unwrap().unwrap();
13713                                if actual.messages.iter().any(|m| m.content == "FIRST_PLAIN_REPLY") { break; }
13714                                tokio::task::yield_now().await;
13715                            }
13716                        }).await.unwrap();
13717                        activation.finish(ActorActivationFinish::Failed).await.unwrap();
13718                        rx.recv().await.unwrap();
13719                    } => {}
13720                }
13721            }
13722            let result = operation.await;
13723            if reject_checkpoint {
13724                assert!(result
13725                    .unwrap_err()
13726                    .to_string()
13727                    .contains("checkpoint unconfirmed"));
13728                assert!(
13729                    link.sent.is_empty(),
13730                    "zero worker/provider correction dispatch"
13731                );
13732                let durable = store.load_session(&child.id).await.unwrap().unwrap();
13733                assert!(durable
13734                    .messages
13735                    .iter()
13736                    .any(|m| m.content == "FIRST_PLAIN_REPLY"));
13737                assert!(!durable
13738                    .messages
13739                    .iter()
13740                    .any(|m| m.id == envelope.id.as_str()));
13741                assert!(!activation
13742                    .input_inbox
13743                    .was_admitted(&child.id, &envelope.id)
13744                    .await
13745                    .unwrap());
13746                assert_eq!(
13747                    activation
13748                        .input_inbox
13749                        .inspect(&child.id)
13750                        .await
13751                        .unwrap()
13752                        .claimed,
13753                    1
13754                );
13755                continue;
13756            }
13757            let (claim, epoch) = result.unwrap().unwrap();
13758            rx.recv().await.unwrap(); // Actual first-reply publication.
13759            assert_eq!(epoch, 2);
13760            assert_eq!(link.sent.len(), 1);
13761            let ParentFrame::Run(next) = &link.sent[0] else {
13762                panic!("correlated continuation Run")
13763            };
13764            assert_eq!(next.execution_epoch, 2);
13765            assert_eq!(next.activation_run_id.as_deref(), Some(run_id));
13766            assert!(next
13767                .messages
13768                .iter()
13769                .any(|m| m["content"] == "FIRST_PLAIN_REPLY"));
13770            assert!(!next
13771                .messages
13772                .iter()
13773                .any(|m| m["id"] == envelope.id.as_str()));
13774            let delivery = &next.initial_session_messages[0];
13775            assert_eq!(delivery.envelope, envelope);
13776            let durable = store.load_session(&child.id).await.unwrap().unwrap();
13777            assert!(durable
13778                .session_inbox_admission()
13779                .unwrap()
13780                .contains(&envelope.id));
13781            assert_eq!(
13782                durable
13783                    .messages
13784                    .iter()
13785                    .filter(|m| m.id == envelope.id.as_str())
13786                    .count(),
13787                1
13788            );
13789            assert!(
13790                !activation
13791                    .input_inbox
13792                    .was_admitted(&child.id, &envelope.id)
13793                    .await
13794                    .unwrap(),
13795                "checkpoint is not a worker admission receipt"
13796            );
13797            let mut confirmation = admission_confirmation(&child.id, &claim.claim, run_id);
13798            confirmation.canonical_claim_generation += 1;
13799            assert!(activation
13800                .confirm_input(&binding, &child, run_id, &claim, &confirmation)
13801                .await
13802                .is_err());
13803            assert!(!activation
13804                .input_inbox
13805                .was_admitted(&child.id, &envelope.id)
13806                .await
13807                .unwrap());
13808            confirmation.canonical_claim_generation = claim.claim.generation;
13809            activation
13810                .confirm_input(&binding, &child, run_id, &claim, &confirmation)
13811                .await
13812                .unwrap();
13813            assert!(activation
13814                .input_inbox
13815                .was_admitted(&child.id, &envelope.id)
13816                .await
13817                .unwrap());
13818            assert_eq!(
13819                expected.as_ref().unwrap().expected_audit_revision,
13820                Some(first_audit)
13821            );
13822            let ChildFrame::Event { event } = permission_posture_frame(&child.id, policy_revision)
13823            else {
13824                unreachable!()
13825            };
13826            process_actor_event(
13827                event,
13828                true,
13829                &mut PermissionPostureHandshake::Awaiting,
13830                expected.as_ref(),
13831                Some(&binding),
13832                &mut child,
13833                &tx,
13834                &mut ActorEventDisplay::default(),
13835            )
13836            .await
13837            .unwrap();
13838            rx.recv().await.unwrap();
13839            assert!(
13840                bamboo_domain::PermissionAuditSnapshot::from_metadata(&child.metadata)
13841                    .unwrap()
13842                    .audit_revision
13843                    > first_audit
13844            );
13845            let reopened = bamboo_storage::SessionStoreV2::new(temp.path().into())
13846                .await
13847                .unwrap();
13848            let cold = reopened.load_session(&child.id).await.unwrap().unwrap();
13849            assert_eq!(
13850                cold.messages
13851                    .iter()
13852                    .filter(|m| m.id == envelope.id.as_str())
13853                    .count(),
13854                1
13855            );
13856            assert!(cold
13857                .session_inbox_admission()
13858                .unwrap()
13859                .contains(&envelope.id));
13860        }
13861    }
13862
13863    fn admission_confirmation(
13864        session_id: &str,
13865        claim: &SessionInboxClaim,
13866        run_id: &str,
13867    ) -> bamboo_subagent::proto::SessionMessageAdmissionConfirmation {
13868        bamboo_subagent::proto::SessionMessageAdmissionConfirmation {
13869            target_session_id: session_id.to_string(),
13870            envelope_id: claim.envelope.id.to_string(),
13871            canonical_claim_generation: claim.generation,
13872            activation_run_id: run_id.to_string(),
13873        }
13874    }
13875
13876    fn expected_default_permission_posture(policy_revision: u64) -> ExpectedPermissionPosture {
13877        ExpectedPermissionPosture {
13878            policy_revision,
13879            resolution: bamboo_domain::PermissionModeResolution {
13880                requested: bamboo_domain::SessionPermissionMode::Default,
13881                effective: bamboo_domain::PermissionMode::Default,
13882            },
13883            expected_audit_revision: None,
13884            executor_mapping: "test_actor:permission_mode=default".to_string(),
13885        }
13886    }
13887
13888    fn permission_posture_frame(session_id: &str, policy_revision: u64) -> ChildFrame {
13889        ChildFrame::Event {
13890            event: serde_json::to_value(AgentEvent::PermissionPostureActivated {
13891                session_id: session_id.to_string(),
13892                policy_revision,
13893                requested_mode: "default".to_string(),
13894                effective_mode: "default".to_string(),
13895                executor_mapping: "test_actor:permission_mode=default".to_string(),
13896            })
13897            .expect("serialize permission posture event"),
13898        }
13899    }
13900
13901    fn actor_event_frame(event: AgentEvent) -> ChildFrame {
13902        ChildFrame::Event {
13903            event: serde_json::to_value(event).expect("serialize actor event"),
13904        }
13905    }
13906
13907    #[test]
13908    fn actor_event_batch_is_fenced_by_session_activation_and_epoch() {
13909        let session = Session::new_child("logical-child", "logical-parent", "model", "child");
13910        let mut batch = ActorEventBatch {
13911            logical_session: Some(LogicalSessionIdentity {
13912                creation: None,
13913                session_id: session.id.clone(),
13914                parent_session_id: session.parent_session_id.clone(),
13915                root_session_id: session.root_session_id.clone(),
13916            }),
13917            activation_id: Some("activation-7".into()),
13918            execution_epoch: 11,
13919            source_node_id: Some("node-a".into()),
13920            source_actor_id: Some("worker-a".into()),
13921            first_seq: 1,
13922            last_seq: 1,
13923            qos: bamboo_subagent::ActorEventQos::Ephemeral,
13924            events: vec![serde_json::json!({"type":"token","content":"ok"})],
13925        };
13926        validate_actor_event_batch(
13927            &batch,
13928            &session,
13929            "logical-parent",
13930            Some("activation-7"),
13931            11,
13932            "worker-a",
13933            None,
13934        )
13935        .unwrap();
13936
13937        let creation = bamboo_subagent::proto::ChildCreationIdentity {
13938            created_at: session.created_at,
13939            spawn_depth: session.spawn_depth,
13940        };
13941        assert!(validate_actor_event_batch(
13942            &batch,
13943            &session,
13944            "logical-parent",
13945            Some("activation-7"),
13946            11,
13947            "worker-a",
13948            Some(&creation)
13949        )
13950        .is_err());
13951        batch.logical_session.as_mut().unwrap().creation = Some(creation.clone());
13952        validate_actor_event_batch(
13953            &batch,
13954            &session,
13955            "logical-parent",
13956            Some("activation-7"),
13957            11,
13958            "worker-a",
13959            Some(&creation),
13960        )
13961        .unwrap();
13962        batch
13963            .logical_session
13964            .as_mut()
13965            .unwrap()
13966            .creation
13967            .as_mut()
13968            .unwrap()
13969            .created_at += chrono::Duration::nanoseconds(1);
13970        assert!(validate_actor_event_batch(
13971            &batch,
13972            &session,
13973            "logical-parent",
13974            Some("activation-7"),
13975            11,
13976            "worker-a",
13977            Some(&creation)
13978        )
13979        .is_err());
13980        batch.logical_session.as_mut().unwrap().creation = None;
13981
13982        batch.execution_epoch = 10;
13983        assert!(validate_actor_event_batch(
13984            &batch,
13985            &session,
13986            "logical-parent",
13987            Some("activation-7"),
13988            11,
13989            "worker-a",
13990            None,
13991        )
13992        .unwrap_err()
13993        .to_string()
13994        .contains("execution epoch"));
13995        batch.execution_epoch = 11;
13996        batch.activation_id = Some("replaced-activation".into());
13997        assert!(validate_actor_event_batch(
13998            &batch,
13999            &session,
14000            "logical-parent",
14001            Some("activation-7"),
14002            11,
14003            "worker-a",
14004            None,
14005        )
14006        .unwrap_err()
14007        .to_string()
14008        .contains("stale activation"));
14009        batch.activation_id = Some("activation-7".into());
14010        batch
14011            .logical_session
14012            .as_mut()
14013            .expect("logical identity")
14014            .session_id = "foreign-child".into();
14015        assert!(validate_actor_event_batch(
14016            &batch,
14017            &session,
14018            "logical-parent",
14019            Some("activation-7"),
14020            11,
14021            "worker-a",
14022            None,
14023        )
14024        .unwrap_err()
14025        .to_string()
14026        .contains("different logical session"));
14027        batch
14028            .logical_session
14029            .as_mut()
14030            .expect("logical identity")
14031            .session_id = session.id.clone();
14032        batch.source_actor_id = Some("worker-b".into());
14033        assert!(validate_actor_event_batch(
14034            &batch,
14035            &session,
14036            "logical-parent",
14037            Some("activation-7"),
14038            11,
14039            "worker-a",
14040            None,
14041        )
14042        .unwrap_err()
14043        .to_string()
14044        .contains("different physical actor"));
14045    }
14046
14047    #[tokio::test]
14048    async fn directory_event_ingress_rejects_old_attempt_and_forged_identity() {
14049        use bamboo_domain::Storage;
14050
14051        let temp = tempfile::tempdir().unwrap();
14052        let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
14053            .await
14054            .unwrap();
14055        let parent = Session::new("event-parent", "model");
14056        let child = Session::new_child_of("event-child", &parent, "model", "task");
14057        store.save_session(&parent).await.unwrap();
14058        store.save_session(&child).await.unwrap();
14059        store.ensure_actor(&child.id).await.unwrap();
14060        let now = chrono::Utc::now();
14061        let claim = |run_id: &str| ActorActivationClaim {
14062            actor_id: child.id.clone(),
14063            run_id: run_id.into(),
14064            lease_owner: "event-owner".into(),
14065            lease_expires_at: now + chrono::Duration::minutes(2),
14066            inbox_generation: 0,
14067            placement_ref: None,
14068            now,
14069        };
14070        let first = store.claim_activation(&claim("run-one")).await.unwrap();
14071        store.start_activation(&first.fence(), now).await.unwrap();
14072        let first_fence = first.fence();
14073        validate_directory_actor_event(&store, &first_fence, &child, Some("run-one"))
14074            .await
14075            .unwrap();
14076        validate_directory_actor_event(&store, &first_fence, &child, None)
14077            .await
14078            .unwrap(); // rolling-upgrade frame uses the same owner
14079        assert!(
14080            validate_directory_actor_event(&store, &first_fence, &child, Some("forged-run"))
14081                .await
14082                .is_err()
14083        );
14084        let mut forged = child.clone();
14085        forged.root_session_id = "foreign-root".into();
14086        assert!(
14087            validate_directory_actor_event(&store, &first_fence, &forged, Some("run-one"))
14088                .await
14089                .is_err()
14090        );
14091
14092        store
14093            .finish_activation(&first_fence, now, ActorActivationFinish::Failed)
14094            .await
14095            .unwrap();
14096        let second = store.claim_activation(&claim("run-two")).await.unwrap();
14097        store.start_activation(&second.fence(), now).await.unwrap();
14098        assert!(
14099            validate_directory_actor_event(&store, &first_fence, &child, Some("run-one"))
14100                .await
14101                .is_err()
14102        );
14103        assert!(
14104            validate_directory_actor_event(&store, &first_fence, &child, None)
14105                .await
14106                .is_err()
14107        );
14108        validate_directory_actor_event(&store, &second.fence(), &child, Some("run-two"))
14109            .await
14110            .unwrap();
14111    }
14112
14113    fn completed_actor_frame() -> ChildFrame {
14114        ChildFrame::Terminal {
14115            status: TerminalStatus::Completed,
14116            result: Some("done".to_string()),
14117            error: None,
14118            final_event_watermark: None,
14119            transcript: Vec::new(),
14120        }
14121    }
14122
14123    async fn drive_permission_handshake_frames(
14124        session_id: &str,
14125        frames: impl IntoIterator<Item = ChildFrame>,
14126        expected: ExpectedPermissionPosture,
14127    ) -> (
14128        crate::runtime::runner::Result<Option<String>>,
14129        Session,
14130        Vec<AgentEvent>,
14131        Vec<ParentFrame>,
14132    ) {
14133        let mut link = ConfirmationSequenceLink {
14134            frames: frames.into_iter().collect(),
14135            sent: Vec::new(),
14136        };
14137        let (event_tx, mut event_rx) = mpsc::channel(128);
14138        let cancel = CancellationToken::new();
14139        let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
14140        let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
14141        let mut session = Session::new(session_id, "model");
14142        let result = drive(ActorDriveContext {
14143            expected_creation: None,
14144            client: &mut link,
14145            parent_session_id: "permission-parent",
14146            child_session_id: session_id,
14147            child_attempt: 0,
14148            approval_registry: None,
14149            approval_decider: None,
14150            approval_reviewer: None,
14151            escalation_bridge: None,
14152            event_tx: &event_tx,
14153            cancel_token: &cancel,
14154            live_rx: &mut live_rx,
14155            delivery_rx: &mut delivery_rx,
14156            logical_session: &mut session,
14157            expected_permission_posture: Some(expected),
14158            session_inbox_runtime: None,
14159            actor_directory_store: None,
14160            canonical_subagent_tool: None,
14161            ticket_service: None,
14162            activation_run_id: None,
14163            execution_epoch: 0,
14164            expected_source_actor_id: session_id,
14165            initial_inflight_claims: VecDeque::new(),
14166            plain_actor: false,
14167            remote_environment_lease: false,
14168            readonly_output: None,
14169            local_history_tools: None,
14170            local_history_read_only: false,
14171            strict_history_output: None,
14172            plain_input: None,
14173            canonical_activation: None,
14174            canonical_placement_ref: None,
14175            actor_event_observer: None,
14176            plain_run: None,
14177            first_frame_timeout: Some(Duration::from_secs(1)),
14178        })
14179        .await;
14180        let events = std::iter::from_fn(|| event_rx.try_recv().ok()).collect();
14181        (result, session, events, link.sent)
14182    }
14183
14184    #[tokio::test]
14185    async fn actor_permission_handshake_rejects_terminal_without_posture() {
14186        let session_id = "permission-missing";
14187        let (result, session, events, _) = drive_permission_handshake_frames(
14188            session_id,
14189            [completed_actor_frame()],
14190            expected_default_permission_posture(7),
14191        )
14192        .await;
14193
14194        assert!(result
14195            .unwrap_err()
14196            .to_string()
14197            .contains("terminated before permission posture confirmation"));
14198        assert!(events.is_empty());
14199        assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
14200    }
14201
14202    #[tokio::test]
14203    async fn remote_environment_rejection_accepts_only_allowlisted_durable_terminal() {
14204        struct TerminalProofProbe {
14205            frame: Option<ChildFrame>,
14206            accepted: bool,
14207        }
14208
14209        #[async_trait]
14210        impl bamboo_subagent::ChildLink for TerminalProofProbe {
14211            async fn send(&mut self, _frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
14212                Ok(())
14213            }
14214
14215            async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
14216                Ok(self.frame.take())
14217            }
14218
14219            fn accept_durable_terminal(&mut self, status: TerminalStatus) {
14220                self.accepted = status == TerminalStatus::Error;
14221            }
14222
14223            fn has_pending_durable_terminal(&self) -> bool {
14224                self.accepted
14225            }
14226        }
14227
14228        for (status, error, expected_proof) in [
14229            (
14230                TerminalStatus::Error,
14231                Some("remote_environment_checkout_not_clean"),
14232                true,
14233            ),
14234            (TerminalStatus::Error, Some("untrusted worker error"), false),
14235            (
14236                TerminalStatus::Cancelled,
14237                Some("remote_environment_checkout_not_clean"),
14238                false,
14239            ),
14240        ] {
14241            let mut link = TerminalProofProbe {
14242                frame: Some(ChildFrame::Terminal {
14243                    status,
14244                    result: None,
14245                    error: error.map(str::to_owned),
14246                    final_event_watermark: None,
14247                    transcript: Vec::new(),
14248                }),
14249                accepted: false,
14250            };
14251            let (event_tx, _event_rx) = mpsc::channel(8);
14252            let cancel = CancellationToken::new();
14253            let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
14254            let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
14255            let mut session = Session::new("remote-terminal-proof", "model");
14256            let result = drive(ActorDriveContext {
14257                client: &mut link,
14258                parent_session_id: "proof-parent",
14259                child_session_id: "remote-terminal-proof",
14260                child_attempt: 0,
14261                approval_registry: None,
14262                approval_decider: None,
14263                approval_reviewer: None,
14264                escalation_bridge: None,
14265                event_tx: &event_tx,
14266                cancel_token: &cancel,
14267                live_rx: &mut live_rx,
14268                delivery_rx: &mut delivery_rx,
14269                logical_session: &mut session,
14270                expected_permission_posture: Some(expected_default_permission_posture(7)),
14271                expected_creation: None,
14272                session_inbox_runtime: None,
14273                actor_directory_store: None,
14274                canonical_subagent_tool: None,
14275                ticket_service: None,
14276                activation_run_id: None,
14277                execution_epoch: 0,
14278                expected_source_actor_id: "remote-terminal-proof",
14279                initial_inflight_claims: VecDeque::new(),
14280                plain_actor: false,
14281                remote_environment_lease: true,
14282                readonly_output: None,
14283                local_history_tools: None,
14284                local_history_read_only: false,
14285                strict_history_output: None,
14286                plain_input: None,
14287                canonical_activation: None,
14288                canonical_placement_ref: None,
14289                actor_event_observer: None,
14290                plain_run: None,
14291                first_frame_timeout: Some(Duration::from_secs(1)),
14292            })
14293            .await;
14294            assert!(result.is_err());
14295            assert_eq!(
14296                bamboo_subagent::ChildLink::has_pending_durable_terminal(&link),
14297                expected_proof
14298            );
14299        }
14300    }
14301
14302    #[tokio::test(start_paused = true)]
14303    async fn remote_cancel_requires_selected_worker_terminal_for_release_proof() {
14304        struct CancelOutcomeLink {
14305            cancel_sent: bool,
14306            settle: bool,
14307            accepted: bool,
14308        }
14309
14310        #[async_trait]
14311        impl bamboo_subagent::ChildLink for CancelOutcomeLink {
14312            async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
14313                self.cancel_sent = matches!(frame, ParentFrame::Cancel);
14314                Ok(())
14315            }
14316
14317            async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
14318                if !self.cancel_sent || !self.settle {
14319                    std::future::pending::<bamboo_subagent::TransportResult<Option<ChildFrame>>>()
14320                        .await
14321                } else {
14322                    self.cancel_sent = false;
14323                    Ok(Some(ChildFrame::Terminal {
14324                        status: TerminalStatus::Cancelled,
14325                        result: None,
14326                        error: None,
14327                        final_event_watermark: None,
14328                        transcript: Vec::new(),
14329                    }))
14330                }
14331            }
14332
14333            fn accept_durable_terminal(&mut self, status: TerminalStatus) {
14334                self.accepted = status == TerminalStatus::Cancelled;
14335            }
14336
14337            fn has_pending_durable_terminal(&self) -> bool {
14338                self.accepted
14339            }
14340        }
14341
14342        let temp = tempfile::tempdir().unwrap();
14343        let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
14344            .await
14345            .unwrap();
14346        let fence = ActorActivationFence {
14347            schema_version: 2,
14348            actor_id: "cancelled-remote-child".into(),
14349            activation_id: "cancelled-remote-activation".into(),
14350            attempt: 1,
14351            run_id: "cancelled-remote-run".into(),
14352            lease_owner: "cancelled-remote-host".into(),
14353            lease_epoch: 1,
14354        };
14355        for settle in [true, false] {
14356            let mut link = CancelOutcomeLink {
14357                cancel_sent: false,
14358                settle,
14359                accepted: false,
14360            };
14361            let (event_tx, _event_rx) = mpsc::channel(8);
14362            let cancel = CancellationToken::new();
14363            cancel.cancel();
14364            let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
14365            let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
14366            let mut session = Session::new("cancelled-remote-child", "model");
14367            let result = drive(ActorDriveContext {
14368                expected_creation: None,
14369                client: &mut link,
14370                parent_session_id: "cancelled-remote-parent",
14371                child_session_id: "cancelled-remote-child",
14372                child_attempt: 1,
14373                approval_registry: None,
14374                approval_decider: None,
14375                approval_reviewer: None,
14376                escalation_bridge: None,
14377                event_tx: &event_tx,
14378                cancel_token: &cancel,
14379                live_rx: &mut live_rx,
14380                delivery_rx: &mut delivery_rx,
14381                logical_session: &mut session,
14382                expected_permission_posture: Some(expected_default_permission_posture(7)),
14383                session_inbox_runtime: None,
14384                actor_directory_store: Some(&store),
14385                canonical_subagent_tool: None,
14386                ticket_service: None,
14387                activation_run_id: Some("cancelled-remote-run"),
14388                execution_epoch: 0,
14389                expected_source_actor_id: "cancelled-remote-worker",
14390                initial_inflight_claims: VecDeque::new(),
14391                plain_actor: false,
14392                remote_environment_lease: true,
14393                readonly_output: None,
14394                local_history_tools: None,
14395                local_history_read_only: false,
14396                strict_history_output: None,
14397                plain_input: None,
14398                canonical_activation: Some(&fence),
14399                canonical_placement_ref: None,
14400                actor_event_observer: None,
14401                plain_run: None,
14402                first_frame_timeout: None,
14403            })
14404            .await;
14405            assert!(matches!(result, Err(AgentError::Cancelled)));
14406            assert_eq!(
14407                link.accepted, settle,
14408                "only a consumed exact terminal can release the remote fence"
14409            );
14410            assert_eq!(
14411                bamboo_subagent::ChildLink::has_pending_durable_terminal(&link),
14412                settle
14413            );
14414        }
14415    }
14416
14417    #[tokio::test]
14418    async fn actor_permission_handshake_rejects_malformed_agent_event() {
14419        let session_id = "permission-malformed";
14420        let (result, session, events, _) = drive_permission_handshake_frames(
14421            session_id,
14422            [ChildFrame::Event {
14423                event: serde_json::json!({"type": "token", "content": 42}),
14424            }],
14425            expected_default_permission_posture(7),
14426        )
14427        .await;
14428
14429        assert!(result
14430            .unwrap_err()
14431            .to_string()
14432            .contains("malformed AgentEvent"));
14433        assert!(events.is_empty());
14434        assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
14435
14436        let (result, _, events, _) = drive_permission_handshake_frames(
14437            session_id,
14438            [
14439                permission_posture_frame(session_id, 7),
14440                ChildFrame::Event {
14441                    event: serde_json::json!({"type":"private-download-secret"}),
14442                },
14443            ],
14444            expected_default_permission_posture(7),
14445        )
14446        .await;
14447        assert!(!result
14448            .unwrap_err()
14449            .to_string()
14450            .contains("private-download-secret"));
14451        assert_eq!(
14452            events.len(),
14453            1,
14454            "only the confirmed posture may be forwarded"
14455        );
14456    }
14457
14458    #[tokio::test]
14459    async fn actor_permission_handshake_rejects_execution_event_before_posture() {
14460        let session_id = "permission-early-event";
14461        let early_events = [
14462            (
14463                "progress",
14464                AgentEvent::RunnerProgress {
14465                    session_id: session_id.to_string(),
14466                    round_count: 1,
14467                },
14468            ),
14469            (
14470                "token",
14471                AgentEvent::Token {
14472                    content: "must-not-forward".to_string(),
14473                },
14474            ),
14475            (
14476                "tool",
14477                AgentEvent::ToolStart {
14478                    tool_call_id: "early-tool".to_string(),
14479                    tool_name: "Read".to_string(),
14480                    arguments: serde_json::json!({"file_path": "README.md"}),
14481                },
14482            ),
14483        ];
14484        for (kind, event) in early_events {
14485            let (result, session, events, _) = drive_permission_handshake_frames(
14486                session_id,
14487                [
14488                    actor_event_frame(event),
14489                    permission_posture_frame(session_id, 7),
14490                    completed_actor_frame(),
14491                ],
14492                expected_default_permission_posture(7),
14493            )
14494            .await;
14495
14496            assert!(
14497                result
14498                    .unwrap_err()
14499                    .to_string()
14500                    .contains("execution event before permission posture confirmation"),
14501                "{kind} must fail closed before posture"
14502            );
14503            assert!(events.is_empty(), "{kind} must not be forwarded");
14504            assert!(
14505                bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none(),
14506                "{kind} must not advance the permission audit"
14507            );
14508        }
14509    }
14510
14511    #[tokio::test]
14512    async fn actor_permission_handshake_rejects_approval_before_posture() {
14513        let session_id = "permission-early-approval";
14514        let (result, session, events, sent) = drive_permission_handshake_frames(
14515            session_id,
14516            [ChildFrame::ApprovalRequest {
14517                id: "approval-before-posture".to_string(),
14518                body: serde_json::json!({
14519                    "tool_name": "Bash",
14520                    "permission": "execute",
14521                    "resource": "echo must-not-run"
14522                }),
14523            }],
14524            expected_default_permission_posture(7),
14525        )
14526        .await;
14527
14528        assert!(result
14529            .unwrap_err()
14530            .to_string()
14531            .contains("requested approval before permission posture confirmation"));
14532        assert!(events.is_empty());
14533        assert!(
14534            sent.is_empty(),
14535            "an unconfirmed actor must receive no approval reply"
14536        );
14537        assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
14538    }
14539
14540    #[tokio::test]
14541    async fn actor_permission_handshake_rejects_mismatched_posture() {
14542        let session_id = "permission-mismatch";
14543        let (result, session, events, _) = drive_permission_handshake_frames(
14544            session_id,
14545            [permission_posture_frame(session_id, 8)],
14546            expected_default_permission_posture(7),
14547        )
14548        .await;
14549
14550        assert!(result
14551            .unwrap_err()
14552            .to_string()
14553            .contains("does not match the host-dispatched policy"));
14554        assert!(events.is_empty());
14555        assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
14556    }
14557
14558    #[tokio::test]
14559    async fn actor_permission_handshake_rejects_untrusted_executor_mapping() {
14560        let session_id = "permission-hostile-mapping";
14561        for hostile_mapping in [
14562            "wrong_executor:permission_mode=default",
14563            "test_actor:permission_mode=default;credential=must-not-persist",
14564        ] {
14565            let frame = ChildFrame::Event {
14566                event: serde_json::to_value(AgentEvent::PermissionPostureActivated {
14567                    session_id: session_id.to_string(),
14568                    policy_revision: 7,
14569                    requested_mode: "default".to_string(),
14570                    effective_mode: "default".to_string(),
14571                    executor_mapping: hostile_mapping.to_string(),
14572                })
14573                .expect("serialize hostile posture fixture"),
14574            };
14575            let (result, session, events, _) = drive_permission_handshake_frames(
14576                session_id,
14577                [frame],
14578                expected_default_permission_posture(7),
14579            )
14580            .await;
14581
14582            let error = result.unwrap_err().to_string();
14583            assert!(error.contains("host-dispatched executor mapping"));
14584            assert!(
14585                !error.contains(hostile_mapping),
14586                "untrusted mapping must not be reflected in host errors"
14587            );
14588            assert!(events.is_empty());
14589            assert!(
14590                bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none(),
14591                "untrusted mapping must not reach durable or in-memory audit state"
14592            );
14593        }
14594    }
14595
14596    #[tokio::test]
14597    async fn actor_permission_handshake_rejects_duplicate_posture() {
14598        let session_id = "permission-duplicate";
14599        let (result, session, events, _) = drive_permission_handshake_frames(
14600            session_id,
14601            [
14602                permission_posture_frame(session_id, 7),
14603                permission_posture_frame(session_id, 7),
14604                completed_actor_frame(),
14605            ],
14606            expected_default_permission_posture(7),
14607        )
14608        .await;
14609
14610        assert!(result
14611            .unwrap_err()
14612            .to_string()
14613            .contains("duplicate permission posture activation"));
14614        assert_eq!(
14615            events.len(),
14616            1,
14617            "only the confirmed posture may be forwarded"
14618        );
14619        assert!(matches!(
14620            events[0],
14621            AgentEvent::PermissionPostureActivated { .. }
14622        ));
14623        let audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
14624            .expect("the first matching posture must be recorded");
14625        assert_eq!(audit.policy_revision, 7);
14626    }
14627
14628    #[tokio::test]
14629    async fn actor_permission_handshake_happy_path_persists_before_forwarding_execution() {
14630        let session_id = "permission-happy";
14631        let (result, session, events, _) = drive_permission_handshake_frames(
14632            session_id,
14633            [
14634                permission_posture_frame(session_id, 7),
14635                actor_event_frame(AgentEvent::RunnerProgress {
14636                    session_id: session_id.to_string(),
14637                    round_count: 1,
14638                }),
14639                actor_event_frame(AgentEvent::Token {
14640                    content: "working".to_string(),
14641                }),
14642                actor_event_frame(AgentEvent::ToolStart {
14643                    tool_call_id: "tool-1".to_string(),
14644                    tool_name: "Read".to_string(),
14645                    arguments: serde_json::json!({"file_path": "README.md"}),
14646                }),
14647                completed_actor_frame(),
14648            ],
14649            expected_default_permission_posture(7),
14650        )
14651        .await;
14652
14653        assert_eq!(result.unwrap().as_deref(), Some("done"));
14654        assert!(matches!(
14655            events.as_slice(),
14656            [
14657                AgentEvent::PermissionPostureActivated { .. },
14658                AgentEvent::RunnerProgress { .. },
14659                AgentEvent::Token { .. },
14660                AgentEvent::ToolStart { .. }
14661            ]
14662        ));
14663        let audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
14664            .expect("matching posture must be recorded before execution events are accepted");
14665        assert_eq!(audit.policy_revision, 7);
14666        assert_eq!(audit.executor_mapping, "test_actor:permission_mode=default");
14667    }
14668
14669    #[tokio::test]
14670    async fn actor_download_tool_start_is_projected_before_parent_forwarding() {
14671        for (index, tool_name) in ["browser", "default::browser", "private-namespace::browser"]
14672            .into_iter()
14673            .enumerate()
14674        {
14675            let session_id = format!("actor-download-{index}");
14676            let args = serde_json::json!({
14677                "action":"download",
14678                "selector":"a[data-secret='private-selector']",
14679                "expected_epoch":17,
14680                "data_base64":"private-extra-bytes",
14681                "extra":{"url":"https://private.test/file"},
14682            });
14683            let original = args.clone();
14684            let read_args = serde_json::json!({"file_path":"README.md"});
14685            let (result, _session, events, _) = drive_permission_handshake_frames(
14686                &session_id,
14687                [
14688                    permission_posture_frame(&session_id, 7),
14689                    actor_event_frame(AgentEvent::ToolStart {
14690                        tool_call_id: "download-call".to_string(),
14691                        tool_name: tool_name.to_string(),
14692                        arguments: args.clone(),
14693                    }),
14694                    actor_event_frame(AgentEvent::ToolStart {
14695                        tool_call_id: "read-call".to_string(),
14696                        tool_name: "Read".to_string(),
14697                        arguments: read_args.clone(),
14698                    }),
14699                    completed_actor_frame(),
14700                ],
14701                expected_default_permission_posture(7),
14702            )
14703            .await;
14704            assert_eq!(result.expect("actor completed").as_deref(), Some("done"));
14705            assert_eq!(args, original);
14706            let starts: Vec<_> = events
14707                .iter()
14708                .filter_map(|event| match event {
14709                    AgentEvent::ToolStart {
14710                        tool_name,
14711                        arguments,
14712                        ..
14713                    } => Some((tool_name.as_str(), arguments)),
14714                    _ => None,
14715                })
14716                .collect();
14717            assert_eq!(starts.len(), 2);
14718            assert_eq!(starts[0].0, "browser");
14719            assert_eq!(
14720                starts[0].1,
14721                &serde_json::json!({"action":"download","expected_epoch":17})
14722            );
14723            assert_eq!(starts[1], ("Read", &read_args));
14724            assert!(!format!("{events:?}").contains("private"));
14725        }
14726    }
14727
14728    #[tokio::test]
14729    async fn actor_hook_lifecycle_hides_every_decision_text_across_event_batch_and_nested_stream() {
14730        let session_id = "actor-hook-display";
14731        let private = "private-selector-url-filename-response";
14732        let hook_name = format!("audit-{private}");
14733        let hook = |duration_ms, phase: &str, decision| AgentEvent::HookLifecycle {
14734            hook_name: hook_name.clone(),
14735            point: bamboo_domain::AgentHookPoint::BeforeToolExecution,
14736            phase: phase.into(),
14737            duration_ms,
14738            decision,
14739        };
14740        let decisions = [
14741            HookResult::Deny {
14742                reason: private.into(),
14743            },
14744            HookResult::InjectContext {
14745                text: private.into(),
14746            },
14747            HookResult::WithContext {
14748                result: Box::new(HookResult::WithContext {
14749                    result: Box::new(HookResult::Deny {
14750                        reason: private.into(),
14751                    }),
14752                    text: private.into(),
14753                }),
14754                text: private.into(),
14755            },
14756            HookResult::Suspend {
14757                reason: private.into(),
14758            },
14759            HookResult::Abort {
14760                reason: private.into(),
14761            },
14762        ];
14763        let originals = decisions.to_vec();
14764        let batch_events = [
14765            hook(2, "completed", decisions[1].clone()),
14766            hook(3, "completed", decisions[2].clone()),
14767            hook(4, "completed", decisions[3].clone()),
14768        ]
14769        .into_iter()
14770        .map(|event| serde_json::to_value(event).unwrap())
14771        .collect::<Vec<_>>();
14772        let batch = ActorEventBatch {
14773            logical_session: Some(LogicalSessionIdentity {
14774                creation: None,
14775                session_id: session_id.into(),
14776                parent_session_id: Some("permission-parent".into()),
14777                root_session_id: session_id.into(),
14778            }),
14779            activation_id: None,
14780            execution_epoch: 0,
14781            source_node_id: None,
14782            source_actor_id: Some(session_id.into()),
14783            first_seq: 1,
14784            last_seq: batch_events.len() as u64,
14785            qos: bamboo_subagent::ActorEventQos::classify(&batch_events[0]),
14786            events: batch_events,
14787        };
14788        let nested = AgentEvent::SubAgentEvent {
14789            parent_session_id: "parent".into(),
14790            child_session_id: "child".into(),
14791            event: Box::new(hook(5, "completed", decisions[4].clone())),
14792        };
14793        let raw = serde_json::to_string(&originals).unwrap();
14794        assert!(raw.contains(private));
14795
14796        let (outcome, _session, forwarded, _) = drive_permission_handshake_frames(
14797            session_id,
14798            [
14799                permission_posture_frame(session_id, 7),
14800                actor_event_frame(hook(1, private, decisions[0].clone())),
14801                ChildFrame::EventBatch { batch },
14802                actor_event_frame(nested),
14803                completed_actor_frame(),
14804            ],
14805            expected_default_permission_posture(7),
14806        )
14807        .await;
14808        assert_eq!(outcome.unwrap().as_deref(), Some("done"));
14809        let wire = serde_json::to_string(&forwarded).unwrap();
14810        assert!(
14811            !wire.contains(private),
14812            "private hook text reached SSE: {wire}"
14813        );
14814
14815        let hooks = forwarded
14816            .iter()
14817            .filter_map(|event| match event {
14818                AgentEvent::HookLifecycle {
14819                    hook_name,
14820                    point,
14821                    phase,
14822                    duration_ms,
14823                    decision,
14824                } => Some((hook_name, point, phase, duration_ms, decision)),
14825                _ => None,
14826            })
14827            .collect::<Vec<_>>();
14828        assert_eq!(hooks.len(), 4);
14829        assert!(hooks.iter().all(|(name, point, _, _, _)| {
14830            name == &&"actor-hook-1".to_string()
14831                && **point == bamboo_domain::AgentHookPoint::BeforeToolExecution
14832        }));
14833        assert_eq!(hooks[0].2, "event");
14834        assert_eq!(hooks[1].2, "completed");
14835        assert_eq!(*hooks[0].3, 1);
14836        assert_eq!(*hooks[3].3, 4);
14837        assert!(matches!(hooks[0].4, HookResult::Deny { reason } if reason == "Hook denied"));
14838        assert!(
14839            matches!(hooks[1].4, HookResult::InjectContext { text } if text == "Hook context hidden")
14840        );
14841        assert!(
14842            matches!(hooks[2].4, HookResult::WithContext { result, text }
14843            if text == "Hook context hidden" && matches!(result.as_ref(),
14844                HookResult::WithContext { result, text }
14845                    if text == "Hook context hidden" && matches!(result.as_ref(),
14846                        HookResult::Deny { reason } if reason == "Hook denied")))
14847        );
14848        assert!(matches!(hooks[3].4, HookResult::Suspend { reason } if reason == "Hook suspended"));
14849        assert!(forwarded.iter().any(|event| matches!(event,
14850            AgentEvent::SubAgentEvent { event, .. }
14851                if matches!(event.as_ref(), AgentEvent::HookLifecycle {
14852                    hook_name, duration_ms: 5,
14853                    decision: HookResult::Abort { reason }, ..
14854                } if hook_name == "actor-hook-1" && reason == "Hook aborted")
14855        )));
14856    }
14857
14858    #[test]
14859    fn actor_hook_display_depth_and_identity_limits_fail_closed() {
14860        let mut display = ActorEventDisplay::default();
14861        let mut deep = HookResult::Allow;
14862        for _ in 0..=MAX_DISPLAY_HOOK_RESULT_DEPTH {
14863            deep = HookResult::WithContext {
14864                result: Box::new(deep),
14865                text: "private-download-bytes".into(),
14866            };
14867        }
14868        let event = |hook_name: String, decision| AgentEvent::HookLifecycle {
14869            hook_name,
14870            point: bamboo_domain::AgentHookPoint::BeforeToolExecution,
14871            phase: "completed".into(),
14872            duration_ms: 7,
14873            decision,
14874        };
14875        assert!(display.project(event("deep".into(), deep), 0).is_none());
14876        assert!(display.overflowed);
14877        assert!(display
14878            .project(
14879                event(
14880                    "private-download-bytes".into(),
14881                    HookResult::Deny {
14882                        reason: "private-download-bytes".into(),
14883                    },
14884                ),
14885                0,
14886            )
14887            .is_none());
14888
14889        let mut oversized = ActorEventDisplay::default();
14890        assert!(oversized
14891            .project(
14892                event("x".repeat(MAX_DISPLAY_ID_BYTES + 1), HookResult::Allow),
14893                0,
14894            )
14895            .is_none());
14896        assert!(oversized.overflowed);
14897
14898        // A new actor connection gets a fresh opaque alias, never the raw
14899        // hook name. Repeated names keep the same alias until the shared
14900        // identity budget is exhausted, then the stream fails closed.
14901        let mut reconnected = ActorEventDisplay::default();
14902        let private = "private-download-bytes";
14903        for _ in 0..2 {
14904            let projected = reconnected
14905                .project(event(private.into(), HookResult::Allow), 0)
14906                .unwrap();
14907            assert!(
14908                matches!(projected, AgentEvent::HookLifecycle { hook_name, .. }
14909                if hook_name == "actor-hook-1")
14910            );
14911        }
14912        assert_eq!(reconnected.identities.load(Ordering::Relaxed), 1);
14913        for index in 1..MAX_DISPLAY_CALLS {
14914            assert!(reconnected
14915                .project(event(format!("hook-{index}"), HookResult::Allow), 0)
14916                .is_some());
14917        }
14918        assert!(reconnected
14919            .project(event("overflow-private".into(), HookResult::Allow), 0)
14920            .is_none());
14921        assert!(reconnected.overflowed);
14922        assert!(reconnected.hook_names.is_empty());
14923    }
14924
14925    #[test]
14926    fn actor_hook_display_preserves_nontext_decision_kinds() {
14927        for result in [
14928            HookResult::Continue,
14929            HookResult::Mutated,
14930            HookResult::Allow,
14931            HookResult::Ask,
14932        ] {
14933            assert_eq!(hook_result_for_display(result.clone(), 0), Some(result));
14934        }
14935    }
14936
14937    #[tokio::test]
14938    async fn malformed_actor_hook_events_never_fall_back_to_raw_display() {
14939        let session_id = "actor-malformed-hook";
14940        let private = "private-download-selector";
14941        let original = AgentEvent::HookLifecycle {
14942            hook_name: "audit".into(),
14943            point: bamboo_domain::AgentHookPoint::BeforeToolExecution,
14944            phase: "completed".into(),
14945            duration_ms: 9,
14946            decision: HookResult::Deny {
14947                reason: private.into(),
14948            },
14949        };
14950        let mut invalid = serde_json::to_value(&original).unwrap();
14951        invalid["decision"]["type"] = serde_json::json!("unknown_decision");
14952        let nested = AgentEvent::SubAgentEvent {
14953            parent_session_id: "parent".into(),
14954            child_session_id: "child".into(),
14955            event: Box::new(original),
14956        };
14957        let mut invalid_nested = serde_json::to_value(nested).unwrap();
14958        invalid_nested["event"]["decision"]["type"] = serde_json::json!("unknown_decision");
14959        let batch = ActorEventBatch {
14960            logical_session: Some(LogicalSessionIdentity {
14961                creation: None,
14962                session_id: session_id.into(),
14963                parent_session_id: Some("permission-parent".into()),
14964                root_session_id: session_id.into(),
14965            }),
14966            activation_id: None,
14967            execution_epoch: 0,
14968            source_node_id: None,
14969            source_actor_id: Some(session_id.into()),
14970            first_seq: 1,
14971            last_seq: 1,
14972            qos: bamboo_subagent::ActorEventQos::classify(&invalid),
14973            events: vec![invalid.clone()],
14974        };
14975        for frame in [
14976            ChildFrame::Event {
14977                event: invalid.clone(),
14978            },
14979            ChildFrame::EventBatch { batch },
14980            ChildFrame::Event {
14981                event: invalid_nested,
14982            },
14983        ] {
14984            let (outcome, _session, forwarded, _) = drive_permission_handshake_frames(
14985                session_id,
14986                [permission_posture_frame(session_id, 7), frame],
14987                expected_default_permission_posture(7),
14988            )
14989            .await;
14990            let error = outcome.unwrap_err().to_string();
14991            assert!(error.contains("malformed AgentEvent"), "{error}");
14992            assert!(!error.contains(private));
14993            assert!(!serde_json::to_string(&forwarded).unwrap().contains(private));
14994        }
14995
14996        let (event_tx, mut event_rx) = mpsc::channel(8);
14997        let mut session = Session::new("legacy-hook", "model");
14998        let mut handshake = PermissionPostureHandshake::NotRequired;
14999        let mut display = ActorEventDisplay::default();
15000        process_actor_event(
15001            invalid,
15002            false,
15003            &mut handshake,
15004            None,
15005            None,
15006            &mut session,
15007            &event_tx,
15008            &mut display,
15009        )
15010        .await
15011        .unwrap();
15012        assert!(display.overflowed);
15013        assert!(event_rx.try_recv().is_err());
15014    }
15015
15016    #[tokio::test]
15017    async fn actor_download_event_batch_hides_output_and_preserves_read_output() {
15018        let session_id = "actor-download-batch";
15019        let private = "private-download-bytes-and-url";
15020        let events = vec![
15021            AgentEvent::ToolStart {
15022                tool_call_id: "download-call".into(),
15023                tool_name: "default::browser".into(),
15024                arguments: serde_json::json!({
15025                    "action":"download", "selector": private, "expected_epoch": 17,
15026                }),
15027            },
15028            AgentEvent::ToolApprovalRequested {
15029                tool_call_id: "download-call".into(),
15030                tool_name: format!("{private}::browser"),
15031                parameters: serde_json::json!({
15032                    "action":"download", "selector": private, "expected_epoch": 17,
15033                }),
15034            },
15035            AgentEvent::ToolStart {
15036                tool_call_id: "read-call".into(),
15037                tool_name: "Read".into(),
15038                arguments: serde_json::json!({"file_path":"README.md"}),
15039            },
15040        ];
15041        let batch = ActorEventBatch {
15042            logical_session: Some(LogicalSessionIdentity {
15043                creation: None,
15044                session_id: session_id.into(),
15045                parent_session_id: Some("permission-parent".into()),
15046                root_session_id: session_id.into(),
15047            }),
15048            activation_id: None,
15049            execution_epoch: 0,
15050            source_node_id: None,
15051            source_actor_id: Some(session_id.into()),
15052            first_seq: 1,
15053            last_seq: events.len() as u64,
15054            qos: bamboo_subagent::ActorEventQos::Durable,
15055            events: events
15056                .into_iter()
15057                .map(|event| serde_json::to_value(event).unwrap())
15058                .collect(),
15059        };
15060        let result = bamboo_agent_core::tools::ToolResult {
15061            success: true,
15062            result: format!("{{\"data_base64\":\"{private}\"}}"),
15063            display_preference: Some(private.into()),
15064            images: vec![bamboo_agent_core::tools::ToolResultImage {
15065                mime_type: "image/png".into(),
15066                data: private.into(),
15067            }],
15068        };
15069        let (outcome, _, forwarded, _) = drive_permission_handshake_frames(
15070            session_id,
15071            [
15072                permission_posture_frame(session_id, 7),
15073                ChildFrame::EventBatch { batch },
15074                actor_event_frame(AgentEvent::ToolToken {
15075                    tool_call_id: "download-call".into(),
15076                    content: private.into(),
15077                }),
15078                actor_event_frame(AgentEvent::ToolToken {
15079                    tool_call_id: "read-call".into(),
15080                    content: "public live output".into(),
15081                }),
15082                actor_event_frame(AgentEvent::ToolComplete {
15083                    tool_call_id: "download-call".into(),
15084                    result,
15085                }),
15086                actor_event_frame(AgentEvent::ToolLifecycle {
15087                    tool_call_id: "download-call".into(),
15088                    tool_name: "browser".into(),
15089                    phase: "finished".into(),
15090                    elapsed_ms: Some(1),
15091                    is_mutating: false,
15092                    auto_approved: false,
15093                    summary: Some(private.into()),
15094                    error: Some(private.into()),
15095                }),
15096                actor_event_frame(AgentEvent::ToolError {
15097                    tool_call_id: "download-call".into(),
15098                    error: private.into(),
15099                }),
15100                actor_event_frame(AgentEvent::ToolComplete {
15101                    tool_call_id: "read-call".into(),
15102                    result: bamboo_agent_core::tools::ToolResult::text(true, "public README text"),
15103                }),
15104                completed_actor_frame(),
15105            ],
15106            expected_default_permission_posture(7),
15107        )
15108        .await;
15109        assert_eq!(outcome.unwrap().as_deref(), Some("done"));
15110        let wire = serde_json::to_string(&forwarded).unwrap();
15111        assert!(
15112            !wire.contains(private),
15113            "private content reached actor SSE: {wire}"
15114        );
15115        assert!(wire.contains("public README text"));
15116        assert!(wire.contains("public live output"));
15117        assert!(forwarded.iter().any(|event| matches!(event,
15118            AgentEvent::ToolComplete { tool_call_id, result }
15119                if tool_call_id == "download-call"
15120                && result.result == "Browser tool result hidden"
15121                && result.images.is_empty() && result.display_preference.is_none()
15122        )));
15123    }
15124
15125    #[tokio::test]
15126    async fn actor_missing_reused_and_nested_tool_identity_fails_closed() {
15127        let private = "private-download-content";
15128        let nested = |event| AgentEvent::SubAgentEvent {
15129            parent_session_id: "parent".into(),
15130            child_session_id: "nested-child".into(),
15131            event: Box::new(event),
15132        };
15133        let (outcome, _, forwarded, _) = drive_permission_handshake_frames(
15134            "actor-reorder",
15135            [
15136                permission_posture_frame("actor-reorder", 7),
15137                actor_event_frame(AgentEvent::ToolComplete {
15138                    tool_call_id: "missing".into(),
15139                    result: bamboo_agent_core::tools::ToolResult::text(true, private),
15140                }),
15141                actor_event_frame(AgentEvent::ToolLifecycle {
15142                    tool_call_id: "missing".into(),
15143                    tool_name: "browser".into(),
15144                    phase: private.into(),
15145                    elapsed_ms: None,
15146                    is_mutating: false,
15147                    auto_approved: false,
15148                    summary: Some(private.into()),
15149                    error: Some(private.into()),
15150                }),
15151                actor_event_frame(AgentEvent::ToolApprovalRequested {
15152                    tool_call_id: "approval-only".into(),
15153                    tool_name: "browser".into(),
15154                    parameters: serde_json::json!({"action":"download","selector":private}),
15155                }),
15156                actor_event_frame(AgentEvent::ToolToken {
15157                    tool_call_id: "approval-only".into(),
15158                    content: private.into(),
15159                }),
15160                actor_event_frame(AgentEvent::ToolStart {
15161                    tool_call_id: "malformed-browser".into(),
15162                    tool_name: format!("{private}::browser"),
15163                    arguments: serde_json::json!({"selector":private}),
15164                }),
15165                actor_event_frame(AgentEvent::ToolComplete {
15166                    tool_call_id: "malformed-browser".into(),
15167                    result: bamboo_agent_core::tools::ToolResult::text(true, private),
15168                }),
15169                actor_event_frame(AgentEvent::ToolStart {
15170                    tool_call_id: "reused".into(),
15171                    tool_name: "Read".into(),
15172                    arguments: serde_json::json!({"file_path":"README.md"}),
15173                }),
15174                actor_event_frame(AgentEvent::ToolStart {
15175                    tool_call_id: "reused".into(),
15176                    tool_name: "browser".into(),
15177                    arguments: serde_json::json!({"action":"download","selector":private}),
15178                }),
15179                actor_event_frame(AgentEvent::ToolToken {
15180                    tool_call_id: "reused".into(),
15181                    content: private.into(),
15182                }),
15183                actor_event_frame(nested(AgentEvent::ToolStart {
15184                    tool_call_id: "nested".into(),
15185                    tool_name: "browser".into(),
15186                    arguments: serde_json::json!({"action":"download","selector":private}),
15187                })),
15188                actor_event_frame(nested(AgentEvent::ToolComplete {
15189                    tool_call_id: "nested".into(),
15190                    result: bamboo_agent_core::tools::ToolResult::text(true, private),
15191                })),
15192                actor_event_frame(AgentEvent::ChildApprovalRequested {
15193                    child_session_id: "nested-child".into(),
15194                    request_id: "approval".into(),
15195                    tool_name: format!("{private}::browser"),
15196                    permission: private.into(),
15197                    resource: private.into(),
15198                }),
15199                actor_event_frame(AgentEvent::ChildApprovalChanged {
15200                    parent_session_id: "parent".into(),
15201                    child_session_id: "nested-child".into(),
15202                    child_attempt: 1,
15203                    request_id: "approval".into(),
15204                    version: 1,
15205                    status: "pending".into(),
15206                    reason: Some(private.into()),
15207                    tool_name: "browser".into(),
15208                    permission: private.into(),
15209                    resource: private.into(),
15210                    created_at: "2026-01-01T00:00:00Z".into(),
15211                    resolved_at: None,
15212                }),
15213                completed_actor_frame(),
15214            ],
15215            expected_default_permission_posture(7),
15216        )
15217        .await;
15218        assert_eq!(outcome.unwrap().as_deref(), Some("done"));
15219        let wire = serde_json::to_string(&forwarded).unwrap();
15220        assert!(
15221            !wire.contains(private),
15222            "private content reached actor SSE: {wire}"
15223        );
15224        assert!(wire.contains("Tool result hidden"));
15225        assert!(wire.contains("Browser tool result hidden"));
15226        assert!(wire.contains("Browser interaction approval"));
15227        assert!(!forwarded
15228            .iter()
15229            .any(|event| matches!(event, AgentEvent::ChildApprovalChanged { .. })));
15230    }
15231
15232    #[test]
15233    fn actor_display_tracking_overflow_never_reclassifies_old_private_id() {
15234        let mut display = ActorEventDisplay::default();
15235        display
15236            .project(
15237                AgentEvent::ToolStart {
15238                    tool_call_id: "private-call".into(),
15239                    tool_name: "browser".into(),
15240                    arguments: serde_json::json!({"action":"download","selector":"private"}),
15241                },
15242                0,
15243            )
15244            .unwrap();
15245        for index in 0..MAX_DISPLAY_CALLS {
15246            display
15247                .project(
15248                    AgentEvent::ToolStart {
15249                        tool_call_id: format!("ordinary-{index}"),
15250                        tool_name: "Read".into(),
15251                        arguments: serde_json::json!({"file_path":"README.md"}),
15252                    },
15253                    0,
15254                )
15255                .unwrap();
15256        }
15257        assert!(display.overflowed);
15258        let forwarded = display
15259            .project(
15260                AgentEvent::ToolComplete {
15261                    tool_call_id: "private-call".into(),
15262                    result: bamboo_agent_core::tools::ToolResult::text(true, "private-bytes"),
15263                },
15264                0,
15265            )
15266            .unwrap();
15267        assert!(!serde_json::to_string(&forwarded)
15268            .unwrap()
15269            .contains("private-bytes"));
15270    }
15271
15272    #[test]
15273    fn nested_actor_display_uses_one_identity_budget_and_bounded_keys() {
15274        let mut display = ActorEventDisplay::default();
15275        let nested = |index: usize| AgentEvent::SubAgentEvent {
15276            parent_session_id: "parent".into(),
15277            child_session_id: format!("child-{index}"),
15278            event: Box::new(AgentEvent::ToolStart {
15279                tool_call_id: format!("read-{index}"),
15280                tool_name: "Read".into(),
15281                arguments: serde_json::json!({"file_path":"README.md"}),
15282            }),
15283        };
15284        for index in 0..(MAX_DISPLAY_CALLS / 2) {
15285            assert!(display.project(nested(index), 0).is_some());
15286        }
15287        assert_eq!(
15288            display.identities.load(Ordering::Relaxed),
15289            MAX_DISPLAY_CALLS
15290        );
15291        assert!(display.project(nested(MAX_DISPLAY_CALLS / 2), 0).is_none());
15292        assert!(display.overflowed);
15293        assert!(display.children.is_empty());
15294
15295        let mut oversized = ActorEventDisplay::default();
15296        assert!(oversized
15297            .project(
15298                AgentEvent::SubAgentEvent {
15299                    parent_session_id: "parent".into(),
15300                    child_session_id: "x".repeat(MAX_DISPLAY_ID_BYTES + 1),
15301                    event: Box::new(AgentEvent::ToolComplete {
15302                        tool_call_id: "call".into(),
15303                        result: bamboo_agent_core::tools::ToolResult::text(
15304                            true,
15305                            "private-download-bytes",
15306                        ),
15307                    }),
15308                },
15309                0,
15310            )
15311            .is_none());
15312        assert!(oversized.overflowed);
15313        assert!(oversized
15314            .project(
15315                AgentEvent::ToolStart {
15316                    tool_call_id: "x".repeat(MAX_DISPLAY_ID_BYTES + 1),
15317                    tool_name: "Read".into(),
15318                    arguments: serde_json::json!({"file_path":"README.md"}),
15319                },
15320                0,
15321            )
15322            .is_none());
15323    }
15324
15325    #[tokio::test]
15326    async fn actor_batch_sequence_gap_invalidates_prior_tool_identity() {
15327        let session_id = "actor-gap";
15328        let batch = |seq, event: AgentEvent| {
15329            let event = serde_json::to_value(event).unwrap();
15330            ChildFrame::EventBatch {
15331                batch: ActorEventBatch {
15332                    logical_session: Some(LogicalSessionIdentity {
15333                        creation: None,
15334                        session_id: session_id.into(),
15335                        parent_session_id: Some("permission-parent".into()),
15336                        root_session_id: session_id.into(),
15337                    }),
15338                    activation_id: None,
15339                    execution_epoch: 0,
15340                    source_node_id: None,
15341                    source_actor_id: Some(session_id.into()),
15342                    first_seq: seq,
15343                    last_seq: seq,
15344                    qos: bamboo_subagent::ActorEventQos::classify(&event),
15345                    events: vec![event],
15346                },
15347            }
15348        };
15349        let (outcome, _, forwarded, _) = drive_permission_handshake_frames(
15350            session_id,
15351            [
15352                permission_posture_frame(session_id, 7),
15353                batch(
15354                    1,
15355                    AgentEvent::ToolStart {
15356                        tool_call_id: "reused-call".into(),
15357                        tool_name: "Read".into(),
15358                        arguments: serde_json::json!({"file_path":"README.md"}),
15359                    },
15360                ),
15361                // Sequence 2 may have replaced this ID with a private browser
15362                // call. The actor's next result must not inherit Read display.
15363                batch(
15364                    3,
15365                    AgentEvent::ToolComplete {
15366                        tool_call_id: "reused-call".into(),
15367                        result: bamboo_agent_core::tools::ToolResult::text(
15368                            true,
15369                            "private-download-bytes",
15370                        ),
15371                    },
15372                ),
15373                completed_actor_frame(),
15374            ],
15375            expected_default_permission_posture(7),
15376        )
15377        .await;
15378        assert_eq!(outcome.unwrap().as_deref(), Some("done"));
15379        let wire = serde_json::to_string(&forwarded).unwrap();
15380        assert!(!wire.contains("private-download-bytes"), "{wire}");
15381        assert!(wire.contains("Tool result hidden"));
15382    }
15383
15384    #[tokio::test]
15385    async fn legacy_malformed_actor_event_invalidates_prior_tool_identity() {
15386        let (event_tx, mut event_rx) = mpsc::channel(8);
15387        let mut session = Session::new("legacy-malformed-private", "model");
15388        let mut handshake = PermissionPostureHandshake::NotRequired;
15389        let mut display = ActorEventDisplay::default();
15390        let read_start = serde_json::to_value(AgentEvent::ToolStart {
15391            tool_call_id: "reused-call".into(),
15392            tool_name: "Read".into(),
15393            arguments: serde_json::json!({"file_path":"README.md"}),
15394        })
15395        .unwrap();
15396        process_actor_event(
15397            read_start,
15398            false,
15399            &mut handshake,
15400            None,
15401            None,
15402            &mut session,
15403            &event_tx,
15404            &mut display,
15405        )
15406        .await
15407        .unwrap();
15408
15409        // A mixed-version actor may send a browser ToolStart whose name has
15410        // the wrong wire type. Serde drops it; the reused ID is no longer safe.
15411        let mut malformed = serde_json::to_value(AgentEvent::ToolStart {
15412            tool_call_id: "reused-call".into(),
15413            tool_name: "browser".into(),
15414            arguments: serde_json::json!({
15415                "action":"download", "selector":"private-download-selector"
15416            }),
15417        })
15418        .unwrap();
15419        malformed["tool_name"] = serde_json::json!(7);
15420        process_actor_event(
15421            malformed,
15422            false,
15423            &mut handshake,
15424            None,
15425            None,
15426            &mut session,
15427            &event_tx,
15428            &mut display,
15429        )
15430        .await
15431        .unwrap();
15432        process_actor_event(
15433            serde_json::to_value(AgentEvent::ToolComplete {
15434                tool_call_id: "reused-call".into(),
15435                result: bamboo_agent_core::tools::ToolResult::text(true, "private-download-bytes"),
15436            })
15437            .unwrap(),
15438            false,
15439            &mut handshake,
15440            None,
15441            None,
15442            &mut session,
15443            &event_tx,
15444            &mut display,
15445        )
15446        .await
15447        .unwrap();
15448        let forwarded = std::iter::from_fn(|| event_rx.try_recv().ok()).collect::<Vec<_>>();
15449        assert_eq!(forwarded.len(), 2);
15450        let wire = serde_json::to_string(&forwarded).unwrap();
15451        assert!(!wire.contains("private-download"), "{wire}");
15452        assert!(wire.contains("Tool result hidden"));
15453    }
15454
15455    #[tokio::test]
15456    async fn actor_initial_batch_acks_in_order_and_rejects_stale_confirmation() {
15457        let temp = tempfile::tempdir().unwrap();
15458        let store = Arc::new(
15459            bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
15460                .await
15461                .unwrap(),
15462        );
15463        let storage: Arc<dyn Storage> = store.clone();
15464        let locked = Arc::new(bamboo_storage::LockedSessionStore::new(storage.clone()));
15465        let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
15466            store.clone(),
15467            bamboo_domain::SessionInboxLimits::default(),
15468        ));
15469        let session_id = "actor-confirmation-order";
15470        let run_id = "actor-run-current";
15471        let mut session = Session::new(session_id, "model");
15472        store.save_session(&session).await.unwrap();
15473        for (id, text) in [("actor-first", "first"), ("actor-second", "second")] {
15474            let mut envelope = bamboo_domain::SessionMessageEnvelope::user_input(session_id, text);
15475            envelope.id = bamboo_domain::SessionMessageId::parse(id).unwrap();
15476            inbox.deliver(&envelope).await.unwrap();
15477        }
15478        inbox
15479            .mark_activation_eligible(
15480                session_id,
15481                2,
15482                bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
15483            )
15484            .await
15485            .unwrap();
15486        let router = SessionActivationRouter::new();
15487        let mut owner_registration = router.register_run(session_id, run_id).await.unwrap();
15488        let binding = SessionInboxRuntimeBinding {
15489            router,
15490            inbox: inbox.clone(),
15491            storage,
15492            persistence: locked,
15493            parent_question_lock: None,
15494        };
15495        let pairs = claim_canonical_deliveries(&binding, &mut session, run_id, usize::MAX, None)
15496            .await
15497            .unwrap();
15498        assert_eq!(
15499            pairs
15500                .iter()
15501                .map(|(claim, _)| claim.envelope.id.as_str())
15502                .collect::<Vec<_>>(),
15503            vec!["actor-first", "actor-second"]
15504        );
15505        let seeded = store.load_session(session_id).await.unwrap().unwrap();
15506        assert_eq!(
15507            seeded
15508                .messages
15509                .iter()
15510                .filter(|message| matches!(message.id.as_str(), "actor-first" | "actor-second"))
15511                .map(|message| message.id.as_str())
15512                .collect::<Vec<_>>(),
15513            vec!["actor-first", "actor-second"],
15514            "host context must be durable before actor dispatch"
15515        );
15516        for (claim, _) in &pairs {
15517            assert_eq!(
15518                seeded
15519                    .messages
15520                    .iter()
15521                    .filter(|message| bamboo_domain::is_matching_session_message(
15522                        message,
15523                        &claim.envelope
15524                    ))
15525                    .count(),
15526                1,
15527                "pre-dispatch host checkpoint must contain exactly one canonical marker for {}",
15528                claim.envelope.id
15529            );
15530        }
15531        assert!(
15532            seeded.session_inbox_admission().is_none_or(|cursor| {
15533                !cursor.contains(&pairs[0].0.envelope.id)
15534                    && !cursor.contains(&pairs[1].0.envelope.id)
15535            }),
15536            "pre-dispatch transcript seeding must not forge worker confirmation"
15537        );
15538        assert_eq!(inbox.inspect(session_id).await.unwrap().claimed, 2);
15539        let claims = pairs
15540            .into_iter()
15541            .map(|(claim, _)| claim)
15542            .collect::<VecDeque<_>>();
15543        let first = claims[0].clone();
15544        let second = claims[1].clone();
15545        let mut stale = admission_confirmation(session_id, &first, "stale-run");
15546        stale.canonical_claim_generation = second.generation;
15547        let mut link = ConfirmationSequenceLink {
15548            frames: VecDeque::from([
15549                ChildFrame::SessionMessageAdmitted {
15550                    confirmation: stale,
15551                },
15552                ChildFrame::SessionMessageAdmitted {
15553                    confirmation: admission_confirmation(session_id, &first, run_id),
15554                },
15555                ChildFrame::SessionMessageAdmitted {
15556                    confirmation: admission_confirmation(session_id, &second, run_id),
15557                },
15558                ChildFrame::Terminal {
15559                    status: TerminalStatus::Completed,
15560                    result: Some("done".to_string()),
15561                    error: None,
15562                    final_event_watermark: None,
15563                    transcript: Vec::new(),
15564                },
15565            ]),
15566            sent: Vec::new(),
15567        };
15568        let (event_tx, _event_rx) = mpsc::channel(8);
15569        let cancel = CancellationToken::new();
15570        let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
15571        let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
15572        let result = drive(ActorDriveContext {
15573            expected_creation: None,
15574            client: &mut link,
15575            parent_session_id: "parent",
15576            child_session_id: session_id,
15577            child_attempt: 0,
15578            approval_registry: None,
15579            approval_decider: None,
15580            approval_reviewer: None,
15581            escalation_bridge: None,
15582            event_tx: &event_tx,
15583            cancel_token: &cancel,
15584            live_rx: &mut live_rx,
15585            delivery_rx: &mut delivery_rx,
15586            logical_session: &mut session,
15587            expected_permission_posture: None,
15588            session_inbox_runtime: Some(&binding),
15589            actor_directory_store: None,
15590            canonical_subagent_tool: None,
15591            ticket_service: None,
15592            activation_run_id: Some(run_id),
15593            execution_epoch: 0,
15594            expected_source_actor_id: session_id,
15595            initial_inflight_claims: claims,
15596            plain_actor: false,
15597            remote_environment_lease: false,
15598            readonly_output: None,
15599            local_history_tools: None,
15600            local_history_read_only: false,
15601            strict_history_output: None,
15602            plain_input: None,
15603            canonical_activation: None,
15604            canonical_placement_ref: None,
15605            actor_event_observer: None,
15606            plain_run: None,
15607            first_frame_timeout: Some(Duration::from_secs(1)),
15608        })
15609        .await
15610        .unwrap();
15611        assert_eq!(result.as_deref(), Some("done"));
15612        assert_eq!(
15613            session
15614                .messages
15615                .iter()
15616                .filter(|message| matches!(message.id.as_str(), "actor-first" | "actor-second"))
15617                .map(|message| message.id.as_str())
15618                .collect::<Vec<_>>(),
15619            vec!["actor-first", "actor-second"]
15620        );
15621        let backlog = inbox.inspect(session_id).await.unwrap();
15622        assert_eq!(backlog.pending + backlog.claimed, 0);
15623        let confirmed = store.load_session(session_id).await.unwrap().unwrap();
15624        let cursor = confirmed
15625            .session_inbox_admission()
15626            .expect("exact worker confirmation must checkpoint the admission cursor");
15627        assert!(cursor.contains(&first.envelope.id));
15628        assert!(cursor.contains(&second.envelope.id));
15629        for claim in [&first, &second] {
15630            assert_eq!(
15631                confirmed
15632                    .messages
15633                    .iter()
15634                    .filter(|message| bamboo_domain::is_matching_session_message(
15635                        message,
15636                        &claim.envelope
15637                    ))
15638                    .count(),
15639                1,
15640                "confirmation must retain one exact canonical marker for {}",
15641                claim.envelope.id
15642            );
15643        }
15644        assert!(inbox
15645            .was_admitted(session_id, &first.envelope.id)
15646            .await
15647            .unwrap());
15648        assert!(inbox
15649            .was_admitted(session_id, &second.envelope.id)
15650            .await
15651            .unwrap());
15652        owner_registration.begin_finalization().await;
15653        owner_registration.finish(2).await.unwrap();
15654    }
15655
15656    #[derive(Default)]
15657    struct RecordingCodexTokenAuthority {
15658        issued_for: std::sync::Mutex<Vec<String>>,
15659        revoked: std::sync::Mutex<Vec<String>>,
15660    }
15661
15662    impl CodexRunTokenAuthority for RecordingCodexTokenAuthority {
15663        fn issue(&self, session_id: &str) -> Result<IssuedCodexRunToken, String> {
15664            self.issued_for
15665                .lock()
15666                .expect("issued fixture lock")
15667                .push(session_id.to_string());
15668            Ok(IssuedCodexRunToken {
15669                token_id: format!("id-{session_id}"),
15670                token: format!("bcx1_secret-{session_id}"),
15671            })
15672        }
15673
15674        fn revoke(&self, token_id: &str) {
15675            self.revoked
15676                .lock()
15677                .expect("revoked fixture lock")
15678                .push(token_id.to_string());
15679        }
15680    }
15681
15682    fn codex_executor(auth_mode: Option<&str>, inherit_user_config: Option<bool>) -> ExecutorSpec {
15683        let bamboo_mode = auth_mode == Some("bamboo")
15684            || (auth_mode.is_none() && !inherit_user_config.unwrap_or(false));
15685        ExecutorSpec::Codex {
15686            binary: None,
15687            model: None,
15688            mode: None,
15689            sandbox: None,
15690            inherit_user_config,
15691            auth_mode: auth_mode.map(str::to_string),
15692            base_url: bamboo_mode.then(|| "http://127.0.0.1:9562/openai/v1".to_string()),
15693            wire_api: Some("responses".to_string()),
15694            provider_key_ref: None,
15695            forward_env: None,
15696            approval_policy: None,
15697            network_access: None,
15698            allow_danger_bypass: None,
15699            permission_profile: None,
15700            workspace_owned: None,
15701        }
15702    }
15703
15704    fn permission_resolution(
15705        requested: bamboo_domain::SessionPermissionMode,
15706        effective: bamboo_domain::PermissionMode,
15707    ) -> bamboo_domain::PermissionModeResolution {
15708        bamboo_domain::PermissionModeResolution {
15709            requested,
15710            effective,
15711        }
15712    }
15713
15714    #[test]
15715    fn permission_posture_mapping_contract_is_exact_for_supported_executors() {
15716        use bamboo_domain::{PermissionMode, SessionPermissionMode};
15717
15718        let default =
15719            permission_resolution(SessionPermissionMode::Default, PermissionMode::Default);
15720        assert_eq!(
15721            expected_permission_executor_mapping(&ExecutorSpec::BambooRuntime, default, false)
15722                .unwrap()
15723                .as_deref(),
15724            Some("bamboo_runtime:default")
15725        );
15726        assert_eq!(
15727            expected_permission_executor_mapping(&ExecutorSpec::Echo, default, false).unwrap(),
15728            None,
15729            "transport-only Echo must not claim the typed permission contract"
15730        );
15731        assert_eq!(
15732            expected_permission_executor_mapping(
15733                &ExecutorSpec::CliAdapter {
15734                    command: "must-not-appear-in-contract".to_string(),
15735                    args: vec!["credential-like-argument".to_string()],
15736                },
15737                default,
15738                false,
15739            )
15740            .unwrap(),
15741            None,
15742            "unimplemented CliAdapter must not leak command data into a contract"
15743        );
15744
15745        let claude = ExecutorSpec::ClaudeCode {
15746            binary: None,
15747            model: None,
15748            permission_mode: Some("default".to_string()),
15749            inherit_user_config: None,
15750            forward_env: None,
15751        };
15752        for (resolution, mapping) in [
15753            (
15754                permission_resolution(SessionPermissionMode::Default, PermissionMode::Plan),
15755                "claude_code:permission_mode=plan",
15756            ),
15757            (
15758                permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
15759                "claude_code:permission_mode=bypassPermissions",
15760            ),
15761            (
15762                permission_resolution(SessionPermissionMode::Default, PermissionMode::AcceptEdits),
15763                "claude_code:permission_mode=acceptEdits",
15764            ),
15765            (
15766                permission_resolution(SessionPermissionMode::Default, PermissionMode::DontAsk),
15767                "claude_code:permission_mode=dontAsk",
15768            ),
15769            (
15770                permission_resolution(
15771                    SessionPermissionMode::Bypass,
15772                    PermissionMode::BypassPermissions,
15773                ),
15774                "claude_code:permission_mode=default",
15775            ),
15776        ] {
15777            assert_eq!(
15778                expected_permission_executor_mapping(&claude, resolution, false)
15779                    .unwrap()
15780                    .as_deref(),
15781                Some(mapping)
15782            );
15783        }
15784        assert_eq!(
15785            expected_permission_executor_mapping(&claude, default, true)
15786                .unwrap()
15787                .as_deref(),
15788            Some("claude_code:blocked_explicit_deny")
15789        );
15790
15791        let mut codex_exec = codex_executor(Some("inherit"), Some(true));
15792        if let ExecutorSpec::Codex {
15793            approval_policy, ..
15794        } = &mut codex_exec
15795        {
15796            *approval_policy = Some("on-failure".to_string());
15797        }
15798        assert_eq!(
15799            expected_permission_executor_mapping(&codex_exec, default, false)
15800                .unwrap()
15801                .as_deref(),
15802            Some("codex_exec:approval_policy=on-failure")
15803        );
15804        assert_eq!(
15805            expected_permission_executor_mapping(
15806                &codex_exec,
15807                permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
15808                false,
15809            )
15810            .unwrap()
15811            .as_deref(),
15812            Some("codex_exec:approval_policy=never")
15813        );
15814        assert_eq!(
15815            expected_permission_executor_mapping(&codex_exec, default, true)
15816                .unwrap()
15817                .as_deref(),
15818            Some("codex_exec:blocked_explicit_deny")
15819        );
15820
15821        let mut codex_app_server = codex_executor(Some("inherit"), Some(true));
15822        if let ExecutorSpec::Codex {
15823            mode,
15824            approval_policy,
15825            ..
15826        } = &mut codex_app_server
15827        {
15828            *mode = Some("app_server".to_string());
15829            *approval_policy = Some("on-request".to_string());
15830        }
15831        assert_eq!(
15832            expected_permission_executor_mapping(&codex_app_server, default, false)
15833                .unwrap()
15834                .as_deref(),
15835            Some("codex_app_server:approvalPolicy=on-request")
15836        );
15837        assert_eq!(
15838            expected_permission_executor_mapping(
15839                &codex_app_server,
15840                permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
15841                false,
15842            )
15843            .unwrap()
15844            .as_deref(),
15845            Some("codex_app_server:approvalPolicy=never")
15846        );
15847        assert_eq!(
15848            expected_permission_executor_mapping(&codex_app_server, default, true)
15849                .unwrap()
15850                .as_deref(),
15851            Some("codex_app_server:blocked_explicit_deny")
15852        );
15853    }
15854
15855    #[test]
15856    fn only_bamboo_managed_non_git_workspaces_are_marked_owned() {
15857        let project = tempfile::tempdir().unwrap();
15858        let managed = project.path().join(".bamboo/worktree/child-571");
15859        std::fs::create_dir_all(&managed).unwrap();
15860        assert!(!workspace_is_bamboo_owned(managed.to_str().unwrap()));
15861        let marker = project
15862            .path()
15863            .join(".bamboo/worktree/.bamboo-owned/child-571");
15864        std::fs::create_dir_all(marker.parent().unwrap()).unwrap();
15865        std::fs::write(&marker, "bamboo/child-571").unwrap();
15866        assert!(workspace_is_bamboo_owned(managed.to_str().unwrap()));
15867        let nested = managed.join("nested/path");
15868        std::fs::create_dir_all(&nested).unwrap();
15869        assert!(workspace_is_bamboo_owned(nested.to_str().unwrap()));
15870
15871        let arbitrary = tempfile::tempdir().unwrap();
15872        assert!(!workspace_is_bamboo_owned(
15873            arbitrary.path().to_str().unwrap()
15874        ));
15875    }
15876
15877    #[test]
15878    fn bamboo_codex_token_is_per_run_redacted_and_revoked_on_guard_drop() {
15879        let authority = Arc::new(RecordingCodexTokenAuthority::default());
15880        let authority_dyn: Arc<dyn CodexRunTokenAuthority> = authority.clone();
15881
15882        let (secrets, guard) = build_codex_run_secrets(
15883            &codex_executor(Some("bamboo"), None),
15884            Some(authority_dyn),
15885            "child-570",
15886        )
15887        .unwrap();
15888
15889        let token = secrets
15890            .codex_provider_token
15891            .as_ref()
15892            .expect("bamboo mode mints a token");
15893        assert_eq!(token.expose(), "bcx1_secret-child-570");
15894        assert!(!format!("{token:?}").contains("secret-child-570"));
15895        assert_eq!(
15896            authority.issued_for.lock().unwrap().as_slice(),
15897            ["child-570"]
15898        );
15899        assert!(authority.revoked.lock().unwrap().is_empty());
15900
15901        drop(guard);
15902        assert_eq!(
15903            authority.revoked.lock().unwrap().as_slice(),
15904            ["id-child-570"]
15905        );
15906    }
15907
15908    #[test]
15909    fn non_bamboo_codex_never_mints_and_bamboo_fails_closed_without_authority() {
15910        let authority = Arc::new(RecordingCodexTokenAuthority::default());
15911        let authority_dyn: Arc<dyn CodexRunTokenAuthority> = authority.clone();
15912        let (secrets, guard) = build_codex_run_secrets(
15913            &codex_executor(Some("custom"), None),
15914            Some(authority_dyn),
15915            "child-custom",
15916        )
15917        .unwrap();
15918        assert!(secrets.codex_provider_token.is_none());
15919        assert!(guard.is_none());
15920        assert!(authority.issued_for.lock().unwrap().is_empty());
15921
15922        let error = build_codex_run_secrets(
15923            &codex_executor(Some("bamboo"), None),
15924            None,
15925            "child-no-authority",
15926        )
15927        .err()
15928        .expect("bamboo mode without an authority must fail closed");
15929        assert!(error.to_string().contains("per-run token authority"));
15930    }
15931
15932    #[test]
15933    fn codex_provisioning_never_leaks_the_session_provider_credential() {
15934        let credentials = vec![ScopedCredential {
15935            provider: "openai".to_string(),
15936            api_key: "upstream-secret-must-not-cross".to_string(),
15937            base_url: None,
15938            provider_type: Some("openai".to_string()),
15939            credential_ref: Some("provider.openai.api_key".to_string()),
15940        }];
15941
15942        for (mode, label) in [
15943            (Some("inherit"), "inherit"),
15944            (Some("api_key"), "api_key"),
15945            (Some("bamboo"), "bamboo"),
15946            (None, "default-bamboo"),
15947        ] {
15948            let runner = ActorChildRunner::new(
15949                format!("codex-{label}-test"),
15950                PathBuf::from("/bin/false"),
15951                Vec::new(),
15952                std::env::temp_dir().join(format!("bamboo-codex-{label}-570")),
15953                codex_executor(mode, None),
15954                credentials.clone(),
15955                "openai".to_string(),
15956                1,
15957            );
15958            let mut session = Session::new(format!("child-{label}"), "model");
15959            session.add_message(bamboo_agent_core::Message::user("test"));
15960            let spec = runner.build_spec(
15961                &session,
15962                &crate::runtime::execution::SpawnJob {
15963                    parent_session_id: "parent".to_string(),
15964                    child_session_id: format!("child-{label}"),
15965                    model: "gpt-5.4".to_string(),
15966                    disabled_tools: None,
15967                },
15968            );
15969            assert!(
15970                spec.secrets.provider_credentials.is_empty(),
15971                "{label} Codex must not receive the session provider key"
15972            );
15973        }
15974    }
15975
15976    #[test]
15977    fn non_codex_provisioning_still_receives_only_its_selected_provider_credential() {
15978        let credentials = vec![
15979            ScopedCredential {
15980                provider: "openai".to_string(),
15981                api_key: "selected-openai-secret".to_string(),
15982                base_url: None,
15983                provider_type: Some("openai".to_string()),
15984                credential_ref: Some("provider.openai.api_key".to_string()),
15985            },
15986            ScopedCredential {
15987                provider: "other".to_string(),
15988                api_key: "unrelated-secret".to_string(),
15989                base_url: None,
15990                provider_type: Some("openai".to_string()),
15991                credential_ref: Some("provider.other.api_key".to_string()),
15992            },
15993        ];
15994        let runner = ActorChildRunner::new(
15995            "echo-test".to_string(),
15996            PathBuf::from("/bin/false"),
15997            Vec::new(),
15998            std::env::temp_dir().join("bamboo-echo-provider-570"),
15999            ExecutorSpec::Echo,
16000            credentials,
16001            "openai".to_string(),
16002            1,
16003        );
16004        let spec = runner.build_spec(
16005            &Session::new("child-echo", "model"),
16006            &crate::runtime::execution::SpawnJob {
16007                parent_session_id: "parent".to_string(),
16008                child_session_id: "child-echo".to_string(),
16009                model: "gpt-5.4".to_string(),
16010                disabled_tools: None,
16011            },
16012        );
16013
16014        assert_eq!(spec.secrets.provider_credentials.len(), 1);
16015        assert_eq!(
16016            spec.secrets.provider_credentials[0].api_key,
16017            "selected-openai-secret"
16018        );
16019    }
16020
16021    #[tokio::test]
16022    async fn live_provider_config_refreshes_credentials_and_default_per_activation() {
16023        fn provider_instance(
16024            provider_type: &str,
16025            api_key: &str,
16026        ) -> bamboo_config::ProviderInstanceConfig {
16027            bamboo_config::ProviderInstanceConfig {
16028                provider_type: provider_type.to_string(),
16029                label: None,
16030                api_key: api_key.to_string(),
16031                api_key_encrypted: None,
16032                credential_ref: None,
16033                base_url: None,
16034                model: None,
16035                fast_model: None,
16036                vision_model: None,
16037                reasoning_effort: None,
16038                responses_only_models: Vec::new(),
16039                request_overrides: None,
16040                enabled: true,
16041                extra: Default::default(),
16042            }
16043        }
16044
16045        let mut initial = bamboo_llm::Config::default();
16046        initial.provider_instances.insert(
16047            "old-provider".to_string(),
16048            provider_instance("openai", "old-key"),
16049        );
16050        initial.default_provider_instance = Some("old-provider".to_string());
16051        let live = Arc::new(tokio::sync::RwLock::new(initial));
16052
16053        let runner = ActorChildRunner::new(
16054            "echo-live-config-test".to_string(),
16055            PathBuf::from("/bin/false"),
16056            Vec::new(),
16057            std::env::temp_dir().join("bamboo-echo-live-config"),
16058            ExecutorSpec::Echo,
16059            Vec::new(),
16060            "stale-provider".to_string(),
16061            1,
16062        )
16063        .with_live_provider_config(live.clone());
16064        let job = crate::runtime::execution::SpawnJob {
16065            parent_session_id: "parent".to_string(),
16066            child_session_id: "child".to_string(),
16067            model: "model".to_string(),
16068            disabled_tools: None,
16069        };
16070
16071        let first = runner
16072            .build_live_spec(&Session::new("child", "model"), &job)
16073            .await;
16074        assert_eq!(
16075            first.model.as_ref().map(|model| model.provider.as_str()),
16076            Some("old-provider")
16077        );
16078        assert_eq!(first.secrets.provider_credentials.len(), 1);
16079        assert_eq!(first.secrets.provider_credentials[0].api_key, "old-key");
16080
16081        let mut reloaded = bamboo_llm::Config::default();
16082        reloaded.provider_instances.insert(
16083            "new-provider".to_string(),
16084            provider_instance("openai", "new-key"),
16085        );
16086        reloaded.default_provider_instance = Some("new-provider".to_string());
16087        *live.write().await = reloaded;
16088
16089        let second = runner
16090            .build_live_spec(&Session::new("child", "model"), &job)
16091            .await;
16092        assert_eq!(
16093            second.model.as_ref().map(|model| model.provider.as_str()),
16094            Some("new-provider")
16095        );
16096        assert_eq!(second.secrets.provider_credentials.len(), 1);
16097        assert_eq!(second.secrets.provider_credentials[0].api_key, "new-key");
16098    }
16099
16100    #[test]
16101    fn custom_codex_provisioning_scopes_only_the_referenced_credential() {
16102        let mut executor = codex_executor(Some("custom"), None);
16103        if let ExecutorSpec::Codex {
16104            base_url,
16105            provider_key_ref,
16106            ..
16107        } = &mut executor
16108        {
16109            *base_url = Some("https://provider.example/v1".to_string());
16110            *provider_key_ref = Some("provider.custom.api_key".to_string());
16111        }
16112        let credentials = vec![
16113            ScopedCredential {
16114                provider: "openai".to_string(),
16115                api_key: "session-provider-secret".to_string(),
16116                base_url: None,
16117                provider_type: Some("openai".to_string()),
16118                credential_ref: Some("provider.openai.api_key".to_string()),
16119            },
16120            ScopedCredential {
16121                provider: "custom".to_string(),
16122                api_key: "selected-secret".to_string(),
16123                base_url: None,
16124                provider_type: Some("openai".to_string()),
16125                credential_ref: Some("provider.custom.api_key".to_string()),
16126            },
16127            ScopedCredential {
16128                provider: "other".to_string(),
16129                api_key: "unrelated-secret".to_string(),
16130                base_url: None,
16131                provider_type: Some("openai".to_string()),
16132                credential_ref: Some("provider.other.api_key".to_string()),
16133            },
16134        ];
16135        let runner = ActorChildRunner::new(
16136            "codex-test".to_string(),
16137            PathBuf::from("/bin/false"),
16138            Vec::new(),
16139            std::env::temp_dir().join("bamboo-codex-570"),
16140            executor,
16141            credentials,
16142            "openai".to_string(),
16143            1,
16144        );
16145        let mut session = Session::new("child-custom", "model");
16146        session.add_message(bamboo_agent_core::Message::user("test"));
16147        let spec = runner.build_spec(
16148            &session,
16149            &crate::runtime::execution::SpawnJob {
16150                parent_session_id: "parent".to_string(),
16151                child_session_id: "child-custom".to_string(),
16152                model: "gpt-5.4".to_string(),
16153                disabled_tools: None,
16154            },
16155        );
16156
16157        assert_eq!(spec.secrets.provider_credentials.len(), 1);
16158        assert_eq!(
16159            spec.secrets.provider_credentials[0]
16160                .credential_ref
16161                .as_deref(),
16162            Some("provider.custom.api_key")
16163        );
16164        assert_eq!(
16165            spec.secrets.provider_credentials[0].api_key,
16166            "selected-secret"
16167        );
16168    }
16169
16170    fn spec_with(
16171        role: &str,
16172        provider: &str,
16173        model: &str,
16174        workspace: Option<&str>,
16175        disabled: Option<Vec<&str>>,
16176    ) -> ProvisionSpec {
16177        let mut spec = ProvisionSpec::new(
16178            ChildIdentity {
16179                child_id: "c".into(),
16180                parent_id: None,
16181                project_key: None,
16182                role: role.into(),
16183                depth: 0,
16184            },
16185            ExecutorSpec::Echo,
16186            "/tmp/fab".into(),
16187        );
16188        spec.workspace = workspace.map(|w| w.to_string());
16189        spec.model = Some(ModelRefSpec {
16190            provider: provider.into(),
16191            model: model.into(),
16192        });
16193        spec.disabled_tools = disabled.map(|d| d.into_iter().map(String::from).collect());
16194        spec
16195    }
16196
16197    #[test]
16198    fn fingerprint_matches_interchangeable_children() {
16199        // Same role/provider/model/workspace and equal tool sets (order-insensitive)
16200        // are interchangeable on one warm worker — and differ only in child_id.
16201        let a = spec_with(
16202            "explorer",
16203            "p",
16204            "m",
16205            Some("/ws"),
16206            Some(vec!["Bash", "Edit"]),
16207        );
16208        let mut b = spec_with(
16209            "explorer",
16210            "p",
16211            "m",
16212            Some("/ws"),
16213            Some(vec!["Edit", "Bash"]),
16214        );
16215        b.identity.child_id = "other".into();
16216        assert_eq!(
16217            ActorChildRunner::fingerprint(&a),
16218            ActorChildRunner::fingerprint(&b)
16219        );
16220    }
16221
16222    #[test]
16223    fn logical_identity_is_invariant_across_local_remote_scheduled_and_warm_reuse() {
16224        let mut session =
16225            Session::new_child("logical-child-681", "logical-parent-681", "model", "child");
16226        session.root_session_id = "logical-root-681".to_string();
16227        let job = SpawnJob {
16228            parent_session_id: "logical-parent-681".to_string(),
16229            child_session_id: "logical-child-681".to_string(),
16230            model: "model".to_string(),
16231            disabled_tools: None,
16232        };
16233        let expected = LogicalSessionIdentity {
16234            creation: None,
16235            session_id: "logical-child-681".to_string(),
16236            parent_session_id: Some("logical-parent-681".to_string()),
16237            root_session_id: "logical-root-681".to_string(),
16238        };
16239
16240        let placements_and_transport_ids = [
16241            (Placement::Local, "local-mailbox-first"),
16242            (
16243                Placement::Remote {
16244                    endpoint: "wss://remote.example/actor".to_string(),
16245                },
16246                "remote-process-44",
16247            ),
16248            (
16249                Placement::Schedulable {
16250                    pool: "gpu-pool".to_string(),
16251                },
16252                "scheduled-mailbox-9",
16253            ),
16254            // Same logical child reactivated on a different pooled mailbox.
16255            (Placement::Local, "warm-mailbox-reused-77"),
16256        ];
16257        for (placement, transport_id) in placements_and_transport_ids {
16258            let mut provision = spec_with("worker", "provider", "model", None, None);
16259            provision.placement = placement;
16260            provision.identity.child_id = transport_id.to_string();
16261            assert_eq!(logical_identity_for_actor_run(&session, &job), expected);
16262            assert_ne!(
16263                provision.identity.child_id, expected.session_id,
16264                "test fixture must prove transport identity is independent"
16265            );
16266        }
16267        let mut original = spec_with("worker", "provider", "model", Some("/ws"), None);
16268        original.executor = ExecutorSpec::BambooRuntime;
16269        original.identity.depth = session.spawn_depth;
16270        original.capabilities.required_child_context = true;
16271        original.capabilities.child_creation_identity = true;
16272        original.capabilities.enforce_permissions = true;
16273        original.capabilities.native_tool_ceiling_required = true;
16274        original.capabilities.native_tool_ceiling =
16275            Some(bamboo_subagent::proto::NativeToolCeiling {
16276                version: 1,
16277                child_session_id: session.id.clone(),
16278                parent_session_id: job.parent_session_id.clone(),
16279                root_session_id: session.root_session_id.clone(),
16280                created_at: session.created_at,
16281                spawn_depth: session.spawn_depth,
16282                project_id: None,
16283                tools: vec![],
16284            });
16285        original.validate().unwrap();
16286        let first = ActorChildRunner::fresh_required_worker_spec(&original);
16287        let second = ActorChildRunner::fresh_required_worker_spec(&original);
16288        assert_ne!(first.identity.child_id, second.identity.child_id);
16289        assert_ne!(first.identity.child_id, original.identity.child_id);
16290        for physical in [first, second] {
16291            physical.validate().unwrap();
16292            let mut actual = serde_json::to_value(&physical).unwrap();
16293            actual["identity"]["child_id"] = serde_json::json!(original.identity.child_id);
16294            assert_eq!(actual, serde_json::to_value(&original).unwrap());
16295            assert_eq!(logical_identity_for_actor_run(&session, &job), expected);
16296        }
16297    }
16298
16299    #[test]
16300    fn fingerprint_separates_distinct_runtimes() {
16301        let base = spec_with("explorer", "p", "m", Some("/ws"), None);
16302        let base_fp = ActorChildRunner::fingerprint(&base);
16303        // Each axis that is baked into the worker must split the pool bucket.
16304        assert_ne!(
16305            base_fp,
16306            ActorChildRunner::fingerprint(&spec_with("writer", "p", "m", Some("/ws"), None))
16307        );
16308        assert_ne!(
16309            base_fp,
16310            ActorChildRunner::fingerprint(&spec_with("explorer", "p2", "m", Some("/ws"), None))
16311        );
16312        assert_ne!(
16313            base_fp,
16314            ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m2", Some("/ws"), None))
16315        );
16316        assert_ne!(
16317            base_fp,
16318            ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m", Some("/ws2"), None))
16319        );
16320        assert_ne!(
16321            base_fp,
16322            ActorChildRunner::fingerprint(&spec_with(
16323                "explorer",
16324                "p",
16325                "m",
16326                Some("/ws"),
16327                Some(vec!["Bash"])
16328            ))
16329        );
16330    }
16331
16332    #[test]
16333    fn fingerprint_splits_rotated_provider_credentials_without_exposing_them() {
16334        let mut before = spec_with("explorer", "provider", "model", Some("/ws"), None);
16335        before.secrets.provider_credentials.push(ScopedCredential {
16336            provider: "provider".to_string(),
16337            api_key: "old-secret-key".to_string(),
16338            base_url: Some("https://provider.example/v1".to_string()),
16339            provider_type: Some("openai".to_string()),
16340            credential_ref: None,
16341        });
16342        let mut after = before.clone();
16343        after.secrets.provider_credentials[0].api_key = "new-secret-key".to_string();
16344
16345        let before_fingerprint = ActorChildRunner::fingerprint(&before);
16346        let after_fingerprint = ActorChildRunner::fingerprint(&after);
16347        assert_ne!(before_fingerprint, after_fingerprint);
16348        assert!(!before_fingerprint.contains("old-secret-key"));
16349        assert!(!after_fingerprint.contains("new-secret-key"));
16350    }
16351
16352    #[test]
16353    fn fingerprint_splits_on_baked_capabilities() {
16354        // Every capability baked once at provision time must split the pool
16355        // bucket, else a worker baked for one posture gets reused for another
16356        // (e.g. a depth-1 worker re-stamping spawn_depth onto a depth-4 child,
16357        // breaking the depth cap; or a bypass worker reused for a non-bypass one).
16358        let base_fp =
16359            ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m", Some("/ws"), None));
16360
16361        let mut creation = spec_with("explorer", "p", "m", Some("/ws"), None);
16362        creation.capabilities.child_creation_identity = true;
16363        let typed = ActorChildRunner::fingerprint(&creation);
16364        assert_ne!(
16365            base_fp, typed,
16366            "legacy worker pool cannot satisfy birth-required Run"
16367        );
16368        creation.identity.child_id = "different-logical-child".into();
16369        assert_eq!(
16370            typed,
16371            ActorChildRunner::fingerprint(&creation),
16372            "birth-required mode retains cross-Child pool reuse"
16373        );
16374
16375        let mut depth = spec_with("explorer", "p", "m", Some("/ws"), None);
16376        depth.identity.depth = 2;
16377        assert_ne!(
16378            base_fp,
16379            ActorChildRunner::fingerprint(&depth),
16380            "depth must split"
16381        );
16382
16383        let mut nested = spec_with("explorer", "p", "m", Some("/ws"), None);
16384        nested.capabilities.nested_spawn = true;
16385        assert_ne!(
16386            base_fp,
16387            ActorChildRunner::fingerprint(&nested),
16388            "nested_spawn must split"
16389        );
16390
16391        let mut bypass = spec_with("explorer", "p", "m", Some("/ws"), None);
16392        bypass.capabilities.bypass = true;
16393        assert_ne!(
16394            base_fp,
16395            ActorChildRunner::fingerprint(&bypass),
16396            "bypass must split"
16397        );
16398
16399        let mut auto = spec_with("explorer", "p", "m", Some("/ws"), None);
16400        auto.capabilities.auto_approve_permissions = true;
16401        assert_ne!(
16402            base_fp,
16403            ActorChildRunner::fingerprint(&auto),
16404            "auto_approve_permissions must split"
16405        );
16406
16407        let mut global_auto = spec_with("explorer", "p", "m", Some("/ws"), None);
16408        global_auto.capabilities.permission_requested_mode = "default".to_string();
16409        global_auto.capabilities.permission_effective_mode = "auto".to_string();
16410        global_auto.capabilities.auto_approve_permissions = true;
16411        let mut explicit_auto = global_auto.clone();
16412        explicit_auto.capabilities.permission_requested_mode = "auto".to_string();
16413        assert_ne!(
16414            ActorChildRunner::fingerprint(&global_auto),
16415            ActorChildRunner::fingerprint(&explicit_auto),
16416            "permission_requested_mode must split global and explicit Auto"
16417        );
16418
16419        let mut plan_overlay = explicit_auto.clone();
16420        plan_overlay.capabilities.permission_effective_mode = "plan".to_string();
16421        assert_ne!(
16422            ActorChildRunner::fingerprint(&explicit_auto),
16423            ActorChildRunner::fingerprint(&plan_overlay),
16424            "permission_effective_mode must split Plan overlay from Auto"
16425        );
16426
16427        let mut enforce = spec_with("explorer", "p", "m", Some("/ws"), None);
16428        enforce.capabilities.enforce_permissions = true;
16429        assert_ne!(
16430            base_fp,
16431            ActorChildRunner::fingerprint(&enforce),
16432            "enforce_permissions must split"
16433        );
16434
16435        let mut cap = spec_with("explorer", "p", "m", Some("/ws"), None);
16436        cap.capabilities.max_spawn_depth = Some(8);
16437        assert_ne!(
16438            base_fp,
16439            ActorChildRunner::fingerprint(&cap),
16440            "max_spawn_depth must split"
16441        );
16442
16443        // #73 (P1): the worker bakes `no_human_review` from this flag once at
16444        // build(), so it MUST split the pool or a worker baked for one approval
16445        // posture is reused for the opposite one.
16446        let mut nha = spec_with("explorer", "p", "m", Some("/ws"), None);
16447        nha.capabilities.no_human_approver = true;
16448        assert_ne!(
16449            base_fp,
16450            ActorChildRunner::fingerprint(&nha),
16451            "no_human_approver must split"
16452        );
16453
16454        // The generic read-only no-shell checker is baked once at build(), so a
16455        // planner/Guardian worker must not be reused for an ordinary child.
16456        let mut read_only = spec_with("explorer", "p", "m", Some("/ws"), None);
16457        read_only.capabilities.read_only = true;
16458        assert_ne!(
16459            base_fp,
16460            ActorChildRunner::fingerprint(&read_only),
16461            "read_only must split"
16462        );
16463
16464        // Preserve the legacy Guardian bit as its own rolling-upgrade axis.
16465        let mut gro = spec_with("explorer", "p", "m", Some("/ws"), None);
16466        gro.capabilities.guardian_read_only = true;
16467        assert_ne!(
16468            base_fp,
16469            ActorChildRunner::fingerprint(&gro),
16470            "guardian_read_only must split"
16471        );
16472    }
16473
16474    #[test]
16475    fn fingerprint_splits_codex_exec_and_app_server_workers() {
16476        let mut exec = spec_with("explorer", "p", "m", Some("/ws"), None);
16477        exec.executor = codex_executor(Some("inherit"), None);
16478        let mut app_server = exec.clone();
16479        if let ExecutorSpec::Codex { mode, .. } = &mut app_server.executor {
16480            *mode = Some("app_server".to_string());
16481        }
16482        assert_ne!(
16483            ActorChildRunner::fingerprint(&exec),
16484            ActorChildRunner::fingerprint(&app_server)
16485        );
16486    }
16487
16488    struct StaticDecider(bool);
16489
16490    #[async_trait]
16491    impl ChildApprovalDecider for StaticDecider {
16492        async fn decide(&self, _child: &str, _req: &serde_json::Value) -> bool {
16493            self.0
16494        }
16495    }
16496
16497    struct RecordingReviewer {
16498        reviewed: mpsc::UnboundedSender<(String, String, serde_json::Value)>,
16499    }
16500
16501    #[async_trait]
16502    impl ChildApprovalReviewer for RecordingReviewer {
16503        async fn review(&self, parent: &str, child: &str, request: &serde_json::Value) -> bool {
16504            let _ = self
16505                .reviewed
16506                .send((parent.to_string(), child.to_string(), request.clone()));
16507            true
16508        }
16509    }
16510
16511    // ---- first-frame watchdog (dead-pooled-worker recovery) -----------------
16512
16513    /// A link that never yields a frame — models a worker that died (or never
16514    /// subscribed) so its Run sits queued with no server.
16515    struct SilentLink;
16516    #[async_trait]
16517    impl bamboo_subagent::ChildLink for SilentLink {
16518        async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
16519            Ok(())
16520        }
16521        async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
16522            std::future::pending().await
16523        }
16524    }
16525
16526    /// A link that immediately yields one terminal frame (a healthy fast worker).
16527    struct InstantTerminalLink {
16528        done: bool,
16529    }
16530
16531    struct ApprovalRoundTripLink {
16532        step: u8,
16533        approval_reply: Option<(String, bool)>,
16534    }
16535
16536    #[async_trait]
16537    impl bamboo_subagent::ChildLink for ApprovalRoundTripLink {
16538        async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
16539            if let ParentFrame::ApprovalReply { id, approved } = frame {
16540                self.approval_reply = Some((id, approved));
16541                self.step = 2;
16542            }
16543            Ok(())
16544        }
16545
16546        async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
16547            match self.step {
16548                0 => {
16549                    self.step = 1;
16550                    Ok(Some(ChildFrame::ApprovalRequest {
16551                        id: "approval-1".into(),
16552                        body: serde_json::json!({
16553                            "tool_name": "Bash",
16554                            "permission": "execute",
16555                            "resource": "rm -rf target",
16556                            "permission_request": {"reason_code": "hard_dangerous"}
16557                        }),
16558                    }))
16559                }
16560                1 => std::future::pending().await,
16561                2 => {
16562                    self.step = 3;
16563                    Ok(Some(ChildFrame::Terminal {
16564                        status: TerminalStatus::Completed,
16565                        result: Some("done".into()),
16566                        error: None,
16567                        final_event_watermark: None,
16568                        transcript: vec![],
16569                    }))
16570                }
16571                _ => std::future::pending().await,
16572            }
16573        }
16574    }
16575
16576    #[tokio::test]
16577    async fn drive_routes_forced_ask_to_parent_reviewer_without_human_event() {
16578        let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(8);
16579        let (review_tx, mut review_rx) = mpsc::unbounded_channel();
16580        let reviewer: Arc<dyn ChildApprovalReviewer> = Arc::new(RecordingReviewer {
16581            reviewed: review_tx,
16582        });
16583        let cancel = CancellationToken::new();
16584        let (live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
16585        let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
16586        let mut logical_session = Session::new("child-reviewer", "model");
16587        let live_guard = crate::external_agents::live::register("child-reviewer", live_tx, 0, None);
16588        let mut link = ApprovalRoundTripLink {
16589            step: 0,
16590            approval_reply: None,
16591        };
16592
16593        let result = tokio::time::timeout(
16594            Duration::from_secs(1),
16595            drive(ActorDriveContext {
16596                expected_creation: None,
16597                client: &mut link,
16598                parent_session_id: "parent-reviewer",
16599                child_session_id: "child-reviewer",
16600                child_attempt: 0,
16601                approval_registry: None,
16602                approval_decider: None,
16603                approval_reviewer: Some(&reviewer),
16604                escalation_bridge: None,
16605                event_tx: &event_tx,
16606                cancel_token: &cancel,
16607                live_rx: &mut live_rx,
16608                delivery_rx: &mut delivery_rx,
16609                logical_session: &mut logical_session,
16610                expected_permission_posture: None,
16611                session_inbox_runtime: None,
16612                actor_directory_store: None,
16613                canonical_subagent_tool: None,
16614                ticket_service: None,
16615                activation_run_id: None,
16616                execution_epoch: 0,
16617                expected_source_actor_id: "child-reviewer",
16618                initial_inflight_claims: VecDeque::new(),
16619                plain_actor: false,
16620                remote_environment_lease: false,
16621                readonly_output: None,
16622                local_history_tools: None,
16623                local_history_read_only: false,
16624                strict_history_output: None,
16625                plain_input: None,
16626                canonical_activation: None,
16627                canonical_placement_ref: None,
16628                actor_event_observer: None,
16629                plain_run: None,
16630                first_frame_timeout: None,
16631            }),
16632        )
16633        .await
16634        .expect("worker must receive the reviewer verdict before terminating");
16635
16636        assert_eq!(result.ok().flatten().as_deref(), Some("done"));
16637        assert_eq!(
16638            link.approval_reply,
16639            Some(("approval-1".to_string(), true)),
16640            "reviewer verdict must traverse the live route back to the worker"
16641        );
16642        let (parent, child, body) = tokio::time::timeout(Duration::from_secs(1), review_rx.recv())
16643            .await
16644            .expect("reviewer should be invoked off-loop")
16645            .expect("review channel should remain open");
16646        assert_eq!(parent, "parent-reviewer");
16647        assert_eq!(child, "child-reviewer");
16648        assert_eq!(
16649            body.pointer("/permission_request/reason_code")
16650                .and_then(serde_json::Value::as_str),
16651            Some("hard_dangerous")
16652        );
16653        assert!(
16654            event_rx.try_recv().is_err(),
16655            "must not emit a human-review event"
16656        );
16657        drop(live_guard);
16658    }
16659
16660    #[tokio::test]
16661    async fn drive_denies_forced_ask_without_parent_reviewer_or_manual_event() {
16662        let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(8);
16663        let cancel = CancellationToken::new();
16664        let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
16665        let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
16666        let mut logical_session = Session::new("child-no-reviewer", "model");
16667        let mut link = ApprovalRoundTripLink {
16668            step: 0,
16669            approval_reply: None,
16670        };
16671
16672        let result = tokio::time::timeout(
16673            Duration::from_secs(1),
16674            drive(ActorDriveContext {
16675                expected_creation: None,
16676                client: &mut link,
16677                parent_session_id: "parent-no-reviewer",
16678                child_session_id: "child-no-reviewer",
16679                child_attempt: 0,
16680                approval_registry: None,
16681                approval_decider: None,
16682                approval_reviewer: None,
16683                escalation_bridge: None,
16684                event_tx: &event_tx,
16685                cancel_token: &cancel,
16686                live_rx: &mut live_rx,
16687                delivery_rx: &mut delivery_rx,
16688                logical_session: &mut logical_session,
16689                expected_permission_posture: None,
16690                session_inbox_runtime: None,
16691                actor_directory_store: None,
16692                canonical_subagent_tool: None,
16693                ticket_service: None,
16694                activation_run_id: None,
16695                execution_epoch: 0,
16696                expected_source_actor_id: "child-no-reviewer",
16697                initial_inflight_claims: VecDeque::new(),
16698                plain_actor: false,
16699                remote_environment_lease: false,
16700                readonly_output: None,
16701                local_history_tools: None,
16702                local_history_read_only: false,
16703                strict_history_output: None,
16704                plain_input: None,
16705                canonical_activation: None,
16706                canonical_placement_ref: None,
16707                actor_event_observer: None,
16708                plain_run: None,
16709                first_frame_timeout: None,
16710            }),
16711        )
16712        .await
16713        .expect("fail-closed reply must unblock the child immediately");
16714
16715        assert_eq!(result.ok().flatten().as_deref(), Some("done"));
16716        assert_eq!(link.approval_reply, Some(("approval-1".to_string(), false)));
16717        assert!(
16718            event_rx.try_recv().is_err(),
16719            "missing parent review must not surface a manual approval event"
16720        );
16721    }
16722    #[async_trait]
16723    impl bamboo_subagent::ChildLink for InstantTerminalLink {
16724        async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
16725            Ok(())
16726        }
16727        async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
16728            if self.done {
16729                std::future::pending().await
16730            } else {
16731                self.done = true;
16732                Ok(Some(ChildFrame::Terminal {
16733                    status: TerminalStatus::Completed,
16734                    result: Some("done".into()),
16735                    error: None,
16736                    final_event_watermark: None,
16737                    transcript: vec![],
16738                }))
16739            }
16740        }
16741    }
16742
16743    #[tokio::test]
16744    async fn drive_trips_first_frame_watchdog_on_a_silent_worker() {
16745        let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
16746        let cancel = CancellationToken::new();
16747        let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
16748        let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
16749        let mut logical_session = Session::new("child-x", "model");
16750        let mut link = SilentLink;
16751        let r = drive(ActorDriveContext {
16752            expected_creation: None,
16753            client: &mut link,
16754            parent_session_id: "parent-x",
16755            child_session_id: "child-x",
16756            child_attempt: 0,
16757            approval_registry: None,
16758            approval_decider: None,
16759            approval_reviewer: None,
16760            escalation_bridge: None,
16761            event_tx: &event_tx,
16762            cancel_token: &cancel,
16763            live_rx: &mut live_rx,
16764            delivery_rx: &mut delivery_rx,
16765            logical_session: &mut logical_session,
16766            expected_permission_posture: None,
16767            session_inbox_runtime: None,
16768            actor_directory_store: None,
16769            canonical_subagent_tool: None,
16770            ticket_service: None,
16771            activation_run_id: None,
16772            execution_epoch: 0,
16773            expected_source_actor_id: "child-x",
16774            initial_inflight_claims: VecDeque::new(),
16775            plain_actor: false,
16776            remote_environment_lease: false,
16777            readonly_output: None,
16778            local_history_tools: None,
16779            local_history_read_only: false,
16780            strict_history_output: None,
16781            plain_input: None,
16782            canonical_activation: None,
16783            canonical_placement_ref: None,
16784            actor_event_observer: None,
16785            plain_run: None,
16786            first_frame_timeout: Some(Duration::from_millis(100)),
16787        })
16788        .await;
16789        assert!(
16790            matches!(r, Err(AgentError::WorkerUnresponsive(_))),
16791            "a silent worker must trip the first-frame watchdog, got {r:?}"
16792        );
16793    }
16794
16795    #[tokio::test]
16796    async fn drive_does_not_trip_when_a_frame_arrives() {
16797        let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
16798        let cancel = CancellationToken::new();
16799        let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
16800        let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
16801        let mut logical_session = Session::new("child-y", "model");
16802        let mut link = InstantTerminalLink { done: false };
16803        // Even a tiny timeout must NOT trip: the terminal frame arrives first and
16804        // disarms the watchdog.
16805        let r = drive(ActorDriveContext {
16806            expected_creation: None,
16807            client: &mut link,
16808            parent_session_id: "parent-y",
16809            child_session_id: "child-y",
16810            child_attempt: 0,
16811            approval_registry: None,
16812            approval_decider: None,
16813            approval_reviewer: None,
16814            escalation_bridge: None,
16815            event_tx: &event_tx,
16816            cancel_token: &cancel,
16817            live_rx: &mut live_rx,
16818            delivery_rx: &mut delivery_rx,
16819            logical_session: &mut logical_session,
16820            expected_permission_posture: None,
16821            session_inbox_runtime: None,
16822            actor_directory_store: None,
16823            canonical_subagent_tool: None,
16824            ticket_service: None,
16825            activation_run_id: None,
16826            execution_epoch: 0,
16827            expected_source_actor_id: "child-y",
16828            initial_inflight_claims: VecDeque::new(),
16829            plain_actor: false,
16830            remote_environment_lease: false,
16831            readonly_output: None,
16832            local_history_tools: None,
16833            local_history_read_only: false,
16834            strict_history_output: None,
16835            plain_input: None,
16836            canonical_activation: None,
16837            canonical_placement_ref: None,
16838            actor_event_observer: None,
16839            plain_run: None,
16840            first_frame_timeout: Some(Duration::from_millis(50)),
16841        })
16842        .await;
16843        assert_eq!(r.ok().flatten().as_deref(), Some("done"));
16844    }
16845
16846    #[tokio::test]
16847    async fn child_approval_fails_closed_without_decider() {
16848        // No decider wired ⇒ the host denies (safe default), unchanged behavior.
16849        let body = serde_json::json!({"tool_name":"Bash","permission":"run","resource":"rm -rf /"});
16850        assert!(!decide_child_approval(None, "child-1", &body).await);
16851    }
16852
16853    #[tokio::test]
16854    async fn child_approval_honors_wired_decider() {
16855        let body =
16856            serde_json::json!({"tool_name":"Write","permission":"write","resource":"/tmp/x"});
16857        let approve: Arc<dyn ChildApprovalDecider> = Arc::new(StaticDecider(true));
16858        let deny: Arc<dyn ChildApprovalDecider> = Arc::new(StaticDecider(false));
16859        assert!(decide_child_approval(Some(&approve), "child-1", &body).await);
16860        assert!(!decide_child_approval(Some(&deny), "child-1", &body).await);
16861    }
16862
16863    // ---- #193: remote placement routing -------------------------------------
16864
16865    use crate::runtime::execution::SpawnJob;
16866    use bamboo_agent_core::Session;
16867
16868    /// A runner with a BOGUS worker_bin (`/bin/false`): a local spawn here would
16869    /// FAIL, so a passing remote test proves the remote path never spawns.
16870    fn bogus_runner(placements: HashMap<String, ResolvedRemotePlacement>) -> ActorChildRunner {
16871        ActorChildRunner::new(
16872            "test-actor".into(),
16873            PathBuf::from("/bin/false"),
16874            vec![],
16875            std::env::temp_dir().join("bamboo-test-fab-193"),
16876            ExecutorSpec::Echo,
16877            vec![],
16878            "anthropic".into(),
16879            4,
16880        )
16881        .with_remote_placements(placements)
16882    }
16883
16884    /// A child session of the given role (the role rides `subagent_type`, the
16885    /// path build_spec + the remote lookup both read).
16886    fn session_of_role(role: &str, assignment: &str) -> Session {
16887        let mut s = Session::new("child-1", "test-model");
16888        s.metadata
16889            .insert("subagent_type".to_string(), role.to_string());
16890        s.add_message(bamboo_agent_core::Message::user(assignment));
16891        s
16892    }
16893
16894    fn job_for(child: &str) -> SpawnJob {
16895        SpawnJob {
16896            parent_session_id: "parent-1".into(),
16897            child_session_id: child.into(),
16898            model: String::new(),
16899            disabled_tools: None,
16900        }
16901    }
16902
16903    #[derive(Default)]
16904    struct RecordingChildSessionPort {
16905        saved: std::sync::Mutex<Option<Session>>,
16906    }
16907
16908    impl RecordingChildSessionPort {
16909        fn saved_child(&self) -> Session {
16910            self.saved
16911                .lock()
16912                .expect("saved-child fixture lock")
16913                .clone()
16914                .expect("create_child_action must save the child")
16915        }
16916    }
16917
16918    #[async_trait]
16919    impl crate::session_app::child_session::ChildSessionPort for RecordingChildSessionPort {
16920        async fn load_root_session(
16921            &self,
16922            _root_id: &str,
16923        ) -> Result<Session, crate::session_app::child_session::ChildSessionError> {
16924            unreachable!("create_child_action does not load the root")
16925        }
16926
16927        async fn load_child_for_parent(
16928            &self,
16929            _parent_id: &str,
16930            _child_id: &str,
16931        ) -> Result<Session, crate::session_app::child_session::ChildSessionError> {
16932            unreachable!("create_child_action does not reload the child")
16933        }
16934
16935        async fn save_child_session(
16936            &self,
16937            child: &mut Session,
16938        ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
16939            *self.saved.lock().expect("saved-child fixture lock") = Some(child.clone());
16940            Ok(())
16941        }
16942
16943        async fn save_child_session_authoritative_flags(
16944            &self,
16945            _child: &mut Session,
16946        ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
16947            unreachable!("new-child creation uses the ordinary save")
16948        }
16949
16950        async fn is_child_running(&self, _child_id: &str) -> bool {
16951            false
16952        }
16953
16954        async fn list_children(
16955            &self,
16956            _parent_id: &str,
16957        ) -> Vec<crate::session_app::child_session::ChildSessionEntry> {
16958            Vec::new()
16959        }
16960
16961        async fn enqueue_child_run(
16962            &self,
16963            _parent: &Session,
16964            _child: &Session,
16965        ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
16966            unreachable!("fixture creates the child with auto_run=false")
16967        }
16968
16969        async fn cancel_child_run_and_wait(
16970            &self,
16971            _child_id: &str,
16972        ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
16973            unreachable!("create_child_action does not cancel")
16974        }
16975
16976        async fn delete_child_session(
16977            &self,
16978            _parent_id: &str,
16979            _child_id: &str,
16980        ) -> Result<
16981            crate::session_app::child_session::DeleteChildResult,
16982            crate::session_app::child_session::ChildSessionError,
16983        > {
16984            unreachable!("create_child_action does not delete")
16985        }
16986
16987        async fn get_child_runner_info(
16988            &self,
16989            _child_id: &str,
16990        ) -> Option<crate::session_app::child_session::ChildRunnerInfo> {
16991            None
16992        }
16993
16994        async fn register_parent_wait_for_child(
16995            &self,
16996            _parent_session_id: &str,
16997            _child_session_id: &str,
16998            _tool_call_id: Option<&str>,
16999        ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17000            unreachable!("create_child_action does not register a wait")
17001        }
17002
17003        async fn register_parent_wait_for_children(
17004            &self,
17005            _parent_session_id: &str,
17006            _child_session_ids: &[String],
17007            _policy: bamboo_domain::session::runtime_state::ChildWaitPolicy,
17008        ) -> Result<usize, crate::session_app::child_session::ChildSessionError> {
17009            unreachable!("create_child_action does not register a wait")
17010        }
17011
17012        async fn active_child_ids(&self, _parent_session_id: &str) -> Vec<String> {
17013            Vec::new()
17014        }
17015
17016        async fn find_resident_child(
17017            &self,
17018            _root_session_id: &str,
17019            _resident_name: &str,
17020        ) -> Option<String> {
17021            None
17022        }
17023
17024        async fn ensure_child_indexed(&self, _child_session_id: &str) {}
17025    }
17026
17027    #[test]
17028    fn build_spec_sets_remote_placement_for_matching_role() {
17029        let mut placements = HashMap::new();
17030        placements.insert(
17031            "explorer".to_string(),
17032            ResolvedRemotePlacement {
17033                endpoint: "wss://gpu-host:8443".into(),
17034                token: Some("T-secret".into()),
17035                ca_cert_file: None,
17036                host_label: None,
17037                broker_peer: None,
17038                requirements: None,
17039            },
17040        );
17041        let runner = bogus_runner(placements);
17042
17043        // The broker credential never rides the child RunSpec secrets envelope.
17044        let s = session_of_role("explorer", "do the thing");
17045        let spec = runner.build_spec(&s, &job_for("child-1"));
17046        match &spec.placement {
17047            Placement::Remote { endpoint } => assert_eq!(endpoint, "wss://gpu-host:8443"),
17048            other => panic!("expected Remote, got {other:?}"),
17049        }
17050        assert!(spec.secrets.worker_auth_token.is_none());
17051    }
17052
17053    #[test]
17054    fn build_spec_leaves_local_for_unmatched_role() {
17055        let mut placements = HashMap::new();
17056        placements.insert(
17057            "explorer".to_string(),
17058            ResolvedRemotePlacement {
17059                endpoint: "wss://gpu-host:8443".into(),
17060                token: Some("T".into()),
17061                ca_cert_file: None,
17062                host_label: None,
17063                broker_peer: None,
17064                requirements: None,
17065            },
17066        );
17067        let runner = bogus_runner(placements);
17068
17069        // A DIFFERENT role keeps the default Local placement + no bearer.
17070        let s = session_of_role("writer", "do the thing");
17071        let spec = runner.build_spec(&s, &job_for("child-1"));
17072        assert_eq!(spec.placement, Placement::Local);
17073        assert!(spec.secrets.worker_auth_token.is_none());
17074    }
17075
17076    #[test]
17077    fn build_spec_local_when_no_placements() {
17078        let runner = bogus_runner(HashMap::new());
17079        let s = session_of_role("explorer", "do the thing");
17080        let spec = runner.build_spec(&s, &job_for("child-1"));
17081        assert_eq!(spec.placement, Placement::Local);
17082        assert!(spec.secrets.worker_auth_token.is_none());
17083    }
17084
17085    #[test]
17086    fn typed_child_state_is_the_read_only_authority_under_auto_and_bypass() {
17087        for mode in [
17088            bamboo_domain::SessionPermissionMode::Auto,
17089            bamboo_domain::SessionPermissionMode::Bypass,
17090        ] {
17091            let runner = bogus_runner(HashMap::new());
17092            let mut child = session_of_role("planner", "inspect and plan");
17093            let runtime = child
17094                .agent_runtime_state
17095                .get_or_insert_with(bamboo_domain::AgentRuntimeState::default);
17096            runtime.set_permission_mode(mode);
17097            runtime.read_only = true;
17098
17099            let spec = runner.build_spec(&child, &job_for("read-only-child"));
17100
17101            assert!(spec.capabilities.read_only);
17102            assert!(spec.capabilities.read_only_enforced());
17103            assert!(
17104                spec.capabilities.guardian_read_only,
17105                "the compatibility alias must protect planners on older workers"
17106            );
17107            assert_eq!(spec.capabilities.permission_requested_mode, mode.as_str());
17108            assert_eq!(spec.capabilities.permission_effective_mode, "plan");
17109            assert!(
17110                !spec.capabilities.bypass,
17111                "read-only must win over requested {mode:?}"
17112            );
17113            assert_eq!(
17114                spec.capabilities.auto_approve_permissions,
17115                mode == bamboo_domain::SessionPermissionMode::Auto,
17116                "Auto may suppress prompts but must not widen Plan authorization"
17117            );
17118            let disabled = spec
17119                .disabled_tools
17120                .as_ref()
17121                .expect("read-only state must provision a host-owned denylist");
17122            for tool_name in [
17123                "Edit",
17124                "Write",
17125                "Plan",
17126                "SubAgent",
17127                "session_control",
17128                "request_permissions",
17129            ] {
17130                assert!(
17131                    disabled.iter().any(|name| name == tool_name),
17132                    "read-only provisioning must disable {tool_name}"
17133                );
17134            }
17135        }
17136
17137        let runner = bogus_runner(HashMap::new());
17138        let cosmetic_planner = runner.build_spec(
17139            &session_of_role("planner", "label only"),
17140            &job_for("cosmetic"),
17141        );
17142        assert!(
17143            !cosmetic_planner.capabilities.read_only_enforced(),
17144            "a cosmetic role label must not change authority"
17145        );
17146        assert!(cosmetic_planner.disabled_tools.is_none());
17147
17148        let cosmetic_guardian = runner.build_spec(
17149            &session_of_role("guardian", "label only"),
17150            &job_for("cosmetic-guardian"),
17151        );
17152        assert!(
17153            !cosmetic_guardian.capabilities.read_only_enforced(),
17154            "a Guardian label without typed state or the legacy denylist is cosmetic"
17155        );
17156
17157        let legacy_guardian = session_of_role("guardian", "pre-upgrade reviewer");
17158        let mut legacy_job = job_for("legacy-guardian");
17159        legacy_job.disabled_tools = Some(vec![
17160            "Edit".to_string(),
17161            "Write".to_string(),
17162            "SubAgent".to_string(),
17163            "WebFetch".to_string(),
17164        ]);
17165        let legacy_spec = runner.build_spec(&legacy_guardian, &legacy_job);
17166        assert!(
17167            legacy_spec.capabilities.read_only_enforced(),
17168            "an in-flight pre-upgrade Guardian keeps its read-only authority"
17169        );
17170
17171        let mut differently_named = session_of_role("explorer", "typed authority");
17172        differently_named
17173            .agent_runtime_state
17174            .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
17175            .read_only = true;
17176        let typed = runner.build_spec(&differently_named, &job_for("typed"));
17177        assert!(
17178            typed.capabilities.read_only_enforced(),
17179            "typed state must enforce read-only independently of role"
17180        );
17181    }
17182
17183    #[test]
17184    fn typed_read_only_forces_native_external_executor_profiles() {
17185        let mut planner = session_of_role("planner", "inspect and plan");
17186        planner
17187            .agent_runtime_state
17188            .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
17189            .read_only = true;
17190
17191        let claude_runner = ActorChildRunner::new(
17192            "test-actor".into(),
17193            PathBuf::from("/bin/false"),
17194            vec![],
17195            std::env::temp_dir().join("bamboo-test-plan-claude"),
17196            ExecutorSpec::ClaudeCode {
17197                binary: None,
17198                model: None,
17199                permission_mode: Some("bypassPermissions".to_string()),
17200                inherit_user_config: Some(true),
17201                forward_env: None,
17202            },
17203            vec![],
17204            "anthropic".into(),
17205            4,
17206        );
17207        let claude_spec = claude_runner.build_spec(&planner, &job_for("planner-claude"));
17208        let disabled = claude_spec
17209            .disabled_tools
17210            .as_ref()
17211            .expect("typed read-only child must receive a host-owned denylist");
17212        for tool in ["Bash", "BashInput", "BashOutput", "KillShell"] {
17213            assert!(
17214                disabled.iter().any(|name| name == tool),
17215                "typed read-only child must not advertise {tool}"
17216            );
17217        }
17218        assert!(matches!(
17219            claude_spec.executor,
17220            ExecutorSpec::ClaudeCode {
17221                permission_mode: Some(ref mode),
17222                inherit_user_config: Some(false),
17223                ..
17224            } if mode == "plan"
17225        ));
17226        assert_eq!(claude_spec.capabilities.permission_effective_mode, "plan");
17227
17228        let mut codex_exec = codex_executor(Some("inherit"), Some(true));
17229        if let ExecutorSpec::Codex {
17230            sandbox,
17231            approval_policy,
17232            network_access,
17233            allow_danger_bypass,
17234            permission_profile,
17235            ..
17236        } = &mut codex_exec
17237        {
17238            *sandbox = Some("danger-full-access".to_string());
17239            *approval_policy = Some("on-failure".to_string());
17240            *network_access = Some(true);
17241            *allow_danger_bypass = Some(true);
17242            *permission_profile = Some("full-access".to_string());
17243        }
17244        let codex_runner = ActorChildRunner::new(
17245            "test-actor".into(),
17246            PathBuf::from("/bin/false"),
17247            vec![],
17248            std::env::temp_dir().join("bamboo-test-plan-codex"),
17249            codex_exec,
17250            vec![],
17251            "openai".into(),
17252            4,
17253        );
17254        let codex_spec = codex_runner.build_spec(&planner, &job_for("planner-codex"));
17255        assert_eq!(
17256            expected_permission_executor_mapping(
17257                &codex_spec.executor,
17258                codex_spec.capabilities.permission_resolution().unwrap(),
17259                false,
17260            )
17261            .unwrap()
17262            .as_deref(),
17263            Some("codex_exec:approval_policy=never")
17264        );
17265        assert!(matches!(
17266            codex_spec.executor,
17267            ExecutorSpec::Codex {
17268                sandbox: Some(ref sandbox),
17269                inherit_user_config: Some(false),
17270                approval_policy: Some(ref approval_policy),
17271                network_access: Some(false),
17272                allow_danger_bypass: Some(false),
17273                permission_profile: Some(ref profile),
17274                ..
17275            } if sandbox == "read-only"
17276                && approval_policy == "never"
17277                && profile == "read-only"
17278        ));
17279
17280        let mut app_server = codex_executor(Some("inherit"), Some(true));
17281        if let ExecutorSpec::Codex {
17282            mode,
17283            sandbox,
17284            approval_policy,
17285            network_access,
17286            allow_danger_bypass,
17287            permission_profile,
17288            ..
17289        } = &mut app_server
17290        {
17291            *mode = Some("app_server".to_string());
17292            *sandbox = Some("danger-full-access".to_string());
17293            *approval_policy = Some("on-request".to_string());
17294            *network_access = Some(true);
17295            *allow_danger_bypass = Some(true);
17296            *permission_profile = Some("full-access".to_string());
17297        }
17298        let app_server_runner = ActorChildRunner::new(
17299            "test-actor".into(),
17300            PathBuf::from("/bin/false"),
17301            vec![],
17302            std::env::temp_dir().join("bamboo-test-plan-codex-app-server"),
17303            app_server,
17304            vec![],
17305            "openai".into(),
17306            4,
17307        );
17308        let app_server_spec =
17309            app_server_runner.build_spec(&planner, &job_for("planner-codex-app-server"));
17310        assert_eq!(
17311            expected_permission_executor_mapping(
17312                &app_server_spec.executor,
17313                app_server_spec
17314                    .capabilities
17315                    .permission_resolution()
17316                    .unwrap(),
17317                false,
17318            )
17319            .unwrap()
17320            .as_deref(),
17321            Some("codex_app_server:approvalPolicy=never")
17322        );
17323        assert!(matches!(
17324            app_server_spec.executor,
17325            ExecutorSpec::Codex {
17326                mode: Some(ref mode),
17327                sandbox: Some(ref sandbox),
17328                inherit_user_config: Some(false),
17329                approval_policy: Some(ref approval_policy),
17330                network_access: Some(false),
17331                allow_danger_bypass: Some(false),
17332                permission_profile: Some(ref profile),
17333                ..
17334            } if mode == "app_server"
17335                && sandbox == "read-only"
17336                && approval_policy == "on-request"
17337                && profile == "read-only"
17338        ));
17339    }
17340
17341    #[test]
17342    fn typed_read_only_rejects_both_codex_executor_modes() {
17343        let mut planner = session_of_role("planner", "inspect and plan");
17344        planner
17345            .agent_runtime_state
17346            .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
17347            .read_only = true;
17348
17349        for mode in ["exec", "app_server"] {
17350            let mut executor = codex_executor(Some("inherit"), Some(true));
17351            if let ExecutorSpec::Codex {
17352                mode: configured_mode,
17353                ..
17354            } = &mut executor
17355            {
17356                *configured_mode = Some(mode.to_string());
17357            }
17358            let runner = ActorChildRunner::new(
17359                "test-actor".into(),
17360                PathBuf::from("/bin/false"),
17361                vec![],
17362                std::env::temp_dir().join(format!("bamboo-test-plan-codex-{mode}")),
17363                executor,
17364                vec![],
17365                "openai".into(),
17366                4,
17367            );
17368            let spec = runner.build_spec(&planner, &job_for(&format!("planner-codex-{mode}")));
17369            let error = ensure_read_only_activation_is_enforceable(&spec)
17370                .expect_err("Codex command execution must fail closed for read-only activations");
17371            let message = error.to_string();
17372            assert!(message.contains("Codex executor"), "{message}");
17373            assert!(message.contains("command execution"), "{message}");
17374        }
17375    }
17376
17377    #[tokio::test]
17378    async fn typed_read_only_codex_fails_before_worker_spawn_or_dispatch() {
17379        let mut planner = session_of_role("planner", "inspect and plan");
17380        planner
17381            .agent_runtime_state
17382            .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
17383            .read_only = true;
17384
17385        let runner = ActorChildRunner::new(
17386            "test-actor".into(),
17387            PathBuf::from("/bin/false"),
17388            vec![],
17389            std::env::temp_dir().join("bamboo-test-plan-codex-no-spawn"),
17390            codex_executor(Some("inherit"), Some(true)),
17391            vec![],
17392            "openai".into(),
17393            4,
17394        );
17395        let (event_tx, _event_rx) = mpsc::channel::<AgentEvent>(4);
17396        let error = runner
17397            .execute_external_child(
17398                &mut planner,
17399                &job_for("planner-codex-no-spawn"),
17400                event_tx,
17401                CancellationToken::new(),
17402            )
17403            .await
17404            .expect_err("typed read-only Codex must fail before invoking /bin/false");
17405        let message = error.to_string();
17406        assert!(message.contains("Codex executor"), "{message}");
17407        assert!(message.contains("command execution"), "{message}");
17408    }
17409
17410    #[test]
17411    fn ticket_placement_keeps_local_and_ordinary_remote_execution_available() {
17412        let runner = bogus_runner(HashMap::new());
17413        let ordinary = session_of_role("worker", "ordinary work");
17414        for placement in [
17415            Placement::Local,
17416            Placement::Remote {
17417                endpoint: "wss://fixture.invalid/actor".into(),
17418            },
17419            Placement::Schedulable {
17420                pool: "fixture-pool".into(),
17421            },
17422        ] {
17423            let mut spec = runner.build_spec(&ordinary, &job_for("placement"));
17424            spec.placement = placement;
17425            ensure_ticket_placement_is_enforceable(&ordinary, &spec).unwrap();
17426            for marker in [
17427                crate::ticket_runtime::TICKET_DISPATCH_KEY,
17428                crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY,
17429            ] {
17430                let mut ticket = ordinary.clone();
17431                ticket.metadata.insert(marker.into(), "fixture".into());
17432                assert_eq!(
17433                    ensure_ticket_placement_is_enforceable(&ticket, &spec).is_ok(),
17434                    matches!(spec.placement, Placement::Local)
17435                );
17436            }
17437        }
17438    }
17439
17440    #[tokio::test]
17441    async fn ticket_placement_rejected_before_remote_route_claim_or_worker_spawn() {
17442        for marker in [
17443            crate::ticket_runtime::TICKET_DISPATCH_KEY,
17444            crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY,
17445        ] {
17446            for schedulable in [false, true] {
17447                let mut session = session_of_role("worker", "Ticket work");
17448                session.metadata.insert(marker.into(), "fixture".into());
17449                let before = serde_json::to_value(&session).unwrap();
17450                let remote = if schedulable {
17451                    HashMap::new()
17452                } else {
17453                    HashMap::from([(
17454                        "worker".into(),
17455                        ResolvedRemotePlacement {
17456                            endpoint: "wss://fixture.invalid/actor".into(),
17457                            ..Default::default()
17458                        },
17459                    )])
17460                };
17461                let sched = if schedulable {
17462                    HashMap::from([(
17463                        "worker".into(),
17464                        sched_placement("fixture-pool", "wss://fixture.invalid"),
17465                    )])
17466                } else {
17467                    HashMap::new()
17468                };
17469                let runner = bogus_sched_runner(remote, sched);
17470                let (events, mut observations) = mpsc::channel::<AgentEvent>(4);
17471                let error = runner
17472                    .execute_external_child(
17473                        &mut session,
17474                        &job_for("ticket-no-remote"),
17475                        events,
17476                        CancellationToken::new(),
17477                    )
17478                    .await
17479                    .unwrap_err();
17480                assert!(
17481                    error
17482                        .to_string()
17483                        .contains("Ticket execution requires local placement"),
17484                    "{error}"
17485                );
17486                assert_eq!(serde_json::to_value(&session).unwrap(), before);
17487                assert!(observations.try_recv().is_err());
17488            }
17489        }
17490    }
17491
17492    #[test]
17493    fn typed_read_only_rejects_resident_actor_placements() {
17494        let runner = bogus_runner(HashMap::new());
17495        let mut planner = session_of_role("planner", "inspect and plan");
17496        planner
17497            .agent_runtime_state
17498            .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
17499            .read_only = true;
17500
17501        for placement in [
17502            Placement::Remote {
17503                endpoint: "wss://resident.example/actor".to_string(),
17504            },
17505            Placement::Schedulable {
17506                pool: "resident-pool".to_string(),
17507            },
17508        ] {
17509            let mut spec = runner.build_spec(&planner, &job_for("read-only-resident"));
17510            spec.placement = placement;
17511
17512            let error = ensure_read_only_activation_is_enforceable(&spec)
17513                .expect_err("resident placement must fail closed for read-only activation");
17514            let message = error.to_string();
17515            assert!(message.contains("requires local placement"), "{message}");
17516            assert!(message.contains("read-only tool boundary"), "{message}");
17517        }
17518
17519        let local = runner.build_spec(&planner, &job_for("read-only-local"));
17520        ensure_read_only_activation_is_enforceable(&local)
17521            .expect("a locally provisioned read-only worker is enforceable");
17522    }
17523
17524    #[tokio::test]
17525    async fn build_spec_preserves_exact_inherited_permission_mode_for_child_worker() {
17526        // Exercise the real creation path instead of pre-seeding the child by
17527        // hand: both legacy Bypass and zero-prompt Auto must survive child
17528        // creation and the actor provisioning boundary without collapsing.
17529        for (label, mode) in [
17530            ("bypass", bamboo_domain::SessionPermissionMode::Bypass),
17531            ("auto", bamboo_domain::SessionPermissionMode::Auto),
17532        ] {
17533            let runner = bogus_runner(HashMap::new());
17534            let mut parent = Session::new(format!("parent-{label}"), "test-model");
17535            parent
17536                .agent_runtime_state
17537                .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
17538                .set_permission_mode(mode);
17539            let workspace = tempfile::tempdir().expect("workspace fixture");
17540            let port = RecordingChildSessionPort::default();
17541            let child_id = format!("child-{label}-{}", uuid::Uuid::new_v4());
17542            crate::session_app::child_session::create_child_action(
17543                &port,
17544                crate::session_app::child_session::CreateChildInput {
17545                    parent_session: parent,
17546                    child_id: child_id.clone(),
17547                    title: format!("{label} child"),
17548                    responsibility: "Run ordinary commands".to_string(),
17549                    assignment_prompt: "run an ordinary command".to_string(),
17550                    subagent_type: "explorer".to_string(),
17551                    workspace: workspace.path().to_string_lossy().into_owned(),
17552                    workspace_source: crate::project_context::WorkspaceSource::Explicit,
17553                    model_override: None,
17554                    model_ref_override: None,
17555                    runtime_metadata: HashMap::new(),
17556                    read_only: false,
17557                    auto_run: false,
17558                    reasoning_effort: None,
17559                    lifecycle: None,
17560                    resident_name: None,
17561                    resident_context: None,
17562                    disabled_tools: None,
17563                    context_fork: None,
17564                },
17565            )
17566            .await
17567            .expect("create inherited-permission child");
17568            let child = port.saved_child();
17569
17570            assert_eq!(
17571                child
17572                    .agent_runtime_state
17573                    .as_ref()
17574                    .map(bamboo_domain::AgentRuntimeState::effective_permission_mode),
17575                Some(mode),
17576                "create_child_action must inherit {label} from the parent"
17577            );
17578
17579            let spec = runner.build_spec(&child, &job_for(&child_id));
17580
17581            assert_eq!(
17582                spec.capabilities.bypass,
17583                mode == bamboo_domain::SessionPermissionMode::Bypass
17584            );
17585            assert_eq!(
17586                spec.capabilities.auto_approve_permissions,
17587                mode == bamboo_domain::SessionPermissionMode::Auto
17588            );
17589            assert!(
17590                spec.capabilities.enforce_permissions,
17591                "policy evaluation must remain active under {label}"
17592            );
17593        }
17594    }
17595
17596    #[tokio::test]
17597    async fn child_resident_and_guardian_inherit_project_through_actor_run_spec() {
17598        let project_id = bamboo_domain::ProjectId::parse("project-inherited").expect("Project id");
17599        let workspace = tempfile::tempdir().expect("workspace fixture");
17600
17601        for (role, lifecycle, resident_name) in [
17602            ("explorer", None, None),
17603            ("resident", Some("resident"), Some("stable-reviewer")),
17604            ("guardian", None, None),
17605        ] {
17606            let mut parent = Session::new(format!("parent-{role}"), "test-model");
17607            parent.set_project_id_meta(project_id.to_string());
17608            let port = RecordingChildSessionPort::default();
17609            let child_id = format!("child-{role}-{}", uuid::Uuid::new_v4());
17610            crate::session_app::child_session::create_child_action(
17611                &port,
17612                crate::session_app::child_session::CreateChildInput {
17613                    parent_session: parent,
17614                    child_id: child_id.clone(),
17615                    title: format!("{role} child"),
17616                    responsibility: "Review the assigned work".to_string(),
17617                    assignment_prompt: "inspect the change".to_string(),
17618                    subagent_type: role.to_string(),
17619                    workspace: workspace.path().to_string_lossy().into_owned(),
17620                    workspace_source: crate::project_context::WorkspaceSource::Explicit,
17621                    model_override: None,
17622                    model_ref_override: None,
17623                    runtime_metadata: HashMap::new(),
17624                    read_only: false,
17625                    auto_run: false,
17626                    reasoning_effort: None,
17627                    lifecycle: lifecycle.map(str::to_string),
17628                    resident_name: resident_name.map(str::to_string),
17629                    resident_context: None,
17630                    disabled_tools: None,
17631                    context_fork: None,
17632                },
17633            )
17634            .await
17635            .expect("create Project-inheriting child");
17636            let child = port.saved_child();
17637            assert_eq!(
17638                crate::project_context::ProjectContextResolver::project_id_from_session(&child),
17639                Some(project_id.clone()),
17640                "{role} child must inherit its parent's Project"
17641            );
17642
17643            assert_eq!(
17644                project_id_for_actor_run(&child).expect("valid actor Project identity"),
17645                Some(project_id.clone()),
17646                "{role} actor RunSpec must preserve inherited Project identity"
17647            );
17648        }
17649    }
17650
17651    #[test]
17652    fn placement_metadata_stamps_remote_and_schedulable_not_local() {
17653        // Local children carry no stamp — the DTO defaults them to the backend host.
17654        assert_eq!(placement_metadata(&Placement::Local, None), None);
17655
17656        // Remote, no node label → host derived from the endpoint.
17657        let r = placement_metadata(
17658            &Placement::Remote {
17659                endpoint: "wss://10.0.0.5:8443/stream".into(),
17660            },
17661            None,
17662        )
17663        .unwrap();
17664        assert!(r.contains(r#""kind":"remote""#), "{r}");
17665        assert!(r.contains(r#""host":"10.0.0.5""#), "{r}");
17666
17667        // A cluster node's label (its metadata) OVERRIDES the raw endpoint host.
17668        let labeled = placement_metadata(
17669            &Placement::Remote {
17670                endpoint: "ws://169.254.230.101:8899".into(),
17671            },
17672            Some("mini"),
17673        )
17674        .unwrap();
17675        assert!(labeled.contains(r#""host":"mini""#), "{labeled}");
17676
17677        // Schedulable → {kind:"remote", host:<node label, else pool>}.
17678        let s = placement_metadata(
17679            &Placement::Schedulable {
17680                pool: "explorers".into(),
17681            },
17682            Some("mini"),
17683        )
17684        .unwrap();
17685        assert!(s.contains(r#""kind":"remote""#), "{s}");
17686        assert!(s.contains(r#""host":"mini""#), "{s}");
17687
17688        // The stamp round-trips through the storage placement type.
17689        let p: bamboo_storage::SessionPlacement = serde_json::from_str(&labeled).unwrap();
17690        assert_eq!(p.kind, "remote");
17691        assert_eq!(p.host, "mini");
17692    }
17693
17694    /// A legacy direct worker endpoint cannot bypass the broker activation
17695    /// contract, even when the endpoint is reachable and has a bearer token.
17696    #[tokio::test]
17697    async fn execute_external_child_rejects_direct_remote_worker() {
17698        let mut placements = HashMap::new();
17699        placements.insert(
17700            "explorer".to_string(),
17701            ResolvedRemotePlacement {
17702                endpoint: "ws://127.0.0.1:1".into(),
17703                token: Some("remote-test-token".into()),
17704                ca_cert_file: None,
17705                host_label: Some("mini-e2e".into()),
17706                broker_peer: None,
17707                requirements: None,
17708            },
17709        );
17710        let runner = bogus_runner(placements);
17711        let mut session = session_of_role("explorer", "hello remote");
17712        let job = job_for("child-1");
17713        let (event_tx, _event_rx) = mpsc::channel::<AgentEvent>(64);
17714        let error = runner
17715            .execute_external_child(&mut session, &job, event_tx, CancellationToken::new())
17716            .await
17717            .err()
17718            .expect("direct remote transport must fail closed");
17719        assert!(error
17720            .to_string()
17721            .contains("remote_broker_activation_unavailable"));
17722        assert!(session
17723            .messages
17724            .iter()
17725            .all(|message| message.role != Role::Assistant));
17726    }
17727
17728    // ---- #181 (P2b): schedulable placement routing --------------------------
17729
17730    /// A bogus-worker_bin runner carrying SCHEDULABLE placements (and optionally
17731    /// remote ones, to test precedence). A local spawn here would fail on
17732    /// `/bin/false`, so a passing schedulable test proves no subprocess spawned.
17733    fn bogus_sched_runner(
17734        remote: HashMap<String, ResolvedRemotePlacement>,
17735        sched: HashMap<String, ResolvedSchedulablePlacement>,
17736    ) -> ActorChildRunner {
17737        ActorChildRunner::new(
17738            "test-actor".into(),
17739            PathBuf::from("/bin/false"),
17740            vec![],
17741            std::env::temp_dir().join("bamboo-test-fab-181"),
17742            ExecutorSpec::Echo,
17743            vec![],
17744            "anthropic".into(),
17745            4,
17746        )
17747        .with_remote_placements(remote)
17748        .with_schedulable_placements(sched)
17749    }
17750
17751    fn sched_placement(
17752        pool: &str,
17753        _registry_url: impl Into<String>,
17754    ) -> ResolvedSchedulablePlacement {
17755        ResolvedSchedulablePlacement {
17756            pool: pool.into(),
17757            host_label: None,
17758            requirements: None,
17759            ..Default::default()
17760        }
17761    }
17762
17763    #[test]
17764    fn build_spec_sets_schedulable_placement_for_matching_role() {
17765        let mut sched = HashMap::new();
17766        sched.insert(
17767            "explorer".to_string(),
17768            sched_placement("gpu-pool", "unused"),
17769        );
17770        let runner = bogus_sched_runner(HashMap::new(), sched);
17771
17772        let s = session_of_role("explorer", "do the thing");
17773        let spec = runner.build_spec(&s, &job_for("child-1"));
17774        match &spec.placement {
17775            Placement::Schedulable { pool } => assert_eq!(pool, "gpu-pool"),
17776            other => panic!("expected Schedulable, got {other:?}"),
17777        }
17778        // No per-placement bearer now — the bus connection carries the bus token.
17779        assert!(spec.secrets.worker_auth_token.is_none());
17780    }
17781
17782    #[test]
17783    fn build_spec_remote_wins_when_role_in_both_maps() {
17784        // A role present in BOTH remote_placements and schedulable_placements must
17785        // resolve to the FIXED remote placement (documented precedence).
17786        let mut remote = HashMap::new();
17787        remote.insert(
17788            "explorer".to_string(),
17789            ResolvedRemotePlacement {
17790                endpoint: "wss://fixed-host:8443".into(),
17791                token: Some("T-remote".into()),
17792                ca_cert_file: None,
17793                host_label: None,
17794                broker_peer: None,
17795                requirements: None,
17796            },
17797        );
17798        let mut sched = HashMap::new();
17799        sched.insert(
17800            "explorer".to_string(),
17801            sched_placement("gpu-pool", "https://control-plane:9562"),
17802        );
17803        let runner = bogus_sched_runner(remote, sched);
17804
17805        let s = session_of_role("explorer", "do the thing");
17806        let spec = runner.build_spec(&s, &job_for("child-1"));
17807        match &spec.placement {
17808            Placement::Remote { endpoint } => assert_eq!(endpoint, "wss://fixed-host:8443"),
17809            other => panic!("expected Remote (precedence), got {other:?}"),
17810        }
17811        assert!(spec.secrets.worker_auth_token.is_none());
17812    }
17813
17814    #[test]
17815    fn build_spec_local_for_unmatched_schedulable_role() {
17816        let mut sched = HashMap::new();
17817        sched.insert(
17818            "explorer".to_string(),
17819            sched_placement("gpu-pool", "https://control-plane:9562"),
17820        );
17821        let runner = bogus_sched_runner(HashMap::new(), sched);
17822        let s = session_of_role("writer", "do the thing");
17823        let spec = runner.build_spec(&s, &job_for("child-1"));
17824        assert_eq!(spec.placement, Placement::Local);
17825        assert!(spec.secrets.worker_auth_token.is_none());
17826    }
17827
17828    /// The full role → resolved-placement → badge-host chain: a child routed to a
17829    /// remote/schedulable placement carrying a cluster node's `host_label` stamps
17830    /// that label; without a label it falls back to the endpoint host / pool; a
17831    /// Local child gets no stamp (the DTO defaults it to the backend host).
17832    #[test]
17833    fn placement_stamp_uses_node_label_for_remote_and_schedulable() {
17834        // Remote WITH a node label → {remote, <label>}, overriding the raw IP.
17835        let mut remote = HashMap::new();
17836        remote.insert(
17837            "explorer".to_string(),
17838            ResolvedRemotePlacement {
17839                endpoint: "ws://169.254.230.101:8899".into(),
17840                token: None,
17841                ca_cert_file: None,
17842                host_label: Some("mini".into()),
17843                broker_peer: None,
17844                requirements: None,
17845            },
17846        );
17847        let runner = bogus_runner(remote);
17848        let spec = runner.build_spec(&session_of_role("explorer", "go"), &job_for("c1"));
17849        let stamp = runner
17850            .placement_stamp_for(&spec)
17851            .expect("remote child is stamped");
17852        assert!(stamp.contains(r#""kind":"remote""#), "{stamp}");
17853        assert!(stamp.contains(r#""host":"mini""#), "{stamp}");
17854
17855        // Remote WITHOUT a node label → falls back to the endpoint host.
17856        let mut remote_nolabel = HashMap::new();
17857        remote_nolabel.insert(
17858            "explorer".to_string(),
17859            ResolvedRemotePlacement {
17860                endpoint: "ws://169.254.230.101:8899".into(),
17861                token: None,
17862                ca_cert_file: None,
17863                host_label: None,
17864                broker_peer: None,
17865                requirements: None,
17866            },
17867        );
17868        let r2 = bogus_runner(remote_nolabel);
17869        let spec2 = r2.build_spec(&session_of_role("explorer", "go"), &job_for("c1"));
17870        assert!(r2
17871            .placement_stamp_for(&spec2)
17872            .unwrap()
17873            .contains(r#""host":"169.254.230.101""#));
17874
17875        // Schedulable WITH a node label → {remote, <label>} (a node, not a pool name).
17876        let mut sched = HashMap::new();
17877        sched.insert(
17878            "mac-mini-monitor".to_string(),
17879            ResolvedSchedulablePlacement {
17880                pool: "mac-mini-monitor".into(),
17881                host_label: Some("mini".into()),
17882                requirements: None,
17883                ..Default::default()
17884            },
17885        );
17886        let sr = bogus_sched_runner(HashMap::new(), sched);
17887        let spec3 = sr.build_spec(&session_of_role("mac-mini-monitor", "go"), &job_for("c1"));
17888        let stamp3 = sr
17889            .placement_stamp_for(&spec3)
17890            .expect("scheduled child is stamped");
17891        assert!(stamp3.contains(r#""kind":"remote""#), "{stamp3}");
17892        assert!(stamp3.contains(r#""host":"mini""#), "{stamp3}");
17893
17894        // A Local (unmatched) child gets NO stamp.
17895        let local = bogus_runner(HashMap::new());
17896        let spec4 = local.build_spec(&session_of_role("writer", "go"), &job_for("c1"));
17897        assert_eq!(local.placement_stamp_for(&spec4), None);
17898    }
17899
17900    #[tokio::test]
17901    async fn legacy_schedulable_route_fails_closed_without_local_fallback() {
17902        let mut sched = HashMap::new();
17903        sched.insert(
17904            "explorer".to_string(),
17905            sched_placement("gpu-pool", "unused"),
17906        );
17907        let runner = bogus_sched_runner(HashMap::new(), sched);
17908        let mut session = session_of_role("explorer", "do the thing");
17909        let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
17910        let error = runner
17911            .execute_external_child(
17912                &mut session,
17913                &job_for("child-1"),
17914                event_tx,
17915                CancellationToken::new(),
17916            )
17917            .await
17918            .expect_err("legacy bus presence cannot grant placement");
17919        assert!(error
17920            .to_string()
17921            .contains("remote_broker_activation_unavailable"));
17922    }
17923}