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::{HashMap, VecDeque};
14use std::path::{Path, PathBuf};
15use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
16use std::sync::Arc;
17use std::time::{Duration, Instant};
18
19use async_trait::async_trait;
20use bamboo_agent_core::{AgentError, AgentEvent, Role, Session};
21use bamboo_domain::poison::PoisonRecover;
22use bamboo_domain::SessionInboxClaim;
23use tokio::sync::mpsc;
24use tokio_util::sync::CancellationToken;
25
26use bamboo_subagent::fleet::{spawn_worker_on_bus, SpawnedChild};
27use bamboo_subagent::proto::{
28    ActorEventBatch, AgentRecord, ChildFrame, LogicalSessionIdentity, ParentFrame,
29    PermissionPolicyContext, RunSpec, SessionMessageDelivery, TerminalStatus,
30};
31use bamboo_subagent::provision::{
32    ChildIdentity, ExecutorSpec, ModelRefSpec, Placement, ProvisionSpec, ScopedCredential,
33};
34use bamboo_subagent::transport::{client_config_trusting_cert, ChildClient};
35
36use crate::runtime::execution::{ExternalChildRunner, SessionInboxRuntimeBinding, SpawnJob};
37
38/// Default cap on simultaneously running actor activations. The event and
39/// broker transports are bounded independently, so 200 active sub-agents are a
40/// supported operating point rather than an opt-in escape hatch.
41pub const DEFAULT_MAX_CONCURRENT_ACTORS: usize = 200;
42
43/// Max nesting depth for direct nested execution (Phase 6). A worker whose
44/// session `spawn_depth` is below this gets its own spawn stack + the real
45/// SubAgent tool; at/over it, neither (and the tool itself refuses). Mirrors
46/// `bamboo_server_tools::DEFAULT_MAX_SPAWN_DEPTH` (kept in sync; engine can't
47/// depend on server-tools). Root orchestrator = 0 ⇒ 4 levels of sub-agents.
48pub const MAX_SPAWN_DEPTH: u32 = 4;
49
50/// Default cap on idle pooled (warm, reusable) workers kept per fingerprint.
51const DEFAULT_MAX_IDLE_PER_KEY: usize = 4;
52
53/// Process-wide-per-runner cap across every reuse fingerprint. Without this,
54/// workloads that continually change role/model/workspace can leave one parked
55/// worker in an unbounded number of otherwise-small buckets.
56const DEFAULT_MAX_IDLE_TOTAL: usize = 16;
57
58/// How long a pooled worker waits for its next assignment before reclaiming
59/// itself (must comfortably exceed the gap between sibling spawns).
60const POOLED_IDLE_TIMEOUT_SECS: u64 = 300;
61
62/// Sweep often enough to retire an expired/dead pool entry even when no later
63/// child checks out the same fingerprint. The worker's own idle deadline is the
64/// primary process-side guard; this task closes the parent handle and pool entry.
65const POOLED_REAPER_INTERVAL: Duration = Duration::from_secs(30);
66
67/// Deadline for a local worker's FIRST frame after a Run is dispatched. A warm
68/// worker answers in seconds; a cold spawn within tens. Total silence past this
69/// means the worker is dead (e.g. a pooled worker that exited right after its
70/// liveness check) and its Run is queued with nobody to serve it — trip it so the
71/// runner respawns once instead of hanging forever. Generous, to never false-trip
72/// a slow-but-healthy cold start.
73const WORKER_FIRST_FRAME_TIMEOUT: Duration = Duration::from_secs(60);
74
75fn active_scoped_session_deny_count(
76    config: &bamboo_tools::permission::PermissionConfig,
77    session_id: &str,
78) -> usize {
79    config
80        .temporary_grants()
81        .into_iter()
82        .filter(|grant| {
83            grant.scope == bamboo_tools::permission::TemporaryPermissionGrantScope::Session
84                && grant.effect == bamboo_tools::permission::TemporaryPermissionGrantEffect::Deny
85                && grant.session_id.as_deref() == Some(session_id)
86        })
87        .count()
88}
89
90fn ensure_no_active_scoped_session_denies(
91    config: &bamboo_tools::permission::PermissionConfig,
92    session_id: &str,
93) -> Result<(), AgentError> {
94    let count = active_scoped_session_deny_count(config, session_id);
95    if count == 0 {
96        Ok(())
97    } else {
98        Err(AgentError::LLM(format!(
99            "external executor activation blocked by {count} active session-scoped explicit deny rule(s)"
100        )))
101    }
102}
103
104/// Plaintext token returned once by the server authority for one Codex run.
105/// `token_id` is non-secret and is the handle used for guaranteed revocation.
106pub struct IssuedCodexRunToken {
107    pub token_id: String,
108    pub token: String,
109}
110
111/// Server-owned authority for Bamboo-as-provider Codex credentials. The engine
112/// only needs mint/revoke; verification remains inside the HTTP server.
113pub trait CodexRunTokenAuthority: Send + Sync + 'static {
114    fn issue(&self, session_id: &str) -> Result<IssuedCodexRunToken, String>;
115    fn revoke(&self, token_id: &str);
116}
117
118struct CodexRunTokenGuard {
119    authority: Arc<dyn CodexRunTokenAuthority>,
120    token_id: String,
121}
122
123impl Drop for CodexRunTokenGuard {
124    fn drop(&mut self) {
125        self.authority.revoke(&self.token_id);
126    }
127}
128
129fn executor_uses_bamboo_codex(executor: &ExecutorSpec) -> bool {
130    matches!(
131        executor,
132        ExecutorSpec::Codex {
133            auth_mode: Some(mode),
134            ..
135        } if mode == "bamboo"
136    ) || matches!(
137        executor,
138        ExecutorSpec::Codex {
139            auth_mode: None,
140            inherit_user_config,
141            ..
142        } if !inherit_user_config.unwrap_or(false)
143    )
144}
145
146fn executor_has_read_only_permission_profile(executor: &ExecutorSpec) -> bool {
147    match executor {
148        ExecutorSpec::ClaudeCode {
149            permission_mode, ..
150        } => permission_mode
151            .as_deref()
152            .is_some_and(|mode| mode.eq_ignore_ascii_case("plan")),
153        ExecutorSpec::Codex {
154            permission_profile,
155            sandbox,
156            ..
157        } => {
158            permission_profile
159                .as_deref()
160                .is_some_and(|profile| profile.eq_ignore_ascii_case("read-only"))
161                || sandbox
162                    .as_deref()
163                    .is_some_and(|value| value.eq_ignore_ascii_case("read-only"))
164        }
165        _ => false,
166    }
167}
168
169/// Exact non-secret executor posture the host expects for one typed activation.
170///
171/// Echo is intentionally a transport-only smoke executor and CliAdapter is not
172/// implemented by the production worker. Neither claims the typed permission
173/// contract, so legacy/custom frame handling remains available only for those
174/// two variants. Every executable permission-aware variant must prove the
175/// mapping derived from its provisioned spec before any execution event.
176fn expected_permission_executor_mapping(
177    executor: &ExecutorSpec,
178    resolution: bamboo_domain::PermissionModeResolution,
179    has_explicit_deny: bool,
180) -> Result<Option<String>, AgentError> {
181    let mapping = match executor {
182        ExecutorSpec::Echo | ExecutorSpec::CliAdapter { .. } => return Ok(None),
183        ExecutorSpec::BambooRuntime => {
184            format!("bamboo_runtime:{}", resolution.effective.as_str())
185        }
186        ExecutorSpec::ClaudeCode {
187            permission_mode, ..
188        } => {
189            if has_explicit_deny {
190                "claude_code:blocked_explicit_deny".to_string()
191            } else {
192                let mode = match resolution.effective {
193                    bamboo_domain::PermissionMode::Plan => "plan",
194                    bamboo_domain::PermissionMode::Auto => "bypassPermissions",
195                    bamboo_domain::PermissionMode::AcceptEdits => "acceptEdits",
196                    bamboo_domain::PermissionMode::DontAsk => "dontAsk",
197                    bamboo_domain::PermissionMode::Default
198                    | bamboo_domain::PermissionMode::BypassPermissions => {
199                        permission_mode.as_deref().unwrap_or("default")
200                    }
201                };
202                format!("claude_code:permission_mode={mode}")
203            }
204        }
205        ExecutorSpec::Codex {
206            mode,
207            sandbox,
208            approval_policy,
209            allow_danger_bypass,
210            ..
211        } => match mode.as_deref().unwrap_or("exec") {
212            "exec" => {
213                let approval_policy = expected_codex_exec_approval_policy(
214                    sandbox.as_deref(),
215                    approval_policy.as_deref(),
216                    allow_danger_bypass.unwrap_or(false),
217                    resolution,
218                )?;
219                if has_explicit_deny {
220                    "codex_exec:blocked_explicit_deny".to_string()
221                } else {
222                    format!("codex_exec:approval_policy={approval_policy}")
223                }
224            }
225            "app_server" => {
226                if !matches!(approval_policy.as_deref(), None | Some("on-request")) {
227                    return Err(AgentError::LLM(
228                        "invalid Codex app-server permission posture configuration".to_string(),
229                    ));
230                }
231                if has_explicit_deny {
232                    "codex_app_server:blocked_explicit_deny".to_string()
233                } else {
234                    let approval_policy = if resolution.suppress_approval_prompts()
235                        || resolution.effective == bamboo_domain::PermissionMode::Plan
236                    {
237                        "never"
238                    } else {
239                        "on-request"
240                    };
241                    format!("codex_app_server:approvalPolicy={approval_policy}")
242                }
243            }
244            _ => {
245                return Err(AgentError::LLM(
246                    "unsupported Codex executor mode for permission posture contract".to_string(),
247                ));
248            }
249        },
250    };
251    Ok(Some(mapping))
252}
253
254fn expected_codex_exec_approval_policy(
255    sandbox: Option<&str>,
256    approval_policy: Option<&str>,
257    allow_danger_bypass: bool,
258    resolution: bamboo_domain::PermissionModeResolution,
259) -> Result<&'static str, AgentError> {
260    let configured = match approval_policy {
261        None | Some("never") => "never",
262        Some("on-failure") => "on-failure",
263        Some(_) => {
264            return Err(AgentError::LLM(
265                "invalid Codex exec permission posture configuration".to_string(),
266            ));
267        }
268    };
269    if resolution.suppress_approval_prompts()
270        || resolution.effective == bamboo_domain::PermissionMode::Plan
271    {
272        return Ok("never");
273    }
274    match sandbox {
275        Some("danger-full-access") => Ok("never"),
276        Some("read-only") | Some("workspace-write") => Ok(configured),
277        None if allow_danger_bypass || resolution.bypass_permissions() => Ok("never"),
278        None => Ok(configured),
279        Some(_) => Err(AgentError::LLM(
280            "invalid Codex exec permission posture configuration".to_string(),
281        )),
282    }
283}
284
285fn workspace_is_bamboo_owned(raw: &str) -> bool {
286    let workspace = std::fs::canonicalize(raw).unwrap_or_else(|_| PathBuf::from(raw));
287    let configured_root = bamboo_config::paths::resolve_workspace_root();
288    let configured_root = std::fs::canonicalize(&configured_root).unwrap_or(configured_root);
289    if workspace.starts_with(&configured_root) {
290        return true;
291    }
292
293    // Project worktrees created by Bamboo live under
294    // `<project>/.bamboo/worktree/<name>` and carry the ownership marker used
295    // by the project-worktree lifecycle. A path that merely imitates the
296    // directory shape is not sufficient to bypass Codex's git guard.
297    workspace.ancestors().any(|candidate| {
298        let Some(name) = candidate.file_name().and_then(|name| name.to_str()) else {
299            return false;
300        };
301        let Some(worktree_root) = candidate.parent() else {
302            return false;
303        };
304        if worktree_root.file_name() != Some(std::ffi::OsStr::new("worktree"))
305            || worktree_root.parent().and_then(Path::file_name)
306                != Some(std::ffi::OsStr::new(".bamboo"))
307        {
308            return false;
309        }
310        let marker = worktree_root.join(".bamboo-owned").join(name);
311        std::fs::read_to_string(marker).is_ok_and(|branch| branch == format!("bamboo/{name}"))
312    })
313}
314
315fn build_codex_run_secrets(
316    executor: &ExecutorSpec,
317    authority: Option<Arc<dyn CodexRunTokenAuthority>>,
318    child_session_id: &str,
319) -> Result<
320    (
321        bamboo_subagent::proto::RunSecrets,
322        Option<CodexRunTokenGuard>,
323    ),
324    AgentError,
325> {
326    if !executor_uses_bamboo_codex(executor) {
327        return Ok((bamboo_subagent::proto::RunSecrets::default(), None));
328    }
329
330    let authority = authority.ok_or_else(|| {
331        AgentError::LLM(
332            "Codex auth mode 'bamboo' requires the server per-run token authority".to_string(),
333        )
334    })?;
335    let issued = authority
336        .issue(child_session_id)
337        .map_err(|error| AgentError::LLM(format!("mint Codex per-run provider token: {error}")))?;
338    let guard = CodexRunTokenGuard {
339        authority,
340        token_id: issued.token_id,
341    };
342    Ok((
343        bamboo_subagent::proto::RunSecrets {
344            codex_provider_token: Some(bamboo_subagent::proto::SecretValue::new(issued.token)),
345        },
346        Some(guard),
347    ))
348}
349
350/// A warm worker on the mailbox bus, parked for reuse between runs. It stays
351/// dialed-in + subscribed to `mailbox_id`; the next interchangeable child
352/// delivers its `Run` there instead of spawning a fresh process. Dropping it
353/// kills a local kill-on-drop subprocess; a remote / schedulable handle is
354/// process-less (`kill()` is a no-op — it self-manages via its idle timeout).
355struct PooledWorker {
356    worker: SpawnedChild,
357    /// The bus mailbox this worker subscribes to (where its `Run`s are delivered).
358    mailbox_id: String,
359    /// Set only while the worker is parked. Checked both at checkout and by the
360    /// background pool sweep; cleared before the worker is handed to a run.
361    parked_at: Option<Instant>,
362}
363
364type WorkerPool = HashMap<String, Vec<PooledWorker>>;
365
366#[derive(Debug, Clone, Copy, PartialEq, Eq)]
367enum PoolReapReason {
368    ProcessExited,
369    IdleTimeout,
370}
371
372impl PoolReapReason {
373    fn as_str(self) -> &'static str {
374        match self {
375            Self::ProcessExited => "process_exited_while_parked",
376            Self::IdleTimeout => "pool_idle_timeout",
377        }
378    }
379}
380
381fn parked_worker_expired(parked_at: Option<Instant>, now: Instant, timeout: Duration) -> bool {
382    parked_at.is_some_and(|parked_at| now.saturating_duration_since(parked_at) >= timeout)
383}
384
385fn idle_pool_limit_reason(
386    pool: &WorkerPool,
387    key: &str,
388    max_per_key: usize,
389    max_total: usize,
390) -> Option<&'static str> {
391    if pool.get(key).map_or(0, Vec::len) >= max_per_key {
392        Some("pool_per_key_limit")
393    } else if pool.values().map(Vec::len).sum::<usize>() >= max_total {
394        Some("pool_global_limit")
395    } else {
396        None
397    }
398}
399
400fn take_reapable_workers(
401    pool: &mut WorkerPool,
402    now: Instant,
403    timeout: Duration,
404) -> Vec<(String, PoolReapReason, PooledWorker)> {
405    let mut reaped = Vec::new();
406    for (key, bucket) in pool.iter_mut() {
407        let mut retained = Vec::with_capacity(bucket.len());
408        for mut worker in std::mem::take(bucket) {
409            let reason = if !worker.worker.is_alive() {
410                Some(PoolReapReason::ProcessExited)
411            } else if parked_worker_expired(worker.parked_at, now, timeout) {
412                Some(PoolReapReason::IdleTimeout)
413            } else {
414                None
415            };
416            if let Some(reason) = reason {
417                reaped.push((key.clone(), reason, worker));
418            } else {
419                retained.push(worker);
420            }
421        }
422        *bucket = retained;
423    }
424    pool.retain(|_, bucket| !bucket.is_empty());
425    reaped
426}
427
428async fn reap_worker_pool_once(pool: &Arc<tokio::sync::Mutex<WorkerPool>>) -> usize {
429    let now = Instant::now();
430    let timeout = Duration::from_secs(POOLED_IDLE_TIMEOUT_SECS);
431    let reaped = {
432        let mut pool = pool.lock().await;
433        take_reapable_workers(&mut pool, now, timeout)
434    };
435    let count = reaped.len();
436    for (pool_key, reason, worker) in reaped {
437        let idle_ms = worker
438            .parked_at
439            .map(|parked_at| now.saturating_duration_since(parked_at).as_millis() as u64);
440        tracing::info!(
441            pool_key,
442            mailbox_id = %worker.mailbox_id,
443            worker_pid = ?worker.worker.pid(),
444            worker_idle_ms = ?idle_ms,
445            shutdown_reason = reason.as_str(),
446            "reaping parked sub-agent worker"
447        );
448        worker.worker.kill().await;
449    }
450    count
451}
452
453/// A role pinned to a remote resident worker (remote-actor-plan §3.4 / P1.5,
454/// #193), resolved at runner-build time from `SubagentsConfig.remote_placements`:
455/// the env-named bearer is already READ into `token` here (the raw token never
456/// rides the config), and `ca_cert_file` is the path to a PEM pinning a
457/// self-signed worker cert (`None` ⇒ default webpki roots / plaintext `ws://`).
458#[derive(Debug, Clone)]
459pub struct ResolvedRemotePlacement {
460    pub endpoint: String,
461    pub token: Option<String>,
462    pub ca_cert_file: Option<PathBuf>,
463    /// Display name for the machine this role runs on — the matching cluster
464    /// node's `label`/host, surfaced on the UI placement badge. `None` ⇒ derive
465    /// from the endpoint host.
466    pub host_label: Option<String>,
467}
468
469/// A role routed to a SCHEDULED worker (remote-actor-plan §3.4 / P2b, #181),
470/// resolved at runner-build time from `SubagentsConfig.schedulable_placements`.
471/// Names the logical `pool` (= the bus role) whose LIVE connected workers are the
472/// scheduling candidates — the runner picks one via the bus presence query
473/// (`BrokerClient::list_connected`). Phase 3 retired the old HTTP registry, so a
474/// pool is now just a role on the bus.
475#[derive(Debug, Clone)]
476pub struct ResolvedSchedulablePlacement {
477    pub pool: String,
478    /// Display name for the machine this pool's workers run on — the matching
479    /// cluster node's `label`/host, surfaced on the UI placement badge. `None` ⇒
480    /// fall back to the pool name.
481    pub host_label: Option<String>,
482}
483
484/// How `execute_external_child` should obtain its worker connection, decided
485/// once from `spec.placement`. Splits the divergent acquire/connect + retire
486/// logic three ways while the shared middle (Run dispatch, live registration,
487/// drive, close) stays identical. `Local` is the unchanged pre-#193 path;
488/// `Remote` is the unchanged #194 path; `Schedulable` (#181, P2b) is new.
489enum PlacementKind {
490    Local,
491    Remote,
492    Schedulable,
493}
494
495/// A read-only activation needs an executor and worker whose tool surface and
496/// no-shell checker enforce the typed capability. Codex's read-only sandbox
497/// prevents writes but still permits command execution, so neither Codex mode
498/// can currently satisfy the no-shell contract. Local Bamboo/Claude workers are
499/// fingerprinted by their baked fields, while remote/schedulable placements
500/// connect to an already-provisioned resident and [`RunSpec`] currently carries
501/// no equivalent per-activation authority. Fail closed before provisioning or
502/// dispatch instead of treating either posture as read-only enforcement.
503fn ensure_read_only_activation_is_enforceable(spec: &ProvisionSpec) -> Result<(), AgentError> {
504    if !spec.capabilities.read_only_enforced() {
505        return Ok(());
506    }
507
508    if matches!(spec.executor, ExecutorSpec::Codex { .. }) {
509        return Err(AgentError::LLM(format!(
510            "read-only actor role '{}' cannot use the Codex executor because its read-only sandbox still permits command execution; use bamboo_runtime or claude_code",
511            spec.identity.role
512        )));
513    }
514
515    if !matches!(spec.placement, Placement::Local) {
516        return Err(AgentError::LLM(format!(
517            "read-only actor role '{}' requires local placement; remote and schedulable resident workers cannot verify this activation's read-only tool boundary",
518            spec.identity.role
519        )));
520    }
521
522    Ok(())
523}
524
525/// Spawns and drives a child session as an independent actor: a `bamboo-subagent` worker process.
526pub struct ActorChildRunner {
527    approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
528    permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
529    agent_id: String,
530    worker_bin: PathBuf,
531    worker_args: Vec<String>,
532    fabric_dir: PathBuf,
533    executor: ExecutorSpec,
534    /// Per-provider credentials snapshotted from the parent config at build
535    /// time; the spec carries only the ONE the child's provider needs.
536    credentials: Vec<ScopedCredential>,
537    /// Parent's default provider (used when the child has no explicit one).
538    default_provider: String,
539    /// The mailbox bus to run local children over (the unified transport). Local
540    /// sub-agents require it; `None` only when no broker could be embedded.
541    bus: Option<bamboo_subagent::BusEndpoint>,
542    /// Backpressure: bounds the number of concurrently *running* actors; further
543    /// runs wait for a slot instead of exploding the process table. (Idle pooled
544    /// workers do not hold a slot.)
545    concurrency: std::sync::Arc<tokio::sync::Semaphore>,
546    /// Warm-worker pool keyed by a reuse fingerprint
547    /// (role/provider/model/workspace/disabled-tools/baked-caps). A finished run
548    /// parks its bus worker here so the next interchangeable child reuses it
549    /// (delivers its `Run` to the same mailbox) instead of spawning a fresh
550    /// process — collapsing N sibling sub-agents onto a few warm workers.
551    pool: Arc<tokio::sync::Mutex<WorkerPool>>,
552    max_idle_per_key: usize,
553    max_idle_total: usize,
554    pool_reaper_started: AtomicBool,
555    /// Monotonic fencing token for concrete worker attempts in this runtime.
556    /// `activation_run_id` fences across runtime restarts; this epoch also
557    /// fences a late first worker when an activation is retried in-place.
558    next_execution_epoch: AtomicU64,
559    /// Host-side decision for a child's gated-tool approval request (Phase 2).
560    /// `None` ⇒ fail-closed DENY (the safe default). A wired decider (policy or
561    /// human-routing bridge) returns approve/deny over the actor WS.
562    approval_decider: Option<Arc<dyn ChildApprovalDecider>>,
563    /// Off-loop parent-agent reviewer for forced-ask requests. The root server
564    /// wires a session-aware reviewer; nested workers wire their owning model
565    /// reviewer directly into the per-run runner.
566    approval_reviewer: Option<Arc<dyn ChildApprovalReviewer>>,
567    /// Per-run escalation host bridge for non-bypass child-approval routing (#68;
568    /// Phase 6, Part B). The owning worker's `run()` installs its OWN host bridge
569    /// here via `set_escalation_bridge`; `execute_external_child` CAPTURES it at
570    /// grandchild-spawn time and hands the owned value to `drive()`, which uses it
571    /// to RE-PROXY a child's approval request UP to the parent run — chaining up
572    /// every level until a bypass level (model-review) or the top orchestrator
573    /// (human) decides, then relaying the reply back down. Was a process-global
574    /// slot; now per-runner so a fire-and-forget grandchild that OUTLIVES the run
575    /// that spawned it keeps that run's bridge for its whole lifetime instead of
576    /// reading a stale/overwritten global at approval time (→ fail-closed deny).
577    escalation_bridge: Arc<std::sync::Mutex<Option<bamboo_subagent::executor::HostBridge>>>,
578    /// Roles pinned to a REMOTE resident worker (#193), keyed by sub-agent role
579    /// (the child's `subagent_type`). A role present here routes through the
580    /// dedicated remote branch in `execute_external_child` (Bearer-authenticated
581    /// `wss://` connect, no spawn, no pool, no kill) instead of the local
582    /// subprocess + warm-pool path. Empty (the default) = all-local behavior.
583    remote_placements: HashMap<String, ResolvedRemotePlacement>,
584    /// Roles routed to a REGISTRY-SCHEDULED worker (#181, P2b), keyed by sub-agent
585    /// role. A role present here (AND not already in `remote_placements`, which
586    /// wins) routes through the dedicated SCHEDULABLE branch in
587    /// `execute_external_child`: query the registry for live workers in the pool,
588    /// pick one (round-robin), connect over `wss://` — no spawn, no pool, no kill,
589    /// and NO local-subprocess fallback (no live worker ⇒ a clear error). Empty
590    /// (the default) = all-local behavior.
591    schedulable_placements: HashMap<String, ResolvedSchedulablePlacement>,
592    /// Per-pool round-robin cursor for schedulable scheduling (#181, P2b). Bumped
593    /// once per pick so successive sibling spawns SPREAD across a pool's live
594    /// workers instead of all landing on the first candidate. Best-effort spread,
595    /// not a load balancer — the registry's live set can change between picks.
596    schedule_cursor: Arc<std::sync::Mutex<HashMap<String, usize>>>,
597    /// Optional server authority used only by `Codex` in `bamboo` auth mode.
598    codex_run_tokens: Option<Arc<dyn CodexRunTokenAuthority>>,
599    /// Canonical logical-session inbox resources, late-bound by each owning
600    /// runtime. Kept per runner/runtime; never process-global.
601    session_inbox_runtime: Arc<std::sync::Mutex<Option<SessionInboxRuntimeBinding>>>,
602}
603
604/// Decides how the host answers a child worker's gated-tool approval request
605/// (Phase 2: child → parent approval delegation). Async so an implementation
606/// can consult a policy. With no decider wired the host replies with a
607/// fail-closed DENY.
608///
609/// NOTE: `decide` is awaited inside the per-child frame pump, so an
610/// implementation must resolve promptly (e.g. a policy lookup). Model-based
611/// review belongs in [`ChildApprovalReviewer`], which runs off-loop and returns
612/// through the live steering channel without stalling the frame pump.
613#[async_trait]
614pub trait ChildApprovalDecider: Send + Sync {
615    /// Decide whether `child_session_id` may perform the gated action described
616    /// by `request` (`{tool_name, permission, resource}`).
617    async fn decide(&self, child_session_id: &str, request: &serde_json::Value) -> bool;
618}
619
620/// Resolve a child approval request to approve/deny. Fail-closed (DENY) when no
621/// decider is wired — the single, testable seam for the host-side decision.
622async fn decide_child_approval(
623    decider: Option<&Arc<dyn ChildApprovalDecider>>,
624    child_session_id: &str,
625    request: &serde_json::Value,
626) -> bool {
627    match decider {
628        Some(decider) => decider.decide(child_session_id, request).await,
629        None => false,
630    }
631}
632
633/// How long a chained parent-agent review may take before the child's gated
634/// tool fails closed (DENY). Bounds an unanswered request so it cannot hang the
635/// worker indefinitely.
636const CHILD_APPROVAL_TIMEOUT: Duration = Duration::from_secs(300);
637
638/// Off-loop reviewer for a child's gated-tool approval request (Phase 6, Part B).
639///
640/// Installed (process-global) by a BYPASSED self-orchestrating worker so its
641/// children's forced-ask (dangerous) gated actions — which still raise
642/// `ConfirmationRequired` even under bypass — get an LLM reasonableness check
643/// rather than a blind pass. `review` is an LLM call: `drive()` invokes it in a
644/// SPAWNED task (NEVER in the frame pump) and delivers the verdict async via the
645/// live channel, so the agent loop is never blocked.
646#[async_trait]
647pub trait ChildApprovalReviewer: Send + Sync {
648    /// Judge whether the gated action `request` (`{tool_name, permission,
649    /// resource}`) is reasonable for `child_session_id`'s task. `true` = approve.
650    async fn review(
651        &self,
652        parent_session_id: &str,
653        child_session_id: &str,
654        request: &serde_json::Value,
655    ) -> bool;
656}
657
658fn child_approval_reviewer_slot() -> &'static std::sync::OnceLock<Arc<dyn ChildApprovalReviewer>> {
659    static SLOT: std::sync::OnceLock<Arc<dyn ChildApprovalReviewer>> = std::sync::OnceLock::new();
660    &SLOT
661}
662
663/// Install the process-global child-approval reviewer (idempotent; first wins).
664pub fn set_child_approval_reviewer(reviewer: Arc<dyn ChildApprovalReviewer>) {
665    let _ = child_approval_reviewer_slot().set(reviewer);
666}
667
668/// The process-global child-approval reviewer, if installed.
669pub fn child_approval_reviewer() -> Option<Arc<dyn ChildApprovalReviewer>> {
670    child_approval_reviewer_slot().get().cloned()
671}
672
673impl ActorChildRunner {
674    #[allow(clippy::too_many_arguments)]
675    pub fn new(
676        agent_id: String,
677        worker_bin: PathBuf,
678        worker_args: Vec<String>,
679        fabric_dir: PathBuf,
680        executor: ExecutorSpec,
681        credentials: Vec<ScopedCredential>,
682        default_provider: String,
683        max_concurrent: usize,
684    ) -> Self {
685        Self {
686            approval_registry: None,
687            permission_config: None,
688            agent_id,
689            worker_bin,
690            worker_args,
691            fabric_dir,
692            executor,
693            credentials,
694            default_provider,
695            bus: None,
696            concurrency: std::sync::Arc::new(tokio::sync::Semaphore::new(max_concurrent.max(1))),
697            pool: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
698            max_idle_per_key: DEFAULT_MAX_IDLE_PER_KEY,
699            max_idle_total: DEFAULT_MAX_IDLE_TOTAL,
700            pool_reaper_started: AtomicBool::new(false),
701            next_execution_epoch: AtomicU64::new(0),
702            approval_decider: None,
703            approval_reviewer: None,
704            escalation_bridge: Arc::new(std::sync::Mutex::new(None)),
705            remote_placements: HashMap::new(),
706            schedulable_placements: HashMap::new(),
707            schedule_cursor: Arc::new(std::sync::Mutex::new(HashMap::new())),
708            codex_run_tokens: None,
709            session_inbox_runtime: Arc::new(std::sync::Mutex::new(None)),
710        }
711    }
712
713    pub fn with_approval_registry(
714        mut self,
715        registry: super::approval_registry::SharedApprovalRegistry,
716    ) -> Self {
717        self.approval_registry = Some(registry);
718        self
719    }
720
721    pub fn with_permission_config(
722        mut self,
723        config: Arc<bamboo_tools::permission::PermissionConfig>,
724    ) -> Self {
725        self.permission_config = Some(config);
726        self
727    }
728
729    /// Run children over the mailbox bus (the unified actor+mailbox transport).
730    /// When set, local children dial this bus and are driven by mailbox id; when
731    /// unset they use the legacy direct-WS path. The server passes its in-process
732    /// broker here (`subagents.broker`); tests without a broker leave it unset.
733    pub fn with_bus(mut self, bus: Option<bamboo_subagent::BusEndpoint>) -> Self {
734        self.bus = bus.filter(|b| !b.endpoint.trim().is_empty());
735        self
736    }
737
738    /// Wire the host-side decider for child gated-tool approval requests
739    /// (Phase 2). Without this the host fail-closed DENYs every request.
740    pub fn with_approval_decider(mut self, decider: Arc<dyn ChildApprovalDecider>) -> Self {
741        self.approval_decider = Some(decider);
742        self
743    }
744
745    pub fn with_approval_reviewer(mut self, reviewer: Arc<dyn ChildApprovalReviewer>) -> Self {
746        self.approval_reviewer = Some(reviewer);
747        self
748    }
749
750    pub fn with_codex_run_tokens(
751        mut self,
752        authority: Option<Arc<dyn CodexRunTokenAuthority>>,
753    ) -> Self {
754        self.codex_run_tokens = authority;
755        self
756    }
757
758    /// Pin specific sub-agent roles to remote resident workers (#193). The map
759    /// is keyed by role (`subagent_type`); a child whose role is present connects
760    /// over `wss://` to the resolved endpoint instead of spawning a local
761    /// subprocess. Default (empty) keeps every role on the local path — exactly
762    /// today's behavior.
763    pub fn with_remote_placements(
764        mut self,
765        placements: HashMap<String, ResolvedRemotePlacement>,
766    ) -> Self {
767        self.remote_placements = placements;
768        self
769    }
770
771    /// Route specific sub-agent roles to a registry-SCHEDULED worker (#181, P2b).
772    /// The map is keyed by role (`subagent_type`); a child whose role is present
773    /// (and NOT already pinned by `remote_placements`, which takes precedence) is
774    /// run on a live worker discovered from the registry instead of a local
775    /// subprocess. Default (empty) keeps every role on the local path.
776    pub fn with_schedulable_placements(
777        mut self,
778        placements: HashMap<String, ResolvedSchedulablePlacement>,
779    ) -> Self {
780        self.schedulable_placements = placements;
781        self
782    }
783
784    /// Reuse fingerprint: two children are interchangeable on one warm worker iff
785    /// they share role, provider, model, workspace, disabled-tool set, AND every
786    /// capability the worker BAKES at provision time (`BambooRuntimeExecutor`
787    /// stamps these once and reuses them across runs): nesting depth, nested-spawn
788    /// stack, requested/effective permission modes, legacy bypass/auto flags,
789    /// permission enforcement, and the depth cap. Omitting any
790    /// of these lets the pool hand a run a worker baked for a DIFFERENT posture —
791    /// e.g. a depth-1 worker (with its own spawn stack) reused for a depth-4
792    /// child would re-stamp `spawn_depth=1` and pass the depth-cap check, breaking
793    /// the recursion bound; or a bypass worker reused for a non-bypass child. So
794    /// these MUST split the pool bucket. Everything else (assignment, history) is
795    /// shipped per-run in the `RunSpec` and does not affect the fingerprint.
796    /// Reuse fingerprint (role/provider/model/workspace/disabled-tools/baked
797    /// caps): two children with the same fingerprint are interchangeable on one
798    /// warm worker, so they share a pool bucket. Any axis the worker bakes ONCE
799    /// at provision time MUST be in here, else a worker baked for one posture
800    /// gets reused for another (see the `fingerprint_*` tests).
801    fn fingerprint(spec: &ProvisionSpec) -> String {
802        let role = spec.identity.role.as_str();
803        let (provider, model) = spec
804            .model
805            .as_ref()
806            .map(|m| (m.provider.as_str(), m.model.as_str()))
807            .unwrap_or(("", ""));
808        let workspace = spec.workspace.as_deref().unwrap_or("");
809        let mut tools = spec.disabled_tools.clone().unwrap_or_default();
810        tools.sort();
811        let caps = &spec.capabilities;
812        // The worker constructs its executor exactly once. In particular,
813        // Codex exec and app-server workers are not interchangeable.
814        let executor = serde_json::to_string(&spec.executor).unwrap_or_default();
815        format!(
816            "{role}\u{1}{provider}\u{1}{model}\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}executor={executor}",
817            tools.join(","),
818            spec.identity.depth,
819            caps.nested_spawn,
820            caps.permission_requested_mode,
821            caps.permission_effective_mode,
822            caps.bypass,
823            caps.auto_approve_permissions,
824            caps.enforce_permissions,
825            caps.max_spawn_depth.unwrap_or(0),
826            // #73 review (P1): a worker bakes `no_human_review` ONCE from this flag
827            // at build() and never re-reads it per run, so the pool MUST NOT hand a
828            // worker baked for one approval posture to a run of the opposite one —
829            // else a scheduled-root worker reused for an interactive child would
830            // silently model-review instead of asking the human (and vice-versa,
831            // reintroducing the 300s-deny). Split the bucket on it.
832            caps.no_human_approver,
833            // The read-only no-shell checker is baked once at build(), so a planner
834            // or Guardian worker must NOT be reused for an ordinary child.
835            caps.read_only,
836            // Preserve the legacy Guardian bit as a distinct fingerprint axis
837            // during rolling upgrades even though both bits enforce read-only.
838            caps.guardian_read_only,
839        )
840    }
841
842    /// Start one weakly-owned sweep task for this runner. It does not keep the
843    /// runner/pool alive, and it never holds the pool lock while killing a child.
844    fn ensure_pool_reaper(&self) {
845        if self
846            .pool_reaper_started
847            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
848            .is_err()
849        {
850            return;
851        }
852        let pool = Arc::downgrade(&self.pool);
853        tokio::spawn(async move {
854            let mut interval = tokio::time::interval(POOLED_REAPER_INTERVAL);
855            interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
856            // Tokio intervals tick immediately once; consume that tick so a new
857            // runner does not scan an empty pool before its first worker parks.
858            interval.tick().await;
859            loop {
860                interval.tick().await;
861                let Some(pool) = pool.upgrade() else {
862                    break;
863                };
864                let _ = reap_worker_pool_once(&pool).await;
865            }
866        });
867    }
868
869    /// Check out a warm bus worker for `key`, reusing a live parked one if any,
870    /// else spawning a fresh one that dials the bus. The returned worker is OWNED
871    /// by the caller for the run's duration (checkout removes it from the pool, so
872    /// a concurrent sibling gets a different worker or spawns its own — one run per
873    /// worker at a time, matching the pre-bus pool semantics).
874    async fn acquire_bus_worker(
875        &self,
876        key: &str,
877        spec: &ProvisionSpec,
878    ) -> crate::runtime::runner::Result<PooledWorker> {
879        self.ensure_pool_reaper();
880        // Drain the bucket, skipping (and reaping) any worker whose process exited
881        // or crossed its idle deadline while parked. A live, fresh one is handed
882        // straight out for reuse.
883        loop {
884            let candidate = {
885                let mut pool = self.pool.lock().await;
886                let candidate = pool.get_mut(key).and_then(|bucket| bucket.pop());
887                if pool.get(key).is_some_and(Vec::is_empty) {
888                    pool.remove(key);
889                }
890                candidate
891            };
892            let Some(mut candidate) = candidate else {
893                break;
894            };
895            let now = Instant::now();
896            let expired = parked_worker_expired(
897                candidate.parked_at,
898                now,
899                Duration::from_secs(POOLED_IDLE_TIMEOUT_SECS),
900            );
901            if candidate.worker.is_alive() && !expired {
902                candidate.parked_at = None;
903                return Ok(candidate);
904            }
905            tracing::info!(
906                pool_key = key,
907                mailbox_id = %candidate.mailbox_id,
908                worker_pid = ?candidate.worker.pid(),
909                shutdown_reason = if expired {
910                    PoolReapReason::IdleTimeout.as_str()
911                } else {
912                    PoolReapReason::ProcessExited.as_str()
913                },
914                "discarding unusable parked sub-agent worker during checkout"
915            );
916            candidate.worker.kill().await;
917        }
918
919        let spawned = spawn_worker_on_bus(&self.worker_bin, &self.worker_args, spec)
920            .await
921            .map_err(|e| AgentError::LLM(format!("actor spawn (bus) failed: {e}")))?;
922        let mailbox_id = spawned.record.agent_id.clone();
923        Ok(PooledWorker {
924            worker: spawned,
925            mailbox_id,
926            parked_at: None,
927        })
928    }
929
930    /// Park a warm bus worker for reuse after a clean run; if its bucket is full
931    /// (or it died), kill it instead. The worker stays dialed-in + subscribed
932    /// while parked, so a reusing child just delivers a new `Run` to its mailbox.
933    async fn release_bus_worker(&self, key: &str, mut worker: PooledWorker) {
934        self.ensure_pool_reaper();
935        if !worker.worker.is_alive() {
936            tracing::info!(
937                pool_key = key,
938                mailbox_id = %worker.mailbox_id,
939                worker_pid = ?worker.worker.pid(),
940                shutdown_reason = "process_exited_before_park",
941                "discarding sub-agent worker instead of parking"
942            );
943            worker.worker.kill().await;
944            return;
945        }
946        worker.parked_at = Some(Instant::now());
947        let mut pool = self.pool.lock().await;
948        let key_count = pool.get(key).map_or(0, Vec::len);
949        let total_count = pool.values().map(Vec::len).sum::<usize>();
950        let limit_reason =
951            idle_pool_limit_reason(&pool, key, self.max_idle_per_key, self.max_idle_total);
952        if let Some(shutdown_reason) = limit_reason {
953            drop(pool);
954            tracing::info!(
955                pool_key = key,
956                mailbox_id = %worker.mailbox_id,
957                worker_pid = ?worker.worker.pid(),
958                idle_workers_for_key = key_count,
959                idle_workers_total = total_count,
960                max_idle_per_key = self.max_idle_per_key,
961                max_idle_total = self.max_idle_total,
962                shutdown_reason,
963                "discarding sub-agent worker because the warm pool is full"
964            );
965            worker.worker.kill().await;
966            return;
967        }
968        pool.entry(key.to_string()).or_default().push(worker);
969    }
970
971    /// Assemble the parent-resolved provisioning document for this child.
972    fn build_spec(&self, session: &Session, job: &SpawnJob) -> ProvisionSpec {
973        let mut spec = ProvisionSpec::new(
974            ChildIdentity {
975                child_id: job.child_session_id.clone(),
976                parent_id: Some(job.parent_session_id.clone()),
977                project_key: None,
978                role: session
979                    .metadata
980                    .get("subagent_type")
981                    .cloned()
982                    .unwrap_or_else(|| "worker".to_string()),
983                // The child session already carries the correct depth
984                // (create_child_action's new_child_of did parent.spawn_depth+1);
985                // stamp it so the worker can re-establish it on its run session
986                // and enforce the max-depth cap across the actor boundary.
987                depth: session.spawn_depth,
988            },
989            self.executor.clone(),
990            self.fabric_dir.to_string_lossy().into_owned(),
991        );
992        spec.workspace = session.workspace.clone();
993        if let ExecutorSpec::Codex {
994            workspace_owned, ..
995        } = &mut spec.executor
996        {
997            *workspace_owned = Some(
998                spec.workspace
999                    .as_deref()
1000                    .is_some_and(workspace_is_bamboo_owned),
1001            );
1002        }
1003        // Unified transport: when a bus is configured, the child dials it (no
1004        // listen socket / file discovery) and the parent drives it by mailbox id.
1005        spec.bus = self.bus.clone();
1006        // Final model: the session's pinned model_ref (create.model / routing already applied),
1007        // falling back to the job's bare model on the parent's default provider.
1008        spec.model = session
1009            .model_ref
1010            .as_ref()
1011            .map(|r| ModelRefSpec {
1012                provider: r.provider.clone(),
1013                model: r.model.clone(),
1014            })
1015            .or_else(|| {
1016                let m = job.model.trim();
1017                (!m.is_empty()).then(|| ModelRefSpec {
1018                    provider: self.default_provider.clone(),
1019                    model: m.to_string(),
1020                })
1021            });
1022        spec.disabled_tools = job.disabled_tools.clone();
1023        match &spec.executor {
1024            // Codex auth is independent of the session's normal Bamboo model
1025            // provider. Inherit/API-key/Bamboo modes need no credential-store
1026            // secret at provisioning; custom mode gets exactly its referenced
1027            // key. This prevents an unrelated upstream provider key from
1028            // reaching a Codex worker that only needs a per-run bcx1_ token.
1029            ExecutorSpec::Codex {
1030                auth_mode,
1031                provider_key_ref,
1032                ..
1033            } => {
1034                if auth_mode.as_deref() == Some("custom") {
1035                    if let Some(reference) = provider_key_ref {
1036                        if let Some(credential) = self.credentials.iter().find(|credential| {
1037                            credential.credential_ref.as_deref() == Some(reference)
1038                        }) {
1039                            spec.secrets.provider_credentials.push(credential.clone());
1040                        } else {
1041                            tracing::warn!(
1042                                "actor child {}: custom Codex credential reference '{}' did not resolve",
1043                                job.child_session_id,
1044                                reference
1045                            );
1046                        }
1047                    } else {
1048                        tracing::warn!(
1049                            "actor child {}: custom Codex executor has no credential reference",
1050                            job.child_session_id
1051                        );
1052                    }
1053                }
1054            }
1055            // Other executors keep the existing least-privilege contract: only
1056            // the credential for the child session's selected provider.
1057            _ => {
1058                let provider = spec
1059                    .model
1060                    .as_ref()
1061                    .map(|model| model.provider.as_str())
1062                    .filter(|provider| !provider.trim().is_empty())
1063                    .unwrap_or(&self.default_provider);
1064                if let Some(credential) = self
1065                    .credentials
1066                    .iter()
1067                    .find(|credential| credential.provider == provider)
1068                {
1069                    spec.secrets.provider_credentials.push(credential.clone());
1070                } else {
1071                    tracing::warn!(
1072                        "actor child {}: no credential found for provider '{}'",
1073                        job.child_session_id,
1074                        provider
1075                    );
1076                }
1077            }
1078        }
1079        // Phase 6 (direct nested execution): a worker BELOW the depth cap may
1080        // orchestrate its OWN children — on startup it builds its own spawn
1081        // stack and runs the real SubAgent tool (no host proxy). The cap (the
1082        // SubAgent tool refuses to spawn at/over `max_spawn_depth`) bounds the
1083        // recursion. Driven purely by the child's depth, so it auto-propagates
1084        // down the tree without any extra config threading.
1085        spec.capabilities.nested_spawn = session.spawn_depth < MAX_SPAWN_DEPTH;
1086        spec.capabilities.max_spawn_depth = Some(MAX_SPAWN_DEPTH);
1087        // #69: activate child-approval review. Sub-agents enforce permissions so
1088        // their DANGEROUS actions (the worker uses a HIGH threshold) reach the
1089        // parent for review — escalated to the human, or model-reviewed off-loop
1090        // when the parent is in bypass. The worker installs no checker without
1091        // this, so the whole review chain would otherwise stay dormant.
1092        spec.capabilities.enforce_permissions = true;
1093        // Propagate "bypass permissions" so a self-orchestrating worker knows it
1094        // is a bypassed parent and installs the off-loop model-reviewer for its
1095        // children's forced-ask actions (Phase 6, Part B). The child session
1096        // already carries the inherited flag (create_child_action seeds it).
1097        let requested_permission_mode = session
1098            .agent_runtime_state
1099            .as_ref()
1100            .map(|state| state.effective_permission_mode())
1101            .unwrap_or_default();
1102        let configured_permission_mode = self
1103            .permission_config
1104            .as_ref()
1105            .map(|config| config.mode())
1106            .unwrap_or_default();
1107        // #73: propagate "no interactive human approver" (headless / scheduled /
1108        // deployed root, inherited by the child session). When set, the worker's
1109        // per-run approval proxy model-reviews a gated action locally instead of
1110        // escalating to a human who will never answer (which would 300s-deny).
1111        spec.capabilities.no_human_approver = session
1112            .agent_runtime_state
1113            .as_ref()
1114            .is_some_and(|s| s.no_human_approver);
1115        // Read-only authority comes from the child's typed runtime state, never
1116        // its cosmetic role label. The sole legacy recovery path additionally
1117        // requires the old Guardian role AND its host-owned mutation denylist,
1118        // preserving in-flight pre-upgrade reviewers without making a label an
1119        // authority boundary.
1120        let typed_read_only = session
1121            .agent_runtime_state
1122            .as_ref()
1123            .is_some_and(|state| state.read_only);
1124        let legacy_guardian_read_only = spec.identity.role == "guardian"
1125            && spec.disabled_tools.as_ref().is_some_and(|disabled| {
1126                ["Edit", "Write", "SubAgent", "WebFetch"]
1127                    .iter()
1128                    .all(|name| disabled.iter().any(|disabled_name| disabled_name == *name))
1129            });
1130        let read_only = typed_read_only || legacy_guardian_read_only;
1131        spec.capabilities.read_only = read_only;
1132        // Emit the old field for every read-only child. Older workers ignore
1133        // `read_only` but understand this bit and will install the strict
1134        // no-shell checker, which prevents a rolling upgrade from weakening Plan.
1135        spec.capabilities.guardian_read_only = read_only;
1136        if spec.capabilities.read_only_enforced() {
1137            // Do not even construct nested delegation machinery for a read-only
1138            // inspection child; the denylist below remains a second boundary.
1139            spec.capabilities.nested_spawn = false;
1140            // Treat the capability as the authority boundary, not as a hint
1141            // that every caller must remember to pair with a schema filter.
1142            // Preserve caller-specific disables while always removing Bamboo's
1143            // shell, mutating, persistent, interactive, and delegation surfaces.
1144            spec.disabled_tools
1145                .get_or_insert_with(Default::default)
1146                .extend(crate::runtime::guardian_state::read_only_child_disabled_tools());
1147            match &mut spec.executor {
1148                ExecutorSpec::ClaudeCode {
1149                    permission_mode,
1150                    inherit_user_config,
1151                    ..
1152                } => {
1153                    *permission_mode = Some("plan".to_string());
1154                    // User config may add MCP tools/hooks outside the provisioned
1155                    // surface, so it is never inherited by a read-only child.
1156                    *inherit_user_config = Some(false);
1157                }
1158                ExecutorSpec::Codex {
1159                    mode,
1160                    sandbox,
1161                    inherit_user_config,
1162                    approval_policy,
1163                    network_access,
1164                    allow_danger_bypass,
1165                    permission_profile,
1166                    ..
1167                } => {
1168                    *sandbox = Some("read-only".to_string());
1169                    *inherit_user_config = Some(false);
1170                    // app-server provisioning accepts `on-request`; the typed
1171                    // Plan activation maps that to `approvalPolicy=never`.
1172                    *approval_policy = Some(
1173                        if mode.as_deref() == Some("app_server") {
1174                            "on-request"
1175                        } else {
1176                            "never"
1177                        }
1178                        .to_string(),
1179                    );
1180                    *network_access = Some(false);
1181                    *allow_danger_bypass = Some(false);
1182                    *permission_profile = Some("read-only".to_string());
1183                }
1184                _ => {}
1185            }
1186        }
1187        let read_only_overlay = session
1188            .agent_runtime_state
1189            .as_ref()
1190            .is_some_and(|state| state.plan_mode.is_some())
1191            || spec.capabilities.read_only_enforced()
1192            || executor_has_read_only_permission_profile(&spec.executor);
1193        let permission_resolution = bamboo_domain::resolve_permission_mode_with_read_only(
1194            requested_permission_mode,
1195            configured_permission_mode,
1196            read_only_overlay,
1197        );
1198        spec.capabilities.bypass = permission_resolution.bypass_permissions();
1199        spec.capabilities.auto_approve_permissions =
1200            permission_resolution.suppress_approval_prompts();
1201        spec.capabilities.permission_requested_mode =
1202            permission_resolution.requested.as_str().to_string();
1203        spec.capabilities.permission_effective_mode =
1204            permission_resolution.effective.as_str().to_string();
1205        // #193: route this role to a REMOTE resident worker when one is pinned.
1206        // `spec.identity.role` was just computed from `subagent_type` above; a
1207        // match flips the placement to Remote and rides the worker's bearer on the
1208        // scoped secrets envelope (TLS handshake / Authorization header only — the
1209        // token is never logged). No match leaves the default `Placement::Local`,
1210        // so the local path is byte-for-byte unchanged for every non-pinned role.
1211        if let Some(placement) = self.remote_placements.get(spec.identity.role.as_str()) {
1212            spec.placement = Placement::Remote {
1213                endpoint: placement.endpoint.clone(),
1214            };
1215            spec.secrets.worker_auth_token = placement.token.clone();
1216        } else if let Some(placement) = self.schedulable_placements.get(spec.identity.role.as_str())
1217        {
1218            // #181 (P2b): route this role to a SCHEDULED worker — ONLY when it is
1219            // NOT already pinned to a fixed remote endpoint (the `else if` makes
1220            // remote_placements take precedence for a role in both). The concrete
1221            // worker is picked at run time in `execute_external_child` from the bus
1222            // (a live connected worker of the pool role). No per-placement bearer
1223            // now — the bus connection uses the bus token. No match in either map
1224            // leaves the default `Placement::Local`.
1225            spec.placement = Placement::Schedulable {
1226                pool: placement.pool.clone(),
1227            };
1228        }
1229        spec
1230    }
1231
1232    /// The `metadata["placement"]` JSON to stamp on a child from its resolved
1233    /// placement, preferring the matching cluster node's `host_label` (its
1234    /// operator label/host) over the raw endpoint/pool. `None` for a Local child
1235    /// (the DTO defaults it to the backend's own host). Split out of
1236    /// `execute_external_child` so the role→placement→host resolution is unit-testable.
1237    fn placement_stamp_for(&self, spec: &ProvisionSpec) -> Option<String> {
1238        let host_label = match &spec.placement {
1239            Placement::Remote { .. } => self
1240                .remote_placements
1241                .get(spec.identity.role.as_str())
1242                .and_then(|p| p.host_label.as_deref()),
1243            Placement::Schedulable { .. } => self
1244                .schedulable_placements
1245                .get(spec.identity.role.as_str())
1246                .and_then(|p| p.host_label.as_deref()),
1247            Placement::Local => None,
1248        };
1249        placement_metadata(&spec.placement, host_label)
1250    }
1251
1252    /// Pick a live worker for a SCHEDULABLE role from the BUS (#181, Phase 3):
1253    /// ask the broker which actors are connected serving the pool role (presence
1254    /// is connection-truth — no HTTP registry, no leases, no connect-fail
1255    /// failover), then round-robin one per resolve for spread. Returns the chosen
1256    /// worker's mailbox id. An empty pool ⇒ a terminal `AgentError` — NEVER a
1257    /// local-subprocess fallback (that would silently defeat the placement).
1258    async fn resolve_schedulable_worker(
1259        &self,
1260        role: &str,
1261    ) -> std::result::Result<String, AgentError> {
1262        let pool = self
1263            .schedulable_placements
1264            .get(role)
1265            .ok_or_else(|| {
1266                AgentError::LLM(format!(
1267                    "schedulable placement for role '{role}' vanished before scheduling"
1268                ))
1269            })?
1270            .pool
1271            .clone();
1272        let bus = self.bus.as_ref().ok_or_else(|| {
1273            AgentError::LLM(format!(
1274                "schedulable role '{role}': no mailbox bus configured (subagents.broker)"
1275            ))
1276        })?;
1277
1278        // Ask the BUS who is connected serving the pool role — presence is
1279        // connection-truth (no HTTP registry, no leases, no stale-record failover).
1280        let mut q = bamboo_broker::BrokerClient::connect(
1281            &bus.endpoint,
1282            bamboo_subagent::AgentRef {
1283                session_id: format!("sched-q-{role}"),
1284                role: None,
1285            },
1286            &bus.token,
1287        )
1288        .await
1289        .map_err(|e| {
1290            AgentError::LLM(format!(
1291                "schedulable role '{role}': bus connect failed: {e}"
1292            ))
1293        })?;
1294        let candidates = q.list_connected(&pool).await.map_err(|e| {
1295            AgentError::LLM(format!(
1296                "schedulable role '{role}': bus presence query failed: {e}"
1297            ))
1298        })?;
1299
1300        if candidates.is_empty() {
1301            return Err(AgentError::LLM(format!(
1302                "schedulable role '{role}': no live worker in pool '{pool}' on the bus \
1303                 (NOT spawning a local subprocess — a schedulable role has no local fallback)"
1304            )));
1305        }
1306
1307        // Round-robin: advance a per-pool cursor once per resolve so successive
1308        // sibling spawns spread across the connected pool workers. No failover
1309        // needed — a listed worker is connected NOW (the bus only lists live
1310        // subscribers), so there is no stale-but-leased candidate to skip.
1311        let idx = {
1312            let mut cursors = self.schedule_cursor.lock().recover_poison();
1313            let cursor = cursors.entry(pool.clone()).or_insert(0);
1314            let i = *cursor % candidates.len();
1315            *cursor = cursor.wrapping_add(1);
1316            i
1317        };
1318        Ok(candidates[idx].clone())
1319    }
1320}
1321
1322#[async_trait]
1323impl ExternalChildRunner for ActorChildRunner {
1324    async fn should_handle(&self, session: &Session) -> bool {
1325        session.metadata.get("runtime.kind") == Some(&"external".to_string())
1326            && session.metadata.get("external.protocol") == Some(&"actor".to_string())
1327            && session.metadata.get("external.agent_id") == Some(&self.agent_id)
1328    }
1329
1330    fn set_escalation_bridge(&self, bridge: Option<bamboo_subagent::executor::HostBridge>) {
1331        *self.escalation_bridge.lock().recover_poison() = bridge;
1332    }
1333
1334    fn set_session_inbox_runtime(&self, binding: Option<SessionInboxRuntimeBinding>) {
1335        *self.session_inbox_runtime.lock().recover_poison() = binding;
1336    }
1337
1338    async fn execute_external_child(
1339        &self,
1340        session: &mut Session,
1341        job: &SpawnJob,
1342        event_tx: mpsc::Sender<AgentEvent>,
1343        cancel_token: CancellationToken,
1344    ) -> crate::runtime::runner::Result<()> {
1345        // #68 CORRECTNESS CRUX: capture the per-run escalation bridge HERE, at the
1346        // moment this grandchild is spawned — while the parent run's bridge is
1347        // still in our slot — into an owned local handed to `drive()` for this
1348        // grandchild's whole lifetime. A fire-and-forget grandchild that OUTLIVES
1349        // the run that spawned it must NOT re-read `self.escalation_bridge` at
1350        // approval time: by then `run()` may have cleared/overwritten it (a worker
1351        // serves runs sequentially), and re-proxying through a closed bridge
1352        // fail-closed denies. Capturing at spawn pins the right bridge per run.
1353        let escalation = self.escalation_bridge.lock().recover_poison().clone();
1354        let session_inbox_runtime = self.session_inbox_runtime.lock().recover_poison().clone();
1355        let assignment = extract_assignment(session);
1356        let mut spec = self.build_spec(session, job);
1357        // Mark the worker reusable + give it an idle timeout so it self-reaps if
1358        // orphaned. Warm bus workers are pooled per fingerprint and reused.
1359        spec.reusable = true;
1360        if spec.limits.idle_timeout_secs.is_none() {
1361            spec.limits.idle_timeout_secs = Some(POOLED_IDLE_TIMEOUT_SECS);
1362        }
1363        ensure_read_only_activation_is_enforceable(&spec)?;
1364        let pool_key = Self::fingerprint(&spec);
1365
1366        // The recommended provider URL is deliberately parent-loopback. A
1367        // resident remote worker would interpret 127.0.0.1 as itself, not this
1368        // server, so reject that ambiguous deployment instead of minting a
1369        // credential that can never authenticate to the intended parent.
1370        if executor_uses_bamboo_codex(&spec.executor) && !matches!(spec.placement, Placement::Local)
1371        {
1372            return Err(AgentError::LLM(
1373                "Codex auth mode 'bamboo' requires local actor placement; use custom mode with a reachable URL for remote workers"
1374                    .to_string(),
1375            ));
1376        }
1377        let project_id = project_id_for_actor_run(session)?;
1378        let requested_permission_mode = session
1379            .agent_runtime_state
1380            .as_ref()
1381            .map(|state| state.effective_permission_mode())
1382            .unwrap_or_default();
1383        // Policy is captured per activation (not only when a worker is
1384        // provisioned), so reused local workers and resident remote/broker
1385        // workers observe the latest durable revision and bypass flag at the
1386        // next run boundary. Session grants are intentionally not inherited.
1387        let permission_policy = if let Some(config) = self.permission_config.as_ref() {
1388            ensure_no_active_scoped_session_denies(config, &session.id)?;
1389            let read_only_overlay = session
1390                .agent_runtime_state
1391                .as_ref()
1392                .is_some_and(|state| state.plan_mode.is_some())
1393                || spec.capabilities.read_only_enforced()
1394                || executor_has_read_only_permission_profile(&spec.executor);
1395            let resolution = bamboo_domain::resolve_permission_mode_with_read_only(
1396                requested_permission_mode,
1397                config.mode(),
1398                read_only_overlay,
1399            );
1400            let policy = serde_json::to_value(config.to_serializable()).map_err(|error| {
1401                AgentError::LLM(format!(
1402                    "failed to serialize permission policy for external executor: {error}"
1403                ))
1404            })?;
1405            Some(PermissionPolicyContext {
1406                revision: config.policy_revision(),
1407                requested_mode: resolution.requested.as_str().to_string(),
1408                effective_mode: resolution.effective.as_str().to_string(),
1409                bypass_permissions: resolution.bypass_permissions(),
1410                auto_approve_permissions: resolution.suppress_approval_prompts(),
1411                session_id: session.id.clone(),
1412                workspace_path: session.workspace.clone(),
1413                inherit_session_grants: false,
1414                policy,
1415            })
1416        } else {
1417            None
1418        };
1419        let provisioned_permission =
1420            spec.capabilities.permission_resolution().map_err(|error| {
1421                AgentError::LLM(format!("invalid provisioned permission posture: {error}"))
1422            })?;
1423        let policy_resolution = permission_policy
1424            .as_ref()
1425            .map(|context| {
1426                context.resolved_modes().map(|(requested, effective)| {
1427                    bamboo_domain::PermissionModeResolution {
1428                        requested,
1429                        effective,
1430                    }
1431                })
1432            })
1433            .transpose()
1434            .map_err(|error| {
1435                AgentError::LLM(format!("invalid host permission posture: {error}"))
1436            })?;
1437        let host_audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata);
1438        let has_explicit_deny = self.permission_config.as_ref().is_some_and(|config| {
1439            bamboo_tools::permission::explicit_deny_policy_reason(&config.to_serializable())
1440                .is_some()
1441        });
1442        let expected_executor_mapping = expected_permission_executor_mapping(
1443            &spec.executor,
1444            policy_resolution.unwrap_or(provisioned_permission),
1445            has_explicit_deny,
1446        )?;
1447        let expected_permission_posture =
1448            expected_executor_mapping.map(|executor_mapping| ExpectedPermissionPosture {
1449                policy_revision: permission_policy
1450                    .as_ref()
1451                    .map(|context| context.revision)
1452                    .or_else(|| host_audit.as_ref().map(|audit| audit.policy_revision))
1453                    .unwrap_or_default(),
1454                resolution: policy_resolution.unwrap_or(provisioned_permission),
1455                expected_audit_revision: host_audit.as_ref().map(|audit| audit.audit_revision),
1456                executor_mapping,
1457            });
1458
1459        // Backpressure: hold a concurrency slot for the lifetime of the *run*
1460        // (cancellation still proceeds — the cancel branch in drive() runs while
1461        // we hold the permit). Released when this fn returns, i.e. once the worker
1462        // is parked back into the pool, so idle workers don't pin slots.
1463        let _slot = self
1464            .concurrency
1465            .acquire()
1466            .await
1467            .map_err(|_| AgentError::LLM("actor concurrency limiter closed".to_string()))?;
1468
1469        // Bamboo-as-provider credentials are minted at the activation boundary,
1470        // after backpressure admits the run and never at worker provisioning.
1471        // This is load-bearing for warm workers: a parked process must never
1472        // retain a token from its previous run. The guard revokes on every return
1473        // path (success, error, cancellation, dispatch failure, or first-frame
1474        // retry exhaustion).
1475        let (run_secrets, _codex_token_guard) = build_codex_run_secrets(
1476            &spec.executor,
1477            self.codex_run_tokens.clone(),
1478            &job.child_session_id,
1479        )?;
1480
1481        // Split LOCAL (spawn + warm-pool) from the two process-less remote paths
1482        // ONLY at the divergent spots — acquire/connect here and the park/retire at
1483        // the end. Everything between (Run dispatch, live-actor registration,
1484        // drive, the close) is identical for all three. `kind` is the single guard.
1485        //   - Local       (#0):  byte-for-byte the pre-#193 reuse-or-spawn path.
1486        //   - Remote       (#194): connect to a FIXED resident endpoint, no spawn.
1487        //   - Schedulable  (#181): resolve a live worker from the registry, connect.
1488        let kind = match spec.placement {
1489            Placement::Remote { .. } => PlacementKind::Remote,
1490            Placement::Schedulable { .. } => PlacementKind::Schedulable,
1491            Placement::Local => PlacementKind::Local,
1492        };
1493        let remote = !matches!(kind, PlacementKind::Local);
1494
1495        // Stamp WHICH machine this child runs on onto its session metadata, so the
1496        // UI can show it (mirrored into the session index → SessionSummary.placement).
1497        // Only remote/scheduled placements need a stamp — a Local child falls through
1498        // to the DTO default (this backend's own host). Persisted by the caller with
1499        // the rest of the child session after we return.
1500        if let Some(placement_meta) = self.placement_stamp_for(&spec) {
1501            session
1502                .metadata
1503                .insert("placement".to_string(), placement_meta);
1504        }
1505
1506        // Retry-once loop: a pooled local worker can die between its liveness
1507        // check and handling the Run (a tiny TOCTOU window) — its Run then sits
1508        // queued with no server. The first-frame watchdog in `drive` surfaces that
1509        // as `WorkerUnresponsive`; we reap the dead worker and re-acquire ONCE
1510        // (which spawns fresh / reuses the next live one). Remote/schedulable have
1511        // no spawn fallback, so they never retry.
1512        let mut attempt = 0u8;
1513        let (result, actor) = loop {
1514            let execution_epoch = self.next_execution_epoch.fetch_add(1, Ordering::Relaxed) + 1;
1515            let (actor, mut client) = match kind {
1516                PlacementKind::Remote => {
1517                    // REMOTE branch: connect to a resident worker. No spawn, no pool
1518                    // touch, no drain. We do not own the worker, so a connect failure
1519                    // has NO respawn fallback — it is a clear, terminal error.
1520                    let placement = self
1521                        .remote_placements
1522                        .get(spec.identity.role.as_str())
1523                        .ok_or_else(|| {
1524                            AgentError::LLM(format!(
1525                                "remote placement for role '{}' vanished before connect",
1526                                spec.identity.role
1527                            ))
1528                        })?;
1529                    let endpoint = placement.endpoint.clone();
1530                    // Build the TLS trust: a pinned CA pins a self-signed worker cert;
1531                    // otherwise default webpki roots (or plaintext for `ws://`).
1532                    let trust_cfg = match placement.ca_cert_file.as_deref() {
1533                        Some(path) => Some(client_config_trusting_cert(path).map_err(|e| {
1534                            AgentError::LLM(format!(
1535                                "remote worker CA cert '{}': {e}",
1536                                path.display()
1537                            ))
1538                        })?),
1539                        None => None,
1540                    };
1541                    let client = ChildClient::connect_with_auth_tls(
1542                        &endpoint,
1543                        placement.token.as_deref(),
1544                        trust_cfg,
1545                    )
1546                    .await
1547                    .map_err(|e| {
1548                        AgentError::LLM(format!("remote actor connect to '{endpoint}' failed: {e}"))
1549                    })?;
1550                    // Process-less handle so live-actor registration (in-band steering)
1551                    // works exactly as for a local worker; `kill()` is a no-op.
1552                    let record = AgentRecord {
1553                        agent_id: job.child_session_id.clone(),
1554                        role: spec.identity.role.clone(),
1555                        labels: Vec::new(),
1556                        endpoint: endpoint.clone(),
1557                        pid: 0,
1558                        version: String::new(),
1559                        started_at: chrono::Utc::now(),
1560                        lease_expires_at: chrono::Utc::now(),
1561                    };
1562                    let _ = endpoint;
1563                    let actor = PooledWorker {
1564                        worker: SpawnedChild::remote(record),
1565                        mailbox_id: job.child_session_id.clone(),
1566                        parked_at: None,
1567                    };
1568                    let client: Box<dyn bamboo_subagent::ChildLink> = Box::new(client);
1569                    (actor, client)
1570                }
1571                PlacementKind::Schedulable => {
1572                    // SCHEDULABLE branch (#181): pick a LIVE worker of the pool role
1573                    // from the BUS (presence = connection-truth; no HTTP registry, no
1574                    // leases, no failover) and drive it by mailbox id. The pool worker
1575                    // stays connected and is reused next time. No spawn, no kill, NO
1576                    // local fallback — an empty pool is a terminal error (raised in
1577                    // resolve_schedulable_worker).
1578                    let bus = self.bus.as_ref().ok_or_else(|| {
1579                        AgentError::LLM(
1580                            "schedulable sub-agents require a mailbox bus (subagents.broker)"
1581                                .to_string(),
1582                        )
1583                    })?;
1584                    let mailbox_id = self
1585                        .resolve_schedulable_worker(spec.identity.role.as_str())
1586                        .await?;
1587                    let parent = bamboo_subagent::AgentRef {
1588                        session_id: format!("p-{}", job.child_session_id),
1589                        role: None,
1590                    };
1591                    let link = bamboo_broker::BrokerChildLink::connect(
1592                        &bus.endpoint,
1593                        parent,
1594                        &bus.token,
1595                        mailbox_id.clone(),
1596                    )
1597                    .await
1598                    .map_err(|e| {
1599                        AgentError::LLM(format!(
1600                            "schedulable link connect to '{mailbox_id}' failed: {e}"
1601                        ))
1602                    })?;
1603                    // Process-less handle — a bus-resident pool worker is never ours to
1604                    // kill (remote ⇒ dropped, not pooled, after the run).
1605                    let actor = PooledWorker {
1606                        worker: SpawnedChild::remote(AgentRecord {
1607                            agent_id: mailbox_id.clone(),
1608                            role: spec.identity.role.clone(),
1609                            labels: Vec::new(),
1610                            endpoint: bus.endpoint.clone(),
1611                            pid: 0,
1612                            version: String::new(),
1613                            started_at: chrono::Utc::now(),
1614                            lease_expires_at: chrono::Utc::now(),
1615                        }),
1616                        mailbox_id,
1617                        parked_at: None,
1618                    };
1619                    let client: Box<dyn bamboo_subagent::ChildLink> = Box::new(link);
1620                    (actor, client)
1621                }
1622                PlacementKind::Local => {
1623                    // LOCAL = the mailbox bus (the unified transport): check out a warm
1624                    // pooled worker (reuse a live parked one, else spawn fresh) and
1625                    // drive it by mailbox id — no listen socket, no file discovery, no
1626                    // respawn-on-connect-miss (the broker queues the Run until the
1627                    // worker handles it). The legacy direct-WS path was retired; the bus
1628                    // is required.
1629                    let bus = self.bus.as_ref().ok_or_else(|| {
1630                        AgentError::LLM(
1631                            "local sub-agents require a mailbox bus (subagents.broker); none is \
1632                         configured and the bus could not be embedded"
1633                                .to_string(),
1634                        )
1635                    })?;
1636                    let actor = self.acquire_bus_worker(&pool_key, &spec).await?;
1637                    let parent = bamboo_subagent::AgentRef {
1638                        session_id: format!("p-{}", job.child_session_id),
1639                        role: None,
1640                    };
1641                    let link = bamboo_broker::BrokerChildLink::connect(
1642                        &bus.endpoint,
1643                        parent,
1644                        &bus.token,
1645                        actor.mailbox_id.clone(),
1646                    )
1647                    .await
1648                    .map_err(|e| {
1649                        AgentError::LLM(format!("broker child link connect failed: {e}"))
1650                    })?;
1651                    let client: Box<dyn bamboo_subagent::ChildLink> = Box::new(link);
1652                    (actor, client)
1653                }
1654            };
1655
1656            // Publish the actor delivery owner and claim the complete bounded
1657            // authorized prefix before dispatching Run. These deliveries ride
1658            // inside RunSpec, so the worker durably enqueues them before its
1659            // first provider boundary rather than racing a later steer frame.
1660            let (delivery_tx, mut delivery_rx) = mpsc::unbounded_channel::<u64>();
1661            let bound_activation_run_id = match session_inbox_runtime.as_ref() {
1662                Some(binding) => {
1663                    let run_id = binding
1664                        .router
1665                        .attach_delivery_sink(&job.child_session_id, delivery_tx.clone())
1666                        .await;
1667                    if run_id.is_none() {
1668                        tracing::debug!(
1669                            session_id = %job.child_session_id,
1670                            "actor driver had no current SessionInbox activation owner to bind"
1671                        );
1672                    }
1673                    run_id
1674                }
1675                None => None,
1676            };
1677            drop(delivery_tx);
1678            let initial_pairs = match (
1679                session_inbox_runtime.as_ref(),
1680                bound_activation_run_id.as_deref(),
1681            ) {
1682                (Some(binding), Some(run_id)) => {
1683                    match claim_canonical_deliveries(binding, session, run_id, usize::MAX).await {
1684                        Ok(deliveries) => deliveries,
1685                        Err(error) => {
1686                            binding
1687                                .router
1688                                .detach_delivery_sink(&job.child_session_id, run_id)
1689                                .await;
1690                            if !remote {
1691                                actor.worker.kill().await;
1692                            }
1693                            return Err(error);
1694                        }
1695                    }
1696                }
1697                _ => Vec::new(),
1698            };
1699            let initial_session_messages = initial_pairs
1700                .iter()
1701                .map(|(_, delivery)| delivery.clone())
1702                .collect::<Vec<_>>();
1703            let initial_inflight_claims = initial_pairs
1704                .into_iter()
1705                .map(|(claim, _)| claim)
1706                .collect::<VecDeque<_>>();
1707            // Recompute after claim reconciliation: a warm retry may have had a
1708            // canonical receipt whose transcript proof was restored above.
1709            let messages = session
1710                .messages
1711                .iter()
1712                .filter_map(|message| serde_json::to_value(message).ok())
1713                .collect();
1714
1715            if let Err(e) = client
1716                .send(ParentFrame::Run(RunSpec {
1717                    // Cloned (not moved) so a retry can re-dispatch to a fresh worker.
1718                    assignment: assignment.clone(),
1719                    logical_session: Some(logical_identity_for_actor_run(session, job)),
1720                    project_id: project_id.clone(),
1721                    reasoning_effort: None,
1722                    permission_policy: permission_policy.clone(),
1723                    messages,
1724                    activation_run_id: bound_activation_run_id.clone(),
1725                    execution_epoch,
1726                    initial_session_messages,
1727                    secrets: run_secrets.clone(),
1728                }))
1729                .await
1730            {
1731                if let (Some(binding), Some(run_id)) = (
1732                    session_inbox_runtime.as_ref(),
1733                    bound_activation_run_id.as_deref(),
1734                ) {
1735                    binding
1736                        .router
1737                        .detach_delivery_sink(&job.child_session_id, run_id)
1738                        .await;
1739                }
1740                if !remote {
1741                    actor.worker.kill().await;
1742                }
1743                return Err(AgentError::LLM(format!("actor run dispatch failed: {e}")));
1744            }
1745
1746            // Register as a live actor so send_message (running, no interrupt) can
1747            // steer this child in-band over the existing WS connection. The guard
1748            // unregisters on every exit path.
1749            let (live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
1750            let live_guard = super::live::register(
1751                &job.child_session_id,
1752                live_tx,
1753                attempt as u32,
1754                self.approval_registry.clone(),
1755            );
1756
1757            let result = drive(ActorDriveContext {
1758                client: &mut *client,
1759                parent_session_id: &job.parent_session_id,
1760                child_session_id: &job.child_session_id,
1761                child_attempt: attempt as u32,
1762                approval_registry: self.approval_registry.as_ref(),
1763                approval_decider: self.approval_decider.as_ref(),
1764                approval_reviewer: self.approval_reviewer.as_ref(),
1765                escalation_bridge: escalation.clone(),
1766                event_tx: &event_tx,
1767                cancel_token: &cancel_token,
1768                live_rx: &mut live_rx,
1769                delivery_rx: &mut delivery_rx,
1770                logical_session: session,
1771                expected_permission_posture: expected_permission_posture.clone(),
1772                session_inbox_runtime: session_inbox_runtime.as_ref(),
1773                activation_run_id: bound_activation_run_id.as_deref(),
1774                execution_epoch,
1775                expected_source_actor_id: &actor.mailbox_id,
1776                initial_inflight_claims,
1777                // First-frame watchdog for EVERY placement: a wedged-but-connected
1778                // worker (subscribed ≠ serving — e.g. stuck on a prior LLM call) emits
1779                // no first frame; without a deadline drive() blocks forever. Bounding it
1780                // turns the "running-but-unresponsive" hang into a recoverable
1781                // WorkerUnresponsive (reap+respawn local / re-pick schedulable / error
1782                // on a fixed remote endpoint).
1783                first_frame_timeout: Some(WORKER_FIRST_FRAME_TIMEOUT),
1784            })
1785            .await;
1786            if let (Some(binding), Some(run_id)) = (
1787                session_inbox_runtime.as_ref(),
1788                bound_activation_run_id.as_deref(),
1789            ) {
1790                binding
1791                    .router
1792                    .detach_delivery_sink(&job.child_session_id, run_id)
1793                    .await;
1794            }
1795            // Unregister IMMEDIATELY: after drive returns nobody consumes live_rx,
1796            // so a send_message landing in the close/park window below must see
1797            // "not live" and take the durable-queue fallback instead of vanishing.
1798            // (Even if one slipped in earlier, send_message also appends it to the
1799            // durable transcript, so the next activation still rehydrates it.)
1800            drop(live_guard);
1801            // Close the parent link (dropping it closes our broker connection; the
1802            // worker stays dialed-in + subscribed, ready for its next Run).
1803            drop(client);
1804
1805            // No first frame ⇒ the worker is wedged. Recover ONCE before giving up:
1806            //   - Local: reap the dead pooled worker + respawn.
1807            //   - Schedulable: not ours to kill — drop it and re-select a live pool
1808            //     member (a wedged worker must not fail the run when the pool has others).
1809            //   - Remote: a FIXED endpoint has no alternative — fall through to a bounded
1810            //     WorkerUnresponsive error (far better than the previous infinite hang).
1811            if attempt == 0 && matches!(result, Err(AgentError::WorkerUnresponsive(_))) {
1812                match kind {
1813                    PlacementKind::Local => {
1814                        tracing::warn!(
1815                        "actor child {} got no first frame; reaping the worker and respawning once",
1816                        job.child_session_id
1817                    );
1818                        actor.worker.kill().await;
1819                        attempt += 1;
1820                        continue;
1821                    }
1822                    PlacementKind::Schedulable => {
1823                        tracing::warn!(
1824                        "scheduled actor child {} got no first frame; re-selecting a pool worker",
1825                        job.child_session_id
1826                    );
1827                        drop(actor);
1828                        attempt += 1;
1829                        continue;
1830                    }
1831                    PlacementKind::Remote => {}
1832                }
1833            }
1834            break (result, actor);
1835        };
1836
1837        // Park the warm worker for reuse on a clean run, or kill it on
1838        // error/cancel (a wedged worker must not be reused). Remote / schedulable
1839        // workers are registry-managed — never ours to pool/kill, just drop.
1840        if remote {
1841            drop(actor);
1842        } else {
1843            match &result {
1844                Ok(_) => self.release_bus_worker(&pool_key, actor).await,
1845                Err(_) => actor.worker.kill().await,
1846            }
1847        }
1848
1849        // Write-back: persist the actor's final reply onto the child session so
1850        // the transcript survives and the NEXT activation sees it as history.
1851        // (run_child_spawn saves the session right after we return.)
1852        match result {
1853            Ok(Some(text)) => {
1854                if !text.is_empty() {
1855                    session.add_message(bamboo_agent_core::Message::assistant(text, None));
1856                }
1857                Ok(())
1858            }
1859            Ok(None) => Ok(()),
1860            Err(e) => Err(e),
1861        }
1862    }
1863}
1864
1865/// The `{kind,host}` placement descriptor stamped onto a child session's metadata
1866/// under `"placement"` — read back by the storage index → `SessionSummary.placement`
1867/// → the UI's machine badge. `None` for `Local` (those fall through to the DTO's
1868/// default of this backend's own host). The value is a JSON string matching
1869/// `bamboo_storage::SessionPlacement { kind, host }`.
1870fn placement_metadata(placement: &Placement, host_label: Option<&str>) -> Option<String> {
1871    // Prefer the cluster node's own label/host (its metadata) when the placement
1872    // maps to a node; else fall back to the raw endpoint host / pool name.
1873    let value = match placement {
1874        Placement::Local => return None,
1875        Placement::Remote { endpoint } => serde_json::json!({
1876            "kind": "remote",
1877            "host": host_label.map(str::to_string).unwrap_or_else(|| host_of_endpoint(endpoint)),
1878        }),
1879        Placement::Schedulable { pool } => serde_json::json!({
1880            "kind": "remote",
1881            "host": host_label.unwrap_or(pool),
1882        }),
1883    };
1884    serde_json::to_string(&value).ok()
1885}
1886
1887/// Extract the host from a `ws[s]://host:port[/path]` bus endpoint, for display.
1888fn host_of_endpoint(endpoint: &str) -> String {
1889    endpoint
1890        .trim()
1891        .trim_start_matches("wss://")
1892        .trim_start_matches("ws://")
1893        .split(['/', ':'])
1894        .next()
1895        .unwrap_or(endpoint)
1896        .to_string()
1897}
1898
1899async fn reconcile_already_admitted_claim(
1900    binding: &SessionInboxRuntimeBinding,
1901    session: &mut Session,
1902    claim: &SessionInboxClaim,
1903) -> crate::runtime::runner::Result<()> {
1904    let latest = binding
1905        .storage
1906        .load_session(&session.id)
1907        .await
1908        .map_err(|error| {
1909            AgentError::LLM(format!(
1910                "load canonical SessionInbox checkpoint for {}: {error}",
1911                session.id
1912            ))
1913        })?
1914        .ok_or_else(|| {
1915            AgentError::LLM(format!(
1916                "canonical SessionInbox target disappeared: {}",
1917                session.id
1918            ))
1919        })?;
1920    let Some(message) = latest
1921        .messages
1922        .iter()
1923        .find(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
1924        .cloned()
1925    else {
1926        return Err(AgentError::LLM(format!(
1927            "canonical admitted receipt for {} exists without transcript message {}",
1928            session.id, claim.envelope.id
1929        )));
1930    };
1931    if let Some(existing) = session
1932        .messages
1933        .iter_mut()
1934        .find(|existing| existing.id == message.id)
1935    {
1936        *existing = message;
1937    } else {
1938        session.add_message(message);
1939    }
1940    bamboo_domain::merge_session_inbox_admission(session, &latest);
1941    binding
1942        .inbox
1943        .ack(&session.id, claim)
1944        .await
1945        .map_err(|error| {
1946            AgentError::LLM(format!(
1947                "ack recovered canonical SessionInbox claim {}: {error}",
1948                claim.envelope.id
1949            ))
1950        })
1951}
1952
1953/// Checkpoint a worker-confirmed envelope into the canonical logical Session,
1954/// then create the permanent host receipt/remove its exact claim. This order is
1955/// the actor-side equivalent of the local state_bridge crash boundary.
1956async fn checkpoint_and_ack_canonical_claim(
1957    binding: &SessionInboxRuntimeBinding,
1958    session: &mut Session,
1959    claim: &SessionInboxClaim,
1960) -> crate::runtime::runner::Result<()> {
1961    if claim.envelope.target_session_id != session.id {
1962        return Err(AgentError::LLM(format!(
1963            "canonical SessionInbox claim target {} does not match active logical session {}",
1964            claim.envelope.target_session_id, session.id
1965        )));
1966    }
1967    if binding
1968        .inbox
1969        .was_admitted(&session.id, &claim.envelope.id)
1970        .await
1971        .map_err(|error| {
1972            AgentError::LLM(format!(
1973                "inspect canonical admitted receipt {}: {error}",
1974                claim.envelope.id
1975            ))
1976        })?
1977    {
1978        return reconcile_already_admitted_claim(binding, session, claim).await;
1979    }
1980
1981    let transcript_has_id = session
1982        .messages
1983        .iter()
1984        .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope));
1985    if session
1986        .messages
1987        .iter()
1988        .any(|message| message.id == claim.envelope.id.as_str())
1989        && !transcript_has_id
1990    {
1991        return Err(AgentError::LLM(format!(
1992            "canonical SessionInbox id {} collides with a non-matching transcript message",
1993            claim.envelope.id
1994        )));
1995    }
1996    let cursor_has_id = session
1997        .session_inbox_admission()
1998        .is_some_and(|state| state.contains(&claim.envelope.id));
1999    if cursor_has_id && !transcript_has_id {
2000        return Err(AgentError::LLM(format!(
2001            "canonical SessionInbox cursor exists without transcript message {}",
2002            claim.envelope.id
2003        )));
2004    }
2005    let before = session.clone();
2006    if !transcript_has_id {
2007        let message = claim.envelope.to_provider_message().map_err(|error| {
2008            AgentError::LLM(format!(
2009                "translate canonical SessionInbox envelope {}: {error}",
2010                claim.envelope.id
2011            ))
2012        })?;
2013        session.add_message(message);
2014    }
2015    session
2016        .session_inbox_admission_mut()
2017        .record(claim.envelope.id.clone(), claim.generation);
2018    session.updated_at = chrono::Utc::now();
2019
2020    if let Err(error) = binding
2021        .persistence
2022        .checkpoint_runtime_session(session)
2023        .await
2024    {
2025        *session = before;
2026        return Err(AgentError::LLM(format!(
2027            "checkpoint canonical SessionInbox claim {}: {error}",
2028            claim.envelope.id
2029        )));
2030    }
2031    if !session
2032        .messages
2033        .iter()
2034        .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
2035    {
2036        *session = before;
2037        return Err(AgentError::LLM(format!(
2038            "canonical SessionInbox checkpoint lost typed transcript proof for {}",
2039            claim.envelope.id
2040        )));
2041    }
2042    binding
2043        .inbox
2044        .ack(&session.id, claim)
2045        .await
2046        .map_err(|error| {
2047            AgentError::LLM(format!(
2048                "ack canonical SessionInbox claim {} after checkpoint: {error}",
2049                claim.envelope.id
2050            ))
2051        })
2052}
2053
2054/// Durably seed claimed typed messages into the canonical host transcript
2055/// before dispatching them to any actor worker, while deliberately leaving the
2056/// admission cursor and `cur/` claims untouched.
2057///
2058/// This is the cross-placement lost-confirmation invariant: if worker A admits
2059/// and reasons over the batch but its confirmations are lost, a retry on worker
2060/// B receives a host snapshot already containing each stable typed message
2061/// exactly once. Worker B's local safe boundary then records/acks the same ids
2062/// without appending duplicates. Only an exact worker confirmation advances the
2063/// host cursor; only a durable cursor checkpoint precedes host ack.
2064async fn checkpoint_claim_context_before_dispatch(
2065    binding: &SessionInboxRuntimeBinding,
2066    session: &mut Session,
2067    claims: &[SessionInboxClaim],
2068) -> crate::runtime::runner::Result<()> {
2069    if claims.is_empty() {
2070        return Ok(());
2071    }
2072    let before = session.clone();
2073    for claim in claims {
2074        if claim.envelope.target_session_id != session.id {
2075            return Err(AgentError::LLM(format!(
2076                "canonical SessionInbox claim target {} does not match actor session {}",
2077                claim.envelope.target_session_id, session.id
2078            )));
2079        }
2080        let matching = session
2081            .messages
2082            .iter()
2083            .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope));
2084        if session
2085            .messages
2086            .iter()
2087            .any(|message| message.id == claim.envelope.id.as_str())
2088            && !matching
2089        {
2090            return Err(AgentError::LLM(format!(
2091                "canonical SessionInbox id {} collides before actor dispatch",
2092                claim.envelope.id
2093            )));
2094        }
2095        if session
2096            .session_inbox_admission()
2097            .is_some_and(|state| state.contains(&claim.envelope.id))
2098            && !matching
2099        {
2100            return Err(AgentError::LLM(format!(
2101                "canonical SessionInbox cursor exists without transcript proof for {}",
2102                claim.envelope.id
2103            )));
2104        }
2105        if !matching {
2106            let message = claim.envelope.to_provider_message().map_err(|error| {
2107                AgentError::LLM(format!(
2108                    "translate canonical SessionInbox envelope {} before actor dispatch: {error}",
2109                    claim.envelope.id
2110                ))
2111            })?;
2112            session.add_message(message);
2113        }
2114    }
2115    session.updated_at = chrono::Utc::now();
2116    if let Err(error) = binding
2117        .persistence
2118        .checkpoint_runtime_session(session)
2119        .await
2120    {
2121        *session = before;
2122        return Err(AgentError::LLM(format!(
2123            "checkpoint canonical SessionInbox actor context: {error}"
2124        )));
2125    }
2126    for claim in claims {
2127        if !session
2128            .messages
2129            .iter()
2130            .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
2131        {
2132            *session = before;
2133            return Err(AgentError::LLM(format!(
2134                "actor context checkpoint lost typed transcript proof for {}",
2135                claim.envelope.id
2136            )));
2137        }
2138    }
2139    Ok(())
2140}
2141
2142async fn claim_canonical_deliveries(
2143    binding: &SessionInboxRuntimeBinding,
2144    session: &mut Session,
2145    activation_run_id: &str,
2146    limit: usize,
2147) -> crate::runtime::runner::Result<Vec<(SessionInboxClaim, SessionMessageDelivery)>> {
2148    let claims = binding
2149        .inbox
2150        .claim_for_turn(&session.id, limit, Some(activation_run_id))
2151        .await
2152        .map_err(|error| {
2153            AgentError::LLM(format!(
2154                "claim canonical SessionInbox for active actor {}: {error}",
2155                session.id
2156            ))
2157        })?;
2158    if claims.is_empty() {
2159        return Ok(Vec::new());
2160    }
2161    let interrupt_generation = binding
2162        .inbox
2163        .inspect(&session.id)
2164        .await
2165        .map_err(|error| {
2166            AgentError::LLM(format!(
2167                "inspect canonical SessionInbox activation policy for {}: {error}",
2168                session.id
2169            ))
2170        })?
2171        .interrupt_generation;
2172    let mut unconfirmed = Vec::with_capacity(claims.len());
2173    for claim in claims {
2174        if binding
2175            .inbox
2176            .was_admitted(&session.id, &claim.envelope.id)
2177            .await
2178            .map_err(|error| {
2179                AgentError::LLM(format!(
2180                    "inspect canonical SessionInbox claim {}: {error}",
2181                    claim.envelope.id
2182                ))
2183            })?
2184        {
2185            reconcile_already_admitted_claim(binding, session, &claim).await?;
2186            continue;
2187        }
2188        unconfirmed.push(claim);
2189    }
2190    checkpoint_claim_context_before_dispatch(binding, session, &unconfirmed).await?;
2191
2192    let mut deliveries = Vec::with_capacity(unconfirmed.len());
2193    for claim in unconfirmed {
2194        // Cursor+transcript without the permanent tombstone is the recoverable
2195        // crash window after an exact worker confirmation was checkpointed but
2196        // before host ack removed `cur/`. Finish that ack without exposing the
2197        // message to another provider run.
2198        if session
2199            .session_inbox_admission()
2200            .is_some_and(|state| state.contains(&claim.envelope.id))
2201        {
2202            binding
2203                .inbox
2204                .ack(&session.id, &claim)
2205                .await
2206                .map_err(|error| {
2207                    AgentError::LLM(format!(
2208                        "finish confirmed canonical SessionInbox ack {}: {error}",
2209                        claim.envelope.id
2210                    ))
2211                })?;
2212            continue;
2213        }
2214        let activation_policy = if claim.generation <= interrupt_generation {
2215            bamboo_domain::SessionActivationPolicy::InterruptSpecificWait
2216        } else {
2217            bamboo_domain::SessionActivationPolicy::RespectSpecificWait
2218        };
2219        let delivery = SessionMessageDelivery {
2220            target_session_id: session.id.clone(),
2221            envelope: claim.envelope.clone(),
2222            canonical_claim_generation: claim.generation,
2223            activation_run_id: activation_run_id.to_string(),
2224            activation_policy,
2225        };
2226        deliveries.push((claim, delivery));
2227    }
2228    Ok(deliveries)
2229}
2230
2231async fn forward_next_canonical_claim(
2232    client: &mut dyn bamboo_subagent::ChildLink,
2233    binding: &SessionInboxRuntimeBinding,
2234    session: &mut Session,
2235    activation_run_id: &str,
2236    inflight: &mut VecDeque<SessionInboxClaim>,
2237) -> crate::runtime::runner::Result<()> {
2238    if !inflight.is_empty() {
2239        return Ok(());
2240    }
2241    let Some((claim, delivery)) =
2242        claim_canonical_deliveries(binding, session, activation_run_id, 1)
2243            .await?
2244            .pop()
2245    else {
2246        return Ok(());
2247    };
2248    client
2249        .send(ParentFrame::SessionMessage { delivery })
2250        .await
2251        .map_err(|error| {
2252            AgentError::LLM(format!(
2253                "forward canonical SessionInbox claim {} to active actor: {error}",
2254                claim.envelope.id
2255            ))
2256        })?;
2257    inflight.push_back(claim);
2258    Ok(())
2259}
2260
2261/// Borrowed and per-run-owned inputs for one actor frame pump.
2262struct ActorDriveContext<'a> {
2263    client: &'a mut dyn bamboo_subagent::ChildLink,
2264    parent_session_id: &'a str,
2265    child_session_id: &'a str,
2266    child_attempt: u32,
2267    approval_registry: Option<&'a super::approval_registry::SharedApprovalRegistry>,
2268    approval_decider: Option<&'a Arc<dyn ChildApprovalDecider>>,
2269    approval_reviewer: Option<&'a Arc<dyn ChildApprovalReviewer>>,
2270    escalation_bridge: Option<bamboo_subagent::executor::HostBridge>,
2271    event_tx: &'a mpsc::Sender<AgentEvent>,
2272    cancel_token: &'a CancellationToken,
2273    live_rx: &'a mut mpsc::UnboundedReceiver<ParentFrame>,
2274    delivery_rx: &'a mut mpsc::UnboundedReceiver<u64>,
2275    logical_session: &'a mut Session,
2276    expected_permission_posture: Option<ExpectedPermissionPosture>,
2277    session_inbox_runtime: Option<&'a SessionInboxRuntimeBinding>,
2278    activation_run_id: Option<&'a str>,
2279    execution_epoch: u64,
2280    expected_source_actor_id: &'a str,
2281    initial_inflight_claims: VecDeque<SessionInboxClaim>,
2282    first_frame_timeout: Option<Duration>,
2283}
2284
2285#[derive(Debug, Clone, PartialEq, Eq)]
2286struct ExpectedPermissionPosture {
2287    policy_revision: u64,
2288    resolution: bamboo_domain::PermissionModeResolution,
2289    expected_audit_revision: Option<u64>,
2290    executor_mapping: String,
2291}
2292
2293#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2294enum PermissionPostureHandshake {
2295    /// Legacy/custom actors were dispatched without a typed posture contract.
2296    NotRequired,
2297    /// The host dispatched an exact posture and no matching, durably recorded
2298    /// worker activation has arrived yet.
2299    Awaiting,
2300    /// One worker posture matched and its host-owned audit write succeeded.
2301    Confirmed,
2302}
2303
2304impl PermissionPostureHandshake {
2305    fn new(expected: Option<&ExpectedPermissionPosture>) -> Self {
2306        if expected.is_some() {
2307            Self::Awaiting
2308        } else {
2309            Self::NotRequired
2310        }
2311    }
2312
2313    fn is_awaiting(self) -> bool {
2314        self == Self::Awaiting
2315    }
2316
2317    fn posture_was_confirmed(self) -> bool {
2318        self == Self::Confirmed
2319    }
2320}
2321
2322fn permission_posture_seed_from_event(
2323    session: &Session,
2324    event: &AgentEvent,
2325) -> Result<Option<bamboo_domain::PermissionAuditSeed>, String> {
2326    let AgentEvent::PermissionPostureActivated {
2327        session_id,
2328        policy_revision,
2329        requested_mode,
2330        effective_mode,
2331        executor_mapping,
2332    } = event
2333    else {
2334        return Ok(None);
2335    };
2336    if session_id != &session.id {
2337        return Err("permission posture event targets a different logical session".to_string());
2338    }
2339    let requested = bamboo_domain::SessionPermissionMode::from_audit_str(requested_mode)
2340        .ok_or_else(|| "permission posture event has an invalid requested mode".to_string())?;
2341    let effective = bamboo_domain::PermissionMode::from_audit_str(effective_mode)
2342        .ok_or_else(|| "permission posture event has an invalid effective mode".to_string())?;
2343    let resolution = bamboo_domain::PermissionModeResolution {
2344        requested,
2345        effective,
2346    };
2347    if !resolution.is_consistent() {
2348        return Err("permission posture event has an inconsistent mode pair".to_string());
2349    }
2350    let current_requested = session
2351        .agent_runtime_state
2352        .as_ref()
2353        .map(|state| state.effective_permission_mode())
2354        .unwrap_or_default();
2355    if current_requested != requested {
2356        return Err("permission posture event is stale for the host typed mode".to_string());
2357    }
2358    let mapping_chars = executor_mapping.chars().count();
2359    if mapping_chars == 0 || mapping_chars > bamboo_domain::MAX_PERMISSION_EXECUTOR_MAPPING_CHARS {
2360        return Err("permission posture event has an invalid executor mapping".to_string());
2361    }
2362    Ok(Some(bamboo_domain::PermissionAuditSeed::new(
2363        *policy_revision,
2364        resolution,
2365        executor_mapping,
2366    )))
2367}
2368
2369fn validate_actor_event_batch(
2370    batch: &ActorEventBatch,
2371    logical_session: &Session,
2372    parent_session_id: &str,
2373    activation_run_id: Option<&str>,
2374    execution_epoch: u64,
2375    expected_source_actor_id: &str,
2376) -> Result<(), AgentError> {
2377    batch
2378        .validate()
2379        .map_err(|error| AgentError::LLM(format!("actor emitted invalid event batch: {error}")))?;
2380    let identity = batch.logical_session.as_ref().ok_or_else(|| {
2381        AgentError::LLM("actor event batch omitted its logical session identity".to_string())
2382    })?;
2383    // Fence against the exact canonical identity used to build RunSpec. Some
2384    // migrated/test sessions have not yet materialized parent ancestry on the
2385    // in-memory Session, so comparing the batch directly with those raw fields
2386    // can reject the identity the host itself just dispatched.
2387    let expected_identity = LogicalSessionIdentity {
2388        session_id: logical_session.id.clone(),
2389        parent_session_id: logical_session
2390            .parent_session_id
2391            .clone()
2392            .or_else(|| Some(parent_session_id.to_string())),
2393        root_session_id: if logical_session.root_session_id.trim().is_empty() {
2394            parent_session_id.to_string()
2395        } else {
2396            logical_session.root_session_id.clone()
2397        },
2398    };
2399    if identity != &expected_identity {
2400        return Err(AgentError::LLM(
2401            "actor event batch targets a different logical session".to_string(),
2402        ));
2403    }
2404    if batch.activation_id.as_deref() != activation_run_id {
2405        return Err(AgentError::LLM(
2406            "actor event batch belongs to a stale activation".to_string(),
2407        ));
2408    }
2409    if batch.execution_epoch != execution_epoch {
2410        return Err(AgentError::LLM(
2411            "actor event batch belongs to a stale execution epoch".to_string(),
2412        ));
2413    }
2414    if batch
2415        .source_actor_id
2416        .as_deref()
2417        .is_some_and(|source| source != expected_source_actor_id)
2418    {
2419        return Err(AgentError::LLM(
2420            "actor event batch declares a different physical actor".to_string(),
2421        ));
2422    }
2423    Ok(())
2424}
2425
2426#[allow(clippy::too_many_arguments)]
2427async fn process_actor_event(
2428    event: serde_json::Value,
2429    strict_permission_events: bool,
2430    permission_handshake: &mut PermissionPostureHandshake,
2431    expected_permission_posture: Option<&ExpectedPermissionPosture>,
2432    session_inbox_runtime: Option<&SessionInboxRuntimeBinding>,
2433    logical_session: &mut Session,
2434    event_tx: &mpsc::Sender<AgentEvent>,
2435) -> crate::runtime::runner::Result<()> {
2436    let event = match serde_json::from_value::<AgentEvent>(event) {
2437        Ok(event) => event,
2438        Err(error) if strict_permission_events => {
2439            return Err(AgentError::LLM(format!(
2440                "actor emitted malformed AgentEvent under a typed permission posture contract: {error}"
2441            )));
2442        }
2443        Err(_) => return Ok(()),
2444    };
2445    if matches!(&event, AgentEvent::PermissionPostureActivated { .. }) {
2446        if permission_handshake.posture_was_confirmed() {
2447            return Err(AgentError::LLM(
2448                "actor emitted a duplicate permission posture activation".to_string(),
2449            ));
2450        }
2451        let seed = permission_posture_seed_from_event(logical_session, &event)
2452            .map_err(AgentError::LLM)?
2453            .ok_or_else(|| {
2454                AgentError::LLM(
2455                    "actor permission posture event did not decode as a posture".to_string(),
2456                )
2457            })?;
2458        if let Some(expected) = expected_permission_posture {
2459            if seed.policy_revision != expected.policy_revision
2460                || seed.resolution != expected.resolution
2461            {
2462                return Err(AgentError::LLM(
2463                    "permission posture event does not match the host-dispatched policy"
2464                        .to_string(),
2465                ));
2466            }
2467            if seed.executor_mapping() != expected.executor_mapping {
2468                return Err(AgentError::LLM(
2469                    "permission posture event does not match the host-dispatched executor mapping"
2470                        .to_string(),
2471                ));
2472            }
2473        }
2474        if let Some(binding) = session_inbox_runtime {
2475            let saved = binding
2476                .persistence
2477                .record_permission_posture_activation(
2478                    &logical_session.id,
2479                    expected_permission_posture
2480                        .and_then(|expected| expected.expected_audit_revision),
2481                    &seed,
2482                )
2483                .await
2484                .map_err(|error| {
2485                    AgentError::LLM(format!(
2486                        "persist child permission posture bootstrap: {error}"
2487                    ))
2488                })?
2489                .ok_or_else(|| {
2490                    AgentError::LLM(
2491                        "persist child permission posture bootstrap: session not found".to_string(),
2492                    )
2493                })?;
2494            let snapshot = bamboo_domain::PermissionAuditSnapshot::from_metadata(&saved.metadata)
2495                .ok_or_else(|| {
2496                AgentError::LLM(
2497                    "persisted child permission posture audit is incomplete".to_string(),
2498                )
2499            })?;
2500            snapshot.write_to(&mut logical_session.metadata);
2501        } else {
2502            // In-memory/custom actor embeddings still use a host-owned clock.
2503            // Durable server paths always take the atomic branch.
2504            bamboo_domain::record_permission_audit(&mut logical_session.metadata, &seed, None)
2505                .map_err(|error| {
2506                    AgentError::LLM(format!(
2507                        "record in-memory child permission posture: {error}"
2508                    ))
2509                })?;
2510        }
2511        // Confirmation is deliberately last: matching alone is not enough.
2512        // The host-owned audit write must succeed first.
2513        *permission_handshake = PermissionPostureHandshake::Confirmed;
2514    } else if permission_handshake.is_awaiting() {
2515        return Err(AgentError::LLM(
2516            "actor emitted an execution event before permission posture confirmation".to_string(),
2517        ));
2518    }
2519    let _ = event_tx.send(event).await;
2520    Ok(())
2521}
2522
2523/// Pump child frames -> parent events until a terminal frame (or cancellation).
2524/// On success, yields the actor's final result text (for session write-back).
2525/// `live_rx` carries in-band frames (steering messages) from the live registry.
2526///
2527/// `escalation_bridge` (#68) is the per-run escalation host bridge CAPTURED BY
2528/// VALUE at spawn time in `execute_external_child` (NOT read live here): when a
2529/// non-bypass child re-proxies an approval request, this owned bridge routes it
2530/// UP to the parent run. Owning it for the call's lifetime is what lets a
2531/// fire-and-forget grandchild that outlives its spawning run still escalate to
2532/// the correct (then-current) parent bridge rather than a stale/overwritten one.
2533async fn drive(context: ActorDriveContext<'_>) -> crate::runtime::runner::Result<Option<String>> {
2534    let ActorDriveContext {
2535        client,
2536        parent_session_id,
2537        child_session_id,
2538        child_attempt,
2539        approval_registry,
2540        approval_decider,
2541        approval_reviewer,
2542        escalation_bridge,
2543        event_tx,
2544        cancel_token,
2545        live_rx,
2546        delivery_rx,
2547        logical_session,
2548        expected_permission_posture,
2549        session_inbox_runtime,
2550        activation_run_id,
2551        execution_epoch,
2552        expected_source_actor_id,
2553        initial_inflight_claims,
2554        first_frame_timeout,
2555    } = context;
2556
2557    // First-frame watchdog: a live worker emits its first frame (run-started /
2558    // first token) within seconds; total silence past the deadline means the
2559    // worker is dead (e.g. a pooled worker that exited right after checkout), so
2560    // its Run sits queued forever. We trip ONLY before the first frame — once any
2561    // frame arrives the worker is proven live and a legitimately long run (a slow
2562    // tool between tokens) never trips it.
2563    let mut got_first_frame = false;
2564    let mut first_frame_watch = first_frame_timeout.map(|d| Box::pin(tokio::time::sleep(d)));
2565    let mut inflight_claims = initial_inflight_claims;
2566    let strict_permission_events = expected_permission_posture.is_some();
2567    let mut permission_handshake =
2568        PermissionPostureHandshake::new(expected_permission_posture.as_ref());
2569    let mut next_actor_event_seq = 1u64;
2570    loop {
2571        tokio::select! {
2572            _ = cancel_token.cancelled() => {
2573                // fall through to the cancel handling below
2574                break;
2575            }
2576            _ = async {
2577                match first_frame_watch.as_mut() {
2578                    Some(s) => s.as_mut().await,
2579                    None => std::future::pending::<()>().await,
2580                }
2581            }, if !got_first_frame => {
2582                return Err(AgentError::WorkerUnresponsive(format!(
2583                    "child {child_session_id} produced no frame within {:?}",
2584                    first_frame_timeout.unwrap_or_default()
2585                )));
2586            }
2587            Some(_generation) = delivery_rx.recv(),
2588                if session_inbox_runtime.is_some() && activation_run_id.is_some() =>
2589            {
2590                forward_next_canonical_claim(
2591                    client,
2592                    session_inbox_runtime.expect("guarded"),
2593                    logical_session,
2594                    activation_run_id.expect("guarded"),
2595                    &mut inflight_claims,
2596                )
2597                .await?;
2598            }
2599            Some(frame) = live_rx.recv() => {
2600                // Forward in-band steering to the worker over the existing WS.
2601                if client.send(frame).await.is_err() {
2602                    tracing::warn!("live steering frame could not be sent; connection failing");
2603                }
2604            }
2605            frame = client.next_frame() => {
2606                // Any frame (event / approval / terminal / close / error) proves
2607                // the worker responded — disarm the first-frame watchdog.
2608                got_first_frame = true;
2609                first_frame_watch = None;
2610                match frame {
2611                    Ok(Some(ChildFrame::Event { event })) => {
2612                        // Rolling-upgrade compatibility: old actors have no
2613                        // route/sequence metadata, but retain the same typed
2614                        // permission handshake and event validation.
2615                        process_actor_event(
2616                            event,
2617                            strict_permission_events,
2618                            &mut permission_handshake,
2619                            expected_permission_posture.as_ref(),
2620                            session_inbox_runtime,
2621                            logical_session,
2622                            event_tx,
2623                        )
2624                        .await?;
2625                    }
2626                    Ok(Some(ChildFrame::EventBatch { batch })) => {
2627                        validate_actor_event_batch(
2628                            &batch,
2629                            logical_session,
2630                            parent_session_id,
2631                            activation_run_id,
2632                            execution_epoch,
2633                            expected_source_actor_id,
2634                        )?;
2635                        if batch.last_seq < next_actor_event_seq {
2636                            continue;
2637                        }
2638                        if batch.first_seq > next_actor_event_seq {
2639                            tracing::warn!(
2640                                child_session_id,
2641                                expected_seq = next_actor_event_seq,
2642                                received_seq = batch.first_seq,
2643                                "actor event sequence gap; authoritative session snapshot is required"
2644                            );
2645                        }
2646                        let skip = next_actor_event_seq
2647                            .saturating_sub(batch.first_seq)
2648                            .min(batch.events.len() as u64) as usize;
2649                        for (offset, event) in batch.events.into_iter().enumerate().skip(skip) {
2650                            let seq = batch.first_seq + offset as u64;
2651                            process_actor_event(
2652                                event,
2653                                strict_permission_events,
2654                                &mut permission_handshake,
2655                                expected_permission_posture.as_ref(),
2656                                session_inbox_runtime,
2657                                logical_session,
2658                                event_tx,
2659                            )
2660                            .await?;
2661                            next_actor_event_seq = seq.saturating_add(1);
2662                        }
2663                    }
2664                    Ok(Some(ChildFrame::ApprovalRequest { id, body })) => {
2665                        if permission_handshake.is_awaiting() {
2666                            return Err(AgentError::LLM(
2667                                "actor requested approval before permission posture confirmation"
2668                                    .to_string(),
2669                            ));
2670                        }
2671                        // Phase 2: a worker proxied a gated-tool approval back to
2672                        // the host. The WORKER side is live — its executor installs
2673                        // a per-run task-local `ApprovalProxy` (subagent_worker.rs)
2674                        // that calls `host.approval_call`, so this frame arrives
2675                        // when a child hits `ConfirmationRequired`.
2676                        if let Some(reviewer) = approval_reviewer
2677                            .cloned()
2678                            .or_else(child_approval_reviewer)
2679                        {
2680                            // Phase 6, Part B: a BYPASSED parent worker
2681                            // model-reviews its children's forced-ask (dangerous)
2682                            // actions. The review is an LLM call, so run it OFF
2683                            // the frame pump in a spawned task and deliver the
2684                            // verdict async via the live channel — the pump keeps
2685                            // forwarding events and the agent loop never blocks. A
2686                            // timeout denies a hung review so the child can't hang.
2687                            let child = child_session_id.to_string();
2688                            let parent = parent_session_id.to_string();
2689                            let req_id = id.clone();
2690                            let body = body.clone();
2691                            let registry = approval_registry.cloned();
2692                            tokio::spawn(async move {
2693                                let approved = tokio::time::timeout(
2694                                    CHILD_APPROVAL_TIMEOUT,
2695                                    reviewer.review(&parent, &child, &body),
2696                                )
2697                                .await
2698                                .unwrap_or(false);
2699                                super::live::deliver_approval_scoped(
2700                                    registry.as_ref(),
2701                                    &child,
2702                                    child_attempt,
2703                                    &req_id,
2704                                    approved,
2705                                );
2706                            });
2707                        } else if approval_decider.is_some() {
2708                            // A decider is wired (policy / auto): decide promptly
2709                            // and reply inline. (Must not block the pump — see the
2710                            // `ChildApprovalDecider` doc.)
2711                            let approved =
2712                                decide_child_approval(approval_decider, child_session_id, &body)
2713                                    .await;
2714                            if client
2715                                .send(ParentFrame::ApprovalReply { id, approved })
2716                                .await
2717                                .is_err()
2718                            {
2719                                tracing::warn!(
2720                                    "failed to answer approval_request; connection failing"
2721                                );
2722                            }
2723                        } else if let Some(host) = escalation_bridge.clone() {
2724                            // Non-bypass WORKER: ESCALATE up our own actor link
2725                            // (re-proxy) so the request chains to our parent — and
2726                            // up every level until a bypass level or the top
2727                            // orchestrator's model reviewer decides. With no such
2728                            // reviewer the top level fails closed. Off-loop so the
2729                            // pump never blocks; relay the reply down to the child.
2730                            let child = child_session_id.to_string();
2731                            let req_id = id.clone();
2732                            let body = body.clone();
2733                            let registry = approval_registry.cloned();
2734                            tokio::spawn(async move {
2735                                let approved = match tokio::time::timeout(
2736                                    CHILD_APPROVAL_TIMEOUT,
2737                                    host.approval_call(body),
2738                                )
2739                                .await
2740                                {
2741                                    Ok(Ok(reply)) => reply
2742                                        .get("approved")
2743                                        .and_then(|v| v.as_bool())
2744                                        .unwrap_or(false),
2745                                    // Transport error or timeout ⇒ fail closed.
2746                                    _ => false,
2747                                };
2748                                super::live::deliver_approval_scoped(
2749                                    registry.as_ref(),
2750                                    &child,
2751                                    child_attempt,
2752                                    &req_id,
2753                                    approved,
2754                                );
2755                            });
2756                        } else {
2757                            // There is no parent-agent reviewer or upstream actor
2758                            // to own this decision. Never open a manual/UI approval
2759                            // path: forced-ask is parent-reviewed or fail-closed.
2760                            tracing::warn!(
2761                                parent_session_id,
2762                                child_session_id,
2763                                request_id = %id,
2764                                "forced-ask request has no parent-agent reviewer; denying"
2765                            );
2766                            if client
2767                                .send(ParentFrame::ApprovalReply {
2768                                    id,
2769                                    approved: false,
2770                                })
2771                                .await
2772                                .is_err()
2773                            {
2774                                tracing::warn!(
2775                                    "failed to send fail-closed approval reply; connection failing"
2776                                );
2777                            }
2778                        }
2779                    }
2780                    Ok(Some(ChildFrame::SessionMessageAdmitted { confirmation })) => {
2781                        let Some(binding) = session_inbox_runtime else {
2782                            tracing::warn!(
2783                                child_session_id,
2784                                "ignoring SessionInbox confirmation without a runtime binding"
2785                            );
2786                            continue;
2787                        };
2788                        let Some(bound_run_id) = activation_run_id else {
2789                            tracing::warn!(
2790                                child_session_id,
2791                                "ignoring SessionInbox confirmation without an activation owner"
2792                            );
2793                            continue;
2794                        };
2795                        let Some(claim) = inflight_claims.front() else {
2796                            tracing::warn!(
2797                                child_session_id,
2798                                envelope_id = %confirmation.envelope_id,
2799                                "rejecting stale SessionInbox confirmation with no in-flight canonical claim"
2800                            );
2801                            continue;
2802                        };
2803                        let exact = confirmation.target_session_id == logical_session.id
2804                            && confirmation.envelope_id == claim.envelope.id.as_str()
2805                            && confirmation.canonical_claim_generation == claim.generation
2806                            && confirmation.activation_run_id == bound_run_id;
2807                        if !exact
2808                            || !binding
2809                                .router
2810                                .owns_run(&logical_session.id, bound_run_id)
2811                                .await
2812                        {
2813                            tracing::warn!(
2814                                child_session_id,
2815                                expected_target = %logical_session.id,
2816                                received_target = %confirmation.target_session_id,
2817                                expected_envelope_id = %claim.envelope.id,
2818                                received_envelope_id = %confirmation.envelope_id,
2819                                expected_generation = claim.generation,
2820                                received_generation = confirmation.canonical_claim_generation,
2821                                expected_run_id = bound_run_id,
2822                                received_run_id = %confirmation.activation_run_id,
2823                                "rejecting stale or mismatched SessionInbox admission confirmation"
2824                            );
2825                            continue;
2826                        }
2827                        let claim = inflight_claims
2828                            .pop_front()
2829                            .expect("validated in-flight canonical claim");
2830                        // On failure the durable canonical cur file remains
2831                        // recoverable for the next owner.
2832                        checkpoint_and_ack_canonical_claim(binding, logical_session, &claim)
2833                            .await?;
2834                        // Ordered single-consumer: only after the exact prior
2835                        // claim is checkpointed+acked may the driver claim and
2836                        // forward the next envelope.
2837                        if inflight_claims.is_empty() {
2838                            forward_next_canonical_claim(
2839                                client,
2840                                binding,
2841                                logical_session,
2842                                bound_run_id,
2843                                &mut inflight_claims,
2844                            )
2845                            .await?;
2846                        }
2847                    }
2848                    Ok(Some(ChildFrame::Terminal { status, result, error, .. })) => {
2849                        if permission_handshake.is_awaiting() {
2850                            return Err(AgentError::LLM(
2851                                "actor terminated before permission posture confirmation"
2852                                    .to_string(),
2853                            ));
2854                        }
2855                        if let Some(claim) = inflight_claims.front() {
2856                            return Err(AgentError::LLM(format!(
2857                                "actor terminated before durably admitting SessionInbox message {}; canonical claim remains recoverable",
2858                                claim.envelope.id
2859                            )));
2860                        }
2861                        return match status {
2862                            TerminalStatus::Completed => Ok(result),
2863                            TerminalStatus::Cancelled => Err(AgentError::Cancelled),
2864                            TerminalStatus::Error => Err(AgentError::LLM(
2865                                error.unwrap_or_else(|| "actor child errored".to_string()),
2866                            )),
2867                            // The suspend/resume round-trip (host re-dispatch of a
2868                            // nested parent) is not wired here yet; a worker in
2869                            // this build never emits Suspended, so this is
2870                            // unreachable in practice.
2871                            TerminalStatus::Suspended => Err(AgentError::LLM(
2872                                "nested sub-agent suspend received but resume transport is not wired"
2873                                    .to_string(),
2874                            )),
2875                        };
2876                    }
2877                    Ok(None) => {
2878                        return Err(AgentError::LLM(
2879                            "actor child closed before terminal".to_string(),
2880                        ));
2881                    }
2882                    Err(e) => {
2883                        return Err(AgentError::LLM(format!("actor transport error: {e}")));
2884                    }
2885                }
2886            }
2887        }
2888    }
2889
2890    // Only reached on cancellation: ask the child to stop (best-effort), then report cancelled.
2891    let _ = client.send(ParentFrame::Cancel).await;
2892    Err(AgentError::Cancelled)
2893}
2894
2895/// The assignment text = the child session's latest user message (falls back to its title).
2896fn project_id_for_actor_run(
2897    session: &Session,
2898) -> Result<Option<bamboo_domain::ProjectId>, AgentError> {
2899    match crate::project_context::ProjectContextResolver::session_project_identity(session) {
2900        crate::project_context::SessionProjectIdentity::Assigned(project_id) => {
2901            Ok(Some(project_id))
2902        }
2903        crate::project_context::SessionProjectIdentity::Unassigned => Ok(None),
2904        crate::project_context::SessionProjectIdentity::Invalid { raw, message } => {
2905            Err(AgentError::LLM(format!(
2906                "child session carries an invalid Project identity '{raw}': {message}"
2907            )))
2908        }
2909    }
2910}
2911
2912fn logical_identity_for_actor_run(session: &Session, job: &SpawnJob) -> LogicalSessionIdentity {
2913    LogicalSessionIdentity {
2914        session_id: session.id.clone(),
2915        parent_session_id: session
2916            .parent_session_id
2917            .clone()
2918            .or_else(|| Some(job.parent_session_id.clone())),
2919        root_session_id: if session.root_session_id.trim().is_empty() {
2920            job.parent_session_id.clone()
2921        } else {
2922            session.root_session_id.clone()
2923        },
2924    }
2925}
2926
2927fn extract_assignment(session: &Session) -> String {
2928    session
2929        .messages
2930        .iter()
2931        .rev()
2932        .find(|m| matches!(m.role, Role::User))
2933        .map(|m| m.content.clone())
2934        .unwrap_or_else(|| {
2935            session
2936                .metadata
2937                .get("title")
2938                .cloned()
2939                .unwrap_or_else(|| "Execute task".to_string())
2940        })
2941}
2942
2943#[cfg(test)]
2944mod tests {
2945    use super::*;
2946    use crate::SessionActivationRouter;
2947    use bamboo_domain::{RuntimeSessionPersistence, SessionInboxPort, Storage};
2948
2949    fn processless_pool_worker(mailbox_id: &str) -> PooledWorker {
2950        PooledWorker {
2951            worker: SpawnedChild::remote(AgentRecord {
2952                agent_id: mailbox_id.to_string(),
2953                role: "test".to_string(),
2954                labels: Vec::new(),
2955                endpoint: "ws://127.0.0.1:1".to_string(),
2956                pid: 0,
2957                version: String::new(),
2958                started_at: chrono::Utc::now(),
2959                lease_expires_at: chrono::Utc::now(),
2960            }),
2961            mailbox_id: mailbox_id.to_string(),
2962            parked_at: None,
2963        }
2964    }
2965
2966    #[test]
2967    fn parked_worker_deadline_is_inclusive_and_ignores_checked_out_workers() {
2968        let now = Instant::now();
2969        let timeout = Duration::from_secs(300);
2970        assert!(!parked_worker_expired(None, now, timeout));
2971        assert!(!parked_worker_expired(
2972            Some(now - timeout + Duration::from_millis(1)),
2973            now,
2974            timeout
2975        ));
2976        assert!(parked_worker_expired(Some(now - timeout), now, timeout));
2977    }
2978
2979    #[test]
2980    fn idle_pool_global_cap_bounds_distinct_fingerprints() {
2981        let mut pool = WorkerPool::new();
2982        pool.insert(
2983            "fingerprint-a".to_string(),
2984            vec![processless_pool_worker("a")],
2985        );
2986        pool.insert(
2987            "fingerprint-b".to_string(),
2988            vec![processless_pool_worker("b")],
2989        );
2990
2991        assert_eq!(
2992            idle_pool_limit_reason(&pool, "fingerprint-c", 4, 2),
2993            Some("pool_global_limit")
2994        );
2995        assert_eq!(idle_pool_limit_reason(&pool, "fingerprint-c", 4, 3), None);
2996        assert_eq!(
2997            idle_pool_limit_reason(&pool, "fingerprint-a", 1, 8),
2998            Some("pool_per_key_limit")
2999        );
3000    }
3001
3002    #[test]
3003    fn pool_sweep_removes_dead_workers_and_empty_fingerprint_buckets() {
3004        let mut pool = WorkerPool::new();
3005        pool.insert(
3006            "fingerprint-a".to_string(),
3007            vec![processless_pool_worker("dead")],
3008        );
3009
3010        let reaped = take_reapable_workers(
3011            &mut pool,
3012            Instant::now(),
3013            Duration::from_secs(POOLED_IDLE_TIMEOUT_SECS),
3014        );
3015
3016        assert!(pool.is_empty());
3017        assert_eq!(reaped.len(), 1);
3018        assert_eq!(reaped[0].1, PoolReapReason::ProcessExited);
3019    }
3020
3021    #[test]
3022    fn actor_preflight_counts_only_current_session_scoped_denies() {
3023        let config = bamboo_tools::permission::PermissionConfig::new();
3024        let secret_matcher = "TOP_SECRET_ACTOR_DENY_MATCHER";
3025        config.deny_scoped_session_permission(
3026            "target-session",
3027            bamboo_tools::permission::PermissionType::ExecuteCommand,
3028            secret_matcher,
3029        );
3030        config.deny_scoped_session_permission(
3031            "other-session",
3032            bamboo_tools::permission::PermissionType::WriteFile,
3033            "/other/**",
3034        );
3035
3036        assert_eq!(
3037            active_scoped_session_deny_count(&config, "target-session"),
3038            1
3039        );
3040        assert_eq!(
3041            active_scoped_session_deny_count(&config, "clean-session"),
3042            0
3043        );
3044        let error = ensure_no_active_scoped_session_denies(&config, "target-session")
3045            .unwrap_err()
3046            .to_string();
3047        assert!(!error.contains(secret_matcher));
3048        ensure_no_active_scoped_session_denies(&config, "clean-session")
3049            .expect("another session's deny must not block this activation");
3050    }
3051
3052    #[test]
3053    fn permission_posture_event_rejects_oversized_executor_mapping() {
3054        let session = Session::new("mapping-bound", "model");
3055        let event = AgentEvent::PermissionPostureActivated {
3056            session_id: session.id.clone(),
3057            policy_revision: 1,
3058            requested_mode: "default".to_string(),
3059            effective_mode: "default".to_string(),
3060            executor_mapping: "x".repeat(bamboo_domain::MAX_PERMISSION_EXECUTOR_MAPPING_CHARS + 1),
3061        };
3062
3063        assert!(permission_posture_seed_from_event(&session, &event)
3064            .unwrap_err()
3065            .contains("executor mapping"));
3066    }
3067
3068    #[test]
3069    fn remote_audit_revision_and_timestamp_fields_cannot_poison_host_audit() {
3070        let mut session = Session::new("host-resigns-audit", "model");
3071        let hostile_timestamp = "9".repeat(1024);
3072        let event: AgentEvent = serde_json::from_value(serde_json::json!({
3073            "type": "permission_posture_activated",
3074            "session_id": session.id.clone(),
3075            "policy_revision": 31,
3076            "requested_mode": "default",
3077            "effective_mode": "default",
3078            "executor_mapping": "codex_exec:approval_policy=never",
3079            "audit_revision": u64::MAX,
3080            "transitioned_at": hostile_timestamp,
3081        }))
3082        .expect("unknown remote audit fields are ignored by the typed event");
3083        let seed = permission_posture_seed_from_event(&session, &event)
3084            .unwrap()
3085            .expect("permission event");
3086
3087        let host_revision =
3088            bamboo_domain::record_permission_audit(&mut session.metadata, &seed, None).unwrap();
3089        let host_audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
3090            .expect("host-generated complete audit");
3091        assert_eq!(host_audit.audit_revision, host_revision);
3092        assert!(host_audit.audit_revision < bamboo_domain::MAX_PERMISSION_AUDIT_REVISION);
3093        assert_ne!(host_audit.transitioned_at, hostile_timestamp);
3094        assert!(chrono::DateTime::parse_from_rfc3339(&host_audit.transitioned_at).is_ok());
3095    }
3096
3097    struct ActorFaultingPersistence {
3098        inner: Arc<bamboo_storage::LockedSessionStore>,
3099        fail_checkpoint_once: std::sync::atomic::AtomicBool,
3100    }
3101
3102    #[async_trait]
3103    impl RuntimeSessionPersistence for ActorFaultingPersistence {
3104        async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
3105            self.inner.merge_save_runtime(session).await
3106        }
3107
3108        async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
3109            if self
3110                .fail_checkpoint_once
3111                .swap(false, std::sync::atomic::Ordering::SeqCst)
3112            {
3113                return Err(std::io::Error::other("injected actor checkpoint failure"));
3114            }
3115            self.inner.checkpoint_runtime_session(session).await
3116        }
3117
3118        async fn load_runtime_session(&self, session_id: &str) -> std::io::Result<Option<Session>> {
3119            self.inner.storage().load_session(session_id).await
3120        }
3121    }
3122
3123    struct ActorFailBeforeAckInbox {
3124        inner: Arc<dyn SessionInboxPort>,
3125        fail_once: std::sync::atomic::AtomicBool,
3126    }
3127
3128    #[async_trait]
3129    impl SessionInboxPort for ActorFailBeforeAckInbox {
3130        async fn deliver(
3131            &self,
3132            envelope: &bamboo_domain::SessionMessageEnvelope,
3133        ) -> Result<bamboo_domain::SessionInboxReceipt, bamboo_domain::SessionInboxError> {
3134            self.inner.deliver(envelope).await
3135        }
3136
3137        async fn mark_activation_eligible(
3138            &self,
3139            target_session_id: &str,
3140            generation: u64,
3141            policy: bamboo_domain::SessionActivationPolicy,
3142        ) -> Result<(), bamboo_domain::SessionInboxError> {
3143            self.inner
3144                .mark_activation_eligible(target_session_id, generation, policy)
3145                .await
3146        }
3147
3148        async fn claim(
3149            &self,
3150            target_session_id: &str,
3151            limit: usize,
3152        ) -> Result<Vec<SessionInboxClaim>, bamboo_domain::SessionInboxError> {
3153            self.inner.claim(target_session_id, limit).await
3154        }
3155
3156        async fn was_admitted(
3157            &self,
3158            target_session_id: &str,
3159            id: &bamboo_domain::SessionMessageId,
3160        ) -> Result<bool, bamboo_domain::SessionInboxError> {
3161            self.inner.was_admitted(target_session_id, id).await
3162        }
3163
3164        async fn ack(
3165            &self,
3166            target_session_id: &str,
3167            claim: &SessionInboxClaim,
3168        ) -> Result<(), bamboo_domain::SessionInboxError> {
3169            if self
3170                .fail_once
3171                .swap(false, std::sync::atomic::Ordering::SeqCst)
3172            {
3173                return Err(bamboo_domain::SessionInboxError::Storage(
3174                    "injected actor pre-ack failure".to_string(),
3175                ));
3176            }
3177            self.inner.ack(target_session_id, claim).await
3178        }
3179
3180        async fn inspect(
3181            &self,
3182            target_session_id: &str,
3183        ) -> Result<bamboo_domain::SessionInboxBacklog, bamboo_domain::SessionInboxError> {
3184            self.inner.inspect(target_session_id).await
3185        }
3186    }
3187
3188    async fn actor_inbox_fixture(
3189        session_id: &str,
3190    ) -> (
3191        tempfile::TempDir,
3192        Arc<bamboo_storage::SessionStoreV2>,
3193        Arc<bamboo_storage::LockedSessionStore>,
3194        Arc<dyn SessionInboxPort>,
3195        Session,
3196        SessionInboxClaim,
3197    ) {
3198        let temp = tempfile::tempdir().unwrap();
3199        let store = Arc::new(
3200            bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
3201                .await
3202                .unwrap(),
3203        );
3204        let storage: Arc<dyn Storage> = store.clone();
3205        let locked = Arc::new(bamboo_storage::LockedSessionStore::new(storage));
3206        let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
3207            store.clone(),
3208            bamboo_domain::SessionInboxLimits::default(),
3209        ));
3210        let session = Session::new(session_id, "model");
3211        store.save_session(&session).await.unwrap();
3212        let mut envelope =
3213            bamboo_domain::SessionMessageEnvelope::user_input(session_id, "actor follow-up");
3214        envelope.id =
3215            bamboo_domain::SessionMessageId::parse(format!("{session_id}-message")).unwrap();
3216        let receipt = inbox.deliver(&envelope).await.unwrap();
3217        inbox
3218            .mark_activation_eligible(
3219                session_id,
3220                receipt.generation,
3221                bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
3222            )
3223            .await
3224            .unwrap();
3225        let claim = inbox.claim(session_id, 1).await.unwrap().remove(0);
3226        (temp, store, locked, inbox, session, claim)
3227    }
3228
3229    fn actor_binding(
3230        store: Arc<bamboo_storage::SessionStoreV2>,
3231        inbox: Arc<dyn SessionInboxPort>,
3232        persistence: Arc<dyn RuntimeSessionPersistence>,
3233    ) -> SessionInboxRuntimeBinding {
3234        let storage: Arc<dyn Storage> = store;
3235        SessionInboxRuntimeBinding {
3236            router: SessionActivationRouter::new(),
3237            inbox,
3238            storage,
3239            persistence,
3240        }
3241    }
3242
3243    #[tokio::test]
3244    async fn actor_mismatched_typed_marker_id_collision_never_acks() {
3245        let (_temp, store, locked, inbox, mut session, claim) =
3246            actor_inbox_fixture("actor-live-id-collision").await;
3247        let mut forged = claim.envelope.to_provider_message().unwrap();
3248        forged.metadata = Some(serde_json::json!({
3249            "session_message": {
3250                "id": claim.envelope.id,
3251                "target_session_id": "different-session"
3252            }
3253        }));
3254        session.add_message(forged);
3255        let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
3256        let binding = actor_binding(store, inbox.clone(), persistence);
3257
3258        assert!(
3259            checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim)
3260                .await
3261                .is_err()
3262        );
3263        assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
3264        assert!(!inbox
3265            .was_admitted(&session.id, &claim.envelope.id)
3266            .await
3267            .unwrap());
3268    }
3269
3270    #[tokio::test]
3271    async fn actor_concurrent_durable_id_collision_after_claim_never_acks() {
3272        let (_temp, store, locked, inbox, mut session, claim) =
3273            actor_inbox_fixture("actor-durable-id-collision").await;
3274        let mut concurrent = store.load_session(&session.id).await.unwrap().unwrap();
3275        let mut forged = bamboo_agent_core::Message::user("concurrent actor collision");
3276        forged.id = claim.envelope.id.to_string();
3277        concurrent.add_message(forged);
3278        store.save_session(&concurrent).await.unwrap();
3279        let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
3280        let binding = actor_binding(store.clone(), inbox.clone(), persistence);
3281
3282        assert!(
3283            checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim)
3284                .await
3285                .is_err()
3286        );
3287        assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
3288        assert!(!inbox
3289            .was_admitted(&session.id, &claim.envelope.id)
3290            .await
3291            .unwrap());
3292        let durable = store.load_session(&session.id).await.unwrap().unwrap();
3293        assert!(!durable.messages.iter().any(|message| {
3294            bamboo_domain::is_matching_session_message(message, &claim.envelope)
3295        }));
3296    }
3297
3298    #[tokio::test]
3299    async fn actor_concurrent_durable_typed_body_mismatch_never_acks() {
3300        let (_temp, store, locked, inbox, mut session, claim) =
3301            actor_inbox_fixture("actor-durable-typed-body-collision").await;
3302        let mut different = claim.envelope.clone();
3303        different.body = bamboo_domain::SessionMessageBody::Content(
3304            bamboo_domain::SessionMessageContent::text("forged actor body"),
3305        );
3306        let mut concurrent = store.load_session(&session.id).await.unwrap().unwrap();
3307        concurrent.add_message(different.to_provider_message().unwrap());
3308        store.save_session(&concurrent).await.unwrap();
3309        let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
3310        let binding = actor_binding(store.clone(), inbox.clone(), persistence);
3311
3312        assert!(
3313            checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim)
3314                .await
3315                .is_err()
3316        );
3317        assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
3318        assert!(!inbox
3319            .was_admitted(&session.id, &claim.envelope.id)
3320            .await
3321            .unwrap());
3322        let durable = store.load_session(&session.id).await.unwrap().unwrap();
3323        assert!(!durable
3324            .messages
3325            .iter()
3326            .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope)));
3327    }
3328
3329    #[tokio::test]
3330    async fn actor_checkpoint_failure_rolls_back_and_restart_admits_once() {
3331        let (_temp, store, locked, inbox, mut session, claim) =
3332            actor_inbox_fixture("actor-checkpoint-failure").await;
3333        let envelope_id = claim.envelope.id.clone();
3334        let fault: Arc<dyn RuntimeSessionPersistence> = Arc::new(ActorFaultingPersistence {
3335            inner: locked.clone(),
3336            fail_checkpoint_once: std::sync::atomic::AtomicBool::new(true),
3337        });
3338        let binding = actor_binding(store.clone(), inbox.clone(), fault);
3339
3340        assert!(
3341            checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim)
3342                .await
3343                .is_err()
3344        );
3345        assert!(!session
3346            .messages
3347            .iter()
3348            .any(|message| message.id == envelope_id.as_str()));
3349        assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
3350        assert!(!inbox.was_admitted(&session.id, &envelope_id).await.unwrap());
3351
3352        let reopened: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
3353            store.clone(),
3354            bamboo_domain::SessionInboxLimits::default(),
3355        ));
3356        let recovered = reopened.claim(&session.id, 1).await.unwrap().remove(0);
3357        let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
3358        let binding = actor_binding(store.clone(), reopened.clone(), persistence);
3359        let mut restarted = store.load_session(&session.id).await.unwrap().unwrap();
3360        checkpoint_and_ack_canonical_claim(&binding, &mut restarted, &recovered)
3361            .await
3362            .unwrap();
3363        assert_eq!(
3364            restarted
3365                .messages
3366                .iter()
3367                .filter(|message| message.id == envelope_id.as_str())
3368                .count(),
3369            1
3370        );
3371        assert!(reopened
3372            .was_admitted(&session.id, &envelope_id)
3373            .await
3374            .unwrap());
3375        let backlog = reopened.inspect(&session.id).await.unwrap();
3376        assert_eq!(backlog.pending + backlog.claimed, 0);
3377    }
3378
3379    #[tokio::test]
3380    async fn actor_checkpoint_success_pre_ack_failure_recovers_without_duplicate() {
3381        let (_temp, store, locked, real_inbox, mut session, claim) =
3382            actor_inbox_fixture("actor-pre-ack-failure").await;
3383        let envelope_id = claim.envelope.id.clone();
3384        let faulted: Arc<dyn SessionInboxPort> = Arc::new(ActorFailBeforeAckInbox {
3385            inner: real_inbox.clone(),
3386            fail_once: std::sync::atomic::AtomicBool::new(true),
3387        });
3388        let persistence: Arc<dyn RuntimeSessionPersistence> = locked.clone();
3389        let binding = actor_binding(store.clone(), faulted, persistence);
3390
3391        assert!(
3392            checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim)
3393                .await
3394                .is_err()
3395        );
3396        let durable = store.load_session(&session.id).await.unwrap().unwrap();
3397        assert_eq!(
3398            durable
3399                .messages
3400                .iter()
3401                .filter(|message| message.id == envelope_id.as_str())
3402                .count(),
3403            1
3404        );
3405        assert_eq!(real_inbox.inspect(&session.id).await.unwrap().claimed, 1);
3406        assert!(!real_inbox
3407            .was_admitted(&session.id, &envelope_id)
3408            .await
3409            .unwrap());
3410
3411        let reopened: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
3412            store.clone(),
3413            bamboo_domain::SessionInboxLimits::default(),
3414        ));
3415        let recovered = reopened.claim(&session.id, 1).await.unwrap().remove(0);
3416        let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
3417        let binding = actor_binding(store.clone(), reopened.clone(), persistence);
3418        let mut restarted = durable;
3419        checkpoint_and_ack_canonical_claim(&binding, &mut restarted, &recovered)
3420            .await
3421            .unwrap();
3422        assert_eq!(
3423            restarted
3424                .messages
3425                .iter()
3426                .filter(|message| message.id == envelope_id.as_str())
3427                .count(),
3428            1
3429        );
3430        assert!(reopened
3431            .was_admitted(&session.id, &envelope_id)
3432            .await
3433            .unwrap());
3434        let backlog = reopened.inspect(&session.id).await.unwrap();
3435        assert_eq!(backlog.pending + backlog.claimed, 0);
3436    }
3437
3438    struct ConfirmationSequenceLink {
3439        frames: VecDeque<ChildFrame>,
3440        sent: Vec<ParentFrame>,
3441    }
3442
3443    #[async_trait]
3444    impl bamboo_subagent::ChildLink for ConfirmationSequenceLink {
3445        async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
3446            self.sent.push(frame);
3447            Ok(())
3448        }
3449
3450        async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
3451            match self.frames.pop_front() {
3452                Some(frame) => Ok(Some(frame)),
3453                None => std::future::pending().await,
3454            }
3455        }
3456    }
3457
3458    fn admission_confirmation(
3459        session_id: &str,
3460        claim: &SessionInboxClaim,
3461        run_id: &str,
3462    ) -> bamboo_subagent::proto::SessionMessageAdmissionConfirmation {
3463        bamboo_subagent::proto::SessionMessageAdmissionConfirmation {
3464            target_session_id: session_id.to_string(),
3465            envelope_id: claim.envelope.id.to_string(),
3466            canonical_claim_generation: claim.generation,
3467            activation_run_id: run_id.to_string(),
3468        }
3469    }
3470
3471    fn expected_default_permission_posture(policy_revision: u64) -> ExpectedPermissionPosture {
3472        ExpectedPermissionPosture {
3473            policy_revision,
3474            resolution: bamboo_domain::PermissionModeResolution {
3475                requested: bamboo_domain::SessionPermissionMode::Default,
3476                effective: bamboo_domain::PermissionMode::Default,
3477            },
3478            expected_audit_revision: None,
3479            executor_mapping: "test_actor:permission_mode=default".to_string(),
3480        }
3481    }
3482
3483    fn permission_posture_frame(session_id: &str, policy_revision: u64) -> ChildFrame {
3484        ChildFrame::Event {
3485            event: serde_json::to_value(AgentEvent::PermissionPostureActivated {
3486                session_id: session_id.to_string(),
3487                policy_revision,
3488                requested_mode: "default".to_string(),
3489                effective_mode: "default".to_string(),
3490                executor_mapping: "test_actor:permission_mode=default".to_string(),
3491            })
3492            .expect("serialize permission posture event"),
3493        }
3494    }
3495
3496    fn actor_event_frame(event: AgentEvent) -> ChildFrame {
3497        ChildFrame::Event {
3498            event: serde_json::to_value(event).expect("serialize actor event"),
3499        }
3500    }
3501
3502    #[test]
3503    fn actor_event_batch_is_fenced_by_session_activation_and_epoch() {
3504        let session = Session::new_child("logical-child", "logical-parent", "model", "child");
3505        let mut batch = ActorEventBatch {
3506            logical_session: Some(LogicalSessionIdentity {
3507                session_id: session.id.clone(),
3508                parent_session_id: session.parent_session_id.clone(),
3509                root_session_id: session.root_session_id.clone(),
3510            }),
3511            activation_id: Some("activation-7".into()),
3512            execution_epoch: 11,
3513            source_node_id: Some("node-a".into()),
3514            source_actor_id: Some("worker-a".into()),
3515            first_seq: 1,
3516            last_seq: 1,
3517            qos: bamboo_subagent::ActorEventQos::Ephemeral,
3518            events: vec![serde_json::json!({"type":"token","content":"ok"})],
3519        };
3520        validate_actor_event_batch(
3521            &batch,
3522            &session,
3523            "logical-parent",
3524            Some("activation-7"),
3525            11,
3526            "worker-a",
3527        )
3528        .unwrap();
3529
3530        batch.execution_epoch = 10;
3531        assert!(validate_actor_event_batch(
3532            &batch,
3533            &session,
3534            "logical-parent",
3535            Some("activation-7"),
3536            11,
3537            "worker-a",
3538        )
3539        .unwrap_err()
3540        .to_string()
3541        .contains("execution epoch"));
3542        batch.execution_epoch = 11;
3543        batch.activation_id = Some("replaced-activation".into());
3544        assert!(validate_actor_event_batch(
3545            &batch,
3546            &session,
3547            "logical-parent",
3548            Some("activation-7"),
3549            11,
3550            "worker-a",
3551        )
3552        .unwrap_err()
3553        .to_string()
3554        .contains("stale activation"));
3555        batch.activation_id = Some("activation-7".into());
3556        batch
3557            .logical_session
3558            .as_mut()
3559            .expect("logical identity")
3560            .session_id = "foreign-child".into();
3561        assert!(validate_actor_event_batch(
3562            &batch,
3563            &session,
3564            "logical-parent",
3565            Some("activation-7"),
3566            11,
3567            "worker-a",
3568        )
3569        .unwrap_err()
3570        .to_string()
3571        .contains("different logical session"));
3572        batch
3573            .logical_session
3574            .as_mut()
3575            .expect("logical identity")
3576            .session_id = session.id.clone();
3577        batch.source_actor_id = Some("worker-b".into());
3578        assert!(validate_actor_event_batch(
3579            &batch,
3580            &session,
3581            "logical-parent",
3582            Some("activation-7"),
3583            11,
3584            "worker-a",
3585        )
3586        .unwrap_err()
3587        .to_string()
3588        .contains("different physical actor"));
3589    }
3590
3591    fn completed_actor_frame() -> ChildFrame {
3592        ChildFrame::Terminal {
3593            status: TerminalStatus::Completed,
3594            result: Some("done".to_string()),
3595            error: None,
3596            transcript: Vec::new(),
3597        }
3598    }
3599
3600    async fn drive_permission_handshake_frames(
3601        session_id: &str,
3602        frames: impl IntoIterator<Item = ChildFrame>,
3603        expected: ExpectedPermissionPosture,
3604    ) -> (
3605        crate::runtime::runner::Result<Option<String>>,
3606        Session,
3607        Vec<AgentEvent>,
3608        Vec<ParentFrame>,
3609    ) {
3610        let mut link = ConfirmationSequenceLink {
3611            frames: frames.into_iter().collect(),
3612            sent: Vec::new(),
3613        };
3614        let (event_tx, mut event_rx) = mpsc::channel(16);
3615        let cancel = CancellationToken::new();
3616        let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
3617        let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
3618        let mut session = Session::new(session_id, "model");
3619        let result = drive(ActorDriveContext {
3620            client: &mut link,
3621            parent_session_id: "permission-parent",
3622            child_session_id: session_id,
3623            child_attempt: 0,
3624            approval_registry: None,
3625            approval_decider: None,
3626            approval_reviewer: None,
3627            escalation_bridge: None,
3628            event_tx: &event_tx,
3629            cancel_token: &cancel,
3630            live_rx: &mut live_rx,
3631            delivery_rx: &mut delivery_rx,
3632            logical_session: &mut session,
3633            expected_permission_posture: Some(expected),
3634            session_inbox_runtime: None,
3635            activation_run_id: None,
3636            execution_epoch: 0,
3637            expected_source_actor_id: session_id,
3638            initial_inflight_claims: VecDeque::new(),
3639            first_frame_timeout: Some(Duration::from_secs(1)),
3640        })
3641        .await;
3642        let events = std::iter::from_fn(|| event_rx.try_recv().ok()).collect();
3643        (result, session, events, link.sent)
3644    }
3645
3646    #[tokio::test]
3647    async fn actor_permission_handshake_rejects_terminal_without_posture() {
3648        let session_id = "permission-missing";
3649        let (result, session, events, _) = drive_permission_handshake_frames(
3650            session_id,
3651            [completed_actor_frame()],
3652            expected_default_permission_posture(7),
3653        )
3654        .await;
3655
3656        assert!(result
3657            .unwrap_err()
3658            .to_string()
3659            .contains("terminated before permission posture confirmation"));
3660        assert!(events.is_empty());
3661        assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
3662    }
3663
3664    #[tokio::test]
3665    async fn actor_permission_handshake_rejects_malformed_agent_event() {
3666        let session_id = "permission-malformed";
3667        let (result, session, events, _) = drive_permission_handshake_frames(
3668            session_id,
3669            [ChildFrame::Event {
3670                event: serde_json::json!({"type": "token", "content": 42}),
3671            }],
3672            expected_default_permission_posture(7),
3673        )
3674        .await;
3675
3676        assert!(result
3677            .unwrap_err()
3678            .to_string()
3679            .contains("malformed AgentEvent"));
3680        assert!(events.is_empty());
3681        assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
3682    }
3683
3684    #[tokio::test]
3685    async fn actor_permission_handshake_rejects_execution_event_before_posture() {
3686        let session_id = "permission-early-event";
3687        let early_events = [
3688            (
3689                "progress",
3690                AgentEvent::RunnerProgress {
3691                    session_id: session_id.to_string(),
3692                    round_count: 1,
3693                },
3694            ),
3695            (
3696                "token",
3697                AgentEvent::Token {
3698                    content: "must-not-forward".to_string(),
3699                },
3700            ),
3701            (
3702                "tool",
3703                AgentEvent::ToolStart {
3704                    tool_call_id: "early-tool".to_string(),
3705                    tool_name: "Read".to_string(),
3706                    arguments: serde_json::json!({"file_path": "README.md"}),
3707                },
3708            ),
3709        ];
3710        for (kind, event) in early_events {
3711            let (result, session, events, _) = drive_permission_handshake_frames(
3712                session_id,
3713                [
3714                    actor_event_frame(event),
3715                    permission_posture_frame(session_id, 7),
3716                    completed_actor_frame(),
3717                ],
3718                expected_default_permission_posture(7),
3719            )
3720            .await;
3721
3722            assert!(
3723                result
3724                    .unwrap_err()
3725                    .to_string()
3726                    .contains("execution event before permission posture confirmation"),
3727                "{kind} must fail closed before posture"
3728            );
3729            assert!(events.is_empty(), "{kind} must not be forwarded");
3730            assert!(
3731                bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none(),
3732                "{kind} must not advance the permission audit"
3733            );
3734        }
3735    }
3736
3737    #[tokio::test]
3738    async fn actor_permission_handshake_rejects_approval_before_posture() {
3739        let session_id = "permission-early-approval";
3740        let (result, session, events, sent) = drive_permission_handshake_frames(
3741            session_id,
3742            [ChildFrame::ApprovalRequest {
3743                id: "approval-before-posture".to_string(),
3744                body: serde_json::json!({
3745                    "tool_name": "Bash",
3746                    "permission": "execute",
3747                    "resource": "echo must-not-run"
3748                }),
3749            }],
3750            expected_default_permission_posture(7),
3751        )
3752        .await;
3753
3754        assert!(result
3755            .unwrap_err()
3756            .to_string()
3757            .contains("requested approval before permission posture confirmation"));
3758        assert!(events.is_empty());
3759        assert!(
3760            sent.is_empty(),
3761            "an unconfirmed actor must receive no approval reply"
3762        );
3763        assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
3764    }
3765
3766    #[tokio::test]
3767    async fn actor_permission_handshake_rejects_mismatched_posture() {
3768        let session_id = "permission-mismatch";
3769        let (result, session, events, _) = drive_permission_handshake_frames(
3770            session_id,
3771            [permission_posture_frame(session_id, 8)],
3772            expected_default_permission_posture(7),
3773        )
3774        .await;
3775
3776        assert!(result
3777            .unwrap_err()
3778            .to_string()
3779            .contains("does not match the host-dispatched policy"));
3780        assert!(events.is_empty());
3781        assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
3782    }
3783
3784    #[tokio::test]
3785    async fn actor_permission_handshake_rejects_untrusted_executor_mapping() {
3786        let session_id = "permission-hostile-mapping";
3787        for hostile_mapping in [
3788            "wrong_executor:permission_mode=default",
3789            "test_actor:permission_mode=default;credential=must-not-persist",
3790        ] {
3791            let frame = ChildFrame::Event {
3792                event: serde_json::to_value(AgentEvent::PermissionPostureActivated {
3793                    session_id: session_id.to_string(),
3794                    policy_revision: 7,
3795                    requested_mode: "default".to_string(),
3796                    effective_mode: "default".to_string(),
3797                    executor_mapping: hostile_mapping.to_string(),
3798                })
3799                .expect("serialize hostile posture fixture"),
3800            };
3801            let (result, session, events, _) = drive_permission_handshake_frames(
3802                session_id,
3803                [frame],
3804                expected_default_permission_posture(7),
3805            )
3806            .await;
3807
3808            let error = result.unwrap_err().to_string();
3809            assert!(error.contains("host-dispatched executor mapping"));
3810            assert!(
3811                !error.contains(hostile_mapping),
3812                "untrusted mapping must not be reflected in host errors"
3813            );
3814            assert!(events.is_empty());
3815            assert!(
3816                bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none(),
3817                "untrusted mapping must not reach durable or in-memory audit state"
3818            );
3819        }
3820    }
3821
3822    #[tokio::test]
3823    async fn actor_permission_handshake_rejects_duplicate_posture() {
3824        let session_id = "permission-duplicate";
3825        let (result, session, events, _) = drive_permission_handshake_frames(
3826            session_id,
3827            [
3828                permission_posture_frame(session_id, 7),
3829                permission_posture_frame(session_id, 7),
3830                completed_actor_frame(),
3831            ],
3832            expected_default_permission_posture(7),
3833        )
3834        .await;
3835
3836        assert!(result
3837            .unwrap_err()
3838            .to_string()
3839            .contains("duplicate permission posture activation"));
3840        assert_eq!(
3841            events.len(),
3842            1,
3843            "only the confirmed posture may be forwarded"
3844        );
3845        assert!(matches!(
3846            events[0],
3847            AgentEvent::PermissionPostureActivated { .. }
3848        ));
3849        let audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
3850            .expect("the first matching posture must be recorded");
3851        assert_eq!(audit.policy_revision, 7);
3852    }
3853
3854    #[tokio::test]
3855    async fn actor_permission_handshake_happy_path_persists_before_forwarding_execution() {
3856        let session_id = "permission-happy";
3857        let (result, session, events, _) = drive_permission_handshake_frames(
3858            session_id,
3859            [
3860                permission_posture_frame(session_id, 7),
3861                actor_event_frame(AgentEvent::RunnerProgress {
3862                    session_id: session_id.to_string(),
3863                    round_count: 1,
3864                }),
3865                actor_event_frame(AgentEvent::Token {
3866                    content: "working".to_string(),
3867                }),
3868                actor_event_frame(AgentEvent::ToolStart {
3869                    tool_call_id: "tool-1".to_string(),
3870                    tool_name: "Read".to_string(),
3871                    arguments: serde_json::json!({"file_path": "README.md"}),
3872                }),
3873                completed_actor_frame(),
3874            ],
3875            expected_default_permission_posture(7),
3876        )
3877        .await;
3878
3879        assert_eq!(result.unwrap().as_deref(), Some("done"));
3880        assert!(matches!(
3881            events.as_slice(),
3882            [
3883                AgentEvent::PermissionPostureActivated { .. },
3884                AgentEvent::RunnerProgress { .. },
3885                AgentEvent::Token { .. },
3886                AgentEvent::ToolStart { .. }
3887            ]
3888        ));
3889        let audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
3890            .expect("matching posture must be recorded before execution events are accepted");
3891        assert_eq!(audit.policy_revision, 7);
3892        assert_eq!(audit.executor_mapping, "test_actor:permission_mode=default");
3893    }
3894
3895    #[tokio::test]
3896    async fn actor_initial_batch_acks_in_order_and_rejects_stale_confirmation() {
3897        let temp = tempfile::tempdir().unwrap();
3898        let store = Arc::new(
3899            bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
3900                .await
3901                .unwrap(),
3902        );
3903        let storage: Arc<dyn Storage> = store.clone();
3904        let locked = Arc::new(bamboo_storage::LockedSessionStore::new(storage.clone()));
3905        let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
3906            store.clone(),
3907            bamboo_domain::SessionInboxLimits::default(),
3908        ));
3909        let session_id = "actor-confirmation-order";
3910        let run_id = "actor-run-current";
3911        let mut session = Session::new(session_id, "model");
3912        store.save_session(&session).await.unwrap();
3913        for (id, text) in [("actor-first", "first"), ("actor-second", "second")] {
3914            let mut envelope = bamboo_domain::SessionMessageEnvelope::user_input(session_id, text);
3915            envelope.id = bamboo_domain::SessionMessageId::parse(id).unwrap();
3916            inbox.deliver(&envelope).await.unwrap();
3917        }
3918        inbox
3919            .mark_activation_eligible(
3920                session_id,
3921                2,
3922                bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
3923            )
3924            .await
3925            .unwrap();
3926        let router = SessionActivationRouter::new();
3927        let mut owner_registration = router.register_run(session_id, run_id).await.unwrap();
3928        let binding = SessionInboxRuntimeBinding {
3929            router,
3930            inbox: inbox.clone(),
3931            storage,
3932            persistence: locked,
3933        };
3934        let pairs = claim_canonical_deliveries(&binding, &mut session, run_id, usize::MAX)
3935            .await
3936            .unwrap();
3937        assert_eq!(
3938            pairs
3939                .iter()
3940                .map(|(claim, _)| claim.envelope.id.as_str())
3941                .collect::<Vec<_>>(),
3942            vec!["actor-first", "actor-second"]
3943        );
3944        let seeded = store.load_session(session_id).await.unwrap().unwrap();
3945        assert_eq!(
3946            seeded
3947                .messages
3948                .iter()
3949                .filter(|message| matches!(message.id.as_str(), "actor-first" | "actor-second"))
3950                .map(|message| message.id.as_str())
3951                .collect::<Vec<_>>(),
3952            vec!["actor-first", "actor-second"],
3953            "host context must be durable before actor dispatch"
3954        );
3955        for (claim, _) in &pairs {
3956            assert_eq!(
3957                seeded
3958                    .messages
3959                    .iter()
3960                    .filter(|message| bamboo_domain::is_matching_session_message(
3961                        message,
3962                        &claim.envelope
3963                    ))
3964                    .count(),
3965                1,
3966                "pre-dispatch host checkpoint must contain exactly one canonical marker for {}",
3967                claim.envelope.id
3968            );
3969        }
3970        assert!(
3971            seeded.session_inbox_admission().is_none_or(|cursor| {
3972                !cursor.contains(&pairs[0].0.envelope.id)
3973                    && !cursor.contains(&pairs[1].0.envelope.id)
3974            }),
3975            "pre-dispatch transcript seeding must not forge worker confirmation"
3976        );
3977        assert_eq!(inbox.inspect(session_id).await.unwrap().claimed, 2);
3978        let claims = pairs
3979            .into_iter()
3980            .map(|(claim, _)| claim)
3981            .collect::<VecDeque<_>>();
3982        let first = claims[0].clone();
3983        let second = claims[1].clone();
3984        let mut stale = admission_confirmation(session_id, &first, "stale-run");
3985        stale.canonical_claim_generation = second.generation;
3986        let mut link = ConfirmationSequenceLink {
3987            frames: VecDeque::from([
3988                ChildFrame::SessionMessageAdmitted {
3989                    confirmation: stale,
3990                },
3991                ChildFrame::SessionMessageAdmitted {
3992                    confirmation: admission_confirmation(session_id, &first, run_id),
3993                },
3994                ChildFrame::SessionMessageAdmitted {
3995                    confirmation: admission_confirmation(session_id, &second, run_id),
3996                },
3997                ChildFrame::Terminal {
3998                    status: TerminalStatus::Completed,
3999                    result: Some("done".to_string()),
4000                    error: None,
4001                    transcript: Vec::new(),
4002                },
4003            ]),
4004            sent: Vec::new(),
4005        };
4006        let (event_tx, _event_rx) = mpsc::channel(8);
4007        let cancel = CancellationToken::new();
4008        let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
4009        let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
4010        let result = drive(ActorDriveContext {
4011            client: &mut link,
4012            parent_session_id: "parent",
4013            child_session_id: session_id,
4014            child_attempt: 0,
4015            approval_registry: None,
4016            approval_decider: None,
4017            approval_reviewer: None,
4018            escalation_bridge: None,
4019            event_tx: &event_tx,
4020            cancel_token: &cancel,
4021            live_rx: &mut live_rx,
4022            delivery_rx: &mut delivery_rx,
4023            logical_session: &mut session,
4024            expected_permission_posture: None,
4025            session_inbox_runtime: Some(&binding),
4026            activation_run_id: Some(run_id),
4027            execution_epoch: 0,
4028            expected_source_actor_id: session_id,
4029            initial_inflight_claims: claims,
4030            first_frame_timeout: Some(Duration::from_secs(1)),
4031        })
4032        .await
4033        .unwrap();
4034        assert_eq!(result.as_deref(), Some("done"));
4035        assert_eq!(
4036            session
4037                .messages
4038                .iter()
4039                .filter(|message| matches!(message.id.as_str(), "actor-first" | "actor-second"))
4040                .map(|message| message.id.as_str())
4041                .collect::<Vec<_>>(),
4042            vec!["actor-first", "actor-second"]
4043        );
4044        let backlog = inbox.inspect(session_id).await.unwrap();
4045        assert_eq!(backlog.pending + backlog.claimed, 0);
4046        let confirmed = store.load_session(session_id).await.unwrap().unwrap();
4047        let cursor = confirmed
4048            .session_inbox_admission()
4049            .expect("exact worker confirmation must checkpoint the admission cursor");
4050        assert!(cursor.contains(&first.envelope.id));
4051        assert!(cursor.contains(&second.envelope.id));
4052        for claim in [&first, &second] {
4053            assert_eq!(
4054                confirmed
4055                    .messages
4056                    .iter()
4057                    .filter(|message| bamboo_domain::is_matching_session_message(
4058                        message,
4059                        &claim.envelope
4060                    ))
4061                    .count(),
4062                1,
4063                "confirmation must retain one exact canonical marker for {}",
4064                claim.envelope.id
4065            );
4066        }
4067        assert!(inbox
4068            .was_admitted(session_id, &first.envelope.id)
4069            .await
4070            .unwrap());
4071        assert!(inbox
4072            .was_admitted(session_id, &second.envelope.id)
4073            .await
4074            .unwrap());
4075        owner_registration.begin_finalization().await;
4076        owner_registration.finish(2).await.unwrap();
4077    }
4078
4079    #[derive(Default)]
4080    struct RecordingCodexTokenAuthority {
4081        issued_for: std::sync::Mutex<Vec<String>>,
4082        revoked: std::sync::Mutex<Vec<String>>,
4083    }
4084
4085    impl CodexRunTokenAuthority for RecordingCodexTokenAuthority {
4086        fn issue(&self, session_id: &str) -> Result<IssuedCodexRunToken, String> {
4087            self.issued_for
4088                .lock()
4089                .expect("issued fixture lock")
4090                .push(session_id.to_string());
4091            Ok(IssuedCodexRunToken {
4092                token_id: format!("id-{session_id}"),
4093                token: format!("bcx1_secret-{session_id}"),
4094            })
4095        }
4096
4097        fn revoke(&self, token_id: &str) {
4098            self.revoked
4099                .lock()
4100                .expect("revoked fixture lock")
4101                .push(token_id.to_string());
4102        }
4103    }
4104
4105    fn codex_executor(auth_mode: Option<&str>, inherit_user_config: Option<bool>) -> ExecutorSpec {
4106        let bamboo_mode = auth_mode == Some("bamboo")
4107            || (auth_mode.is_none() && !inherit_user_config.unwrap_or(false));
4108        ExecutorSpec::Codex {
4109            binary: None,
4110            model: None,
4111            mode: None,
4112            sandbox: None,
4113            inherit_user_config,
4114            auth_mode: auth_mode.map(str::to_string),
4115            base_url: bamboo_mode.then(|| "http://127.0.0.1:9562/openai/v1".to_string()),
4116            wire_api: Some("responses".to_string()),
4117            provider_key_ref: None,
4118            forward_env: None,
4119            approval_policy: None,
4120            network_access: None,
4121            allow_danger_bypass: None,
4122            permission_profile: None,
4123            workspace_owned: None,
4124        }
4125    }
4126
4127    fn permission_resolution(
4128        requested: bamboo_domain::SessionPermissionMode,
4129        effective: bamboo_domain::PermissionMode,
4130    ) -> bamboo_domain::PermissionModeResolution {
4131        bamboo_domain::PermissionModeResolution {
4132            requested,
4133            effective,
4134        }
4135    }
4136
4137    #[test]
4138    fn permission_posture_mapping_contract_is_exact_for_supported_executors() {
4139        use bamboo_domain::{PermissionMode, SessionPermissionMode};
4140
4141        let default =
4142            permission_resolution(SessionPermissionMode::Default, PermissionMode::Default);
4143        assert_eq!(
4144            expected_permission_executor_mapping(&ExecutorSpec::BambooRuntime, default, false)
4145                .unwrap()
4146                .as_deref(),
4147            Some("bamboo_runtime:default")
4148        );
4149        assert_eq!(
4150            expected_permission_executor_mapping(&ExecutorSpec::Echo, default, false).unwrap(),
4151            None,
4152            "transport-only Echo must not claim the typed permission contract"
4153        );
4154        assert_eq!(
4155            expected_permission_executor_mapping(
4156                &ExecutorSpec::CliAdapter {
4157                    command: "must-not-appear-in-contract".to_string(),
4158                    args: vec!["credential-like-argument".to_string()],
4159                },
4160                default,
4161                false,
4162            )
4163            .unwrap(),
4164            None,
4165            "unimplemented CliAdapter must not leak command data into a contract"
4166        );
4167
4168        let claude = ExecutorSpec::ClaudeCode {
4169            binary: None,
4170            model: None,
4171            permission_mode: Some("default".to_string()),
4172            inherit_user_config: None,
4173            forward_env: None,
4174        };
4175        for (resolution, mapping) in [
4176            (
4177                permission_resolution(SessionPermissionMode::Default, PermissionMode::Plan),
4178                "claude_code:permission_mode=plan",
4179            ),
4180            (
4181                permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
4182                "claude_code:permission_mode=bypassPermissions",
4183            ),
4184            (
4185                permission_resolution(SessionPermissionMode::Default, PermissionMode::AcceptEdits),
4186                "claude_code:permission_mode=acceptEdits",
4187            ),
4188            (
4189                permission_resolution(SessionPermissionMode::Default, PermissionMode::DontAsk),
4190                "claude_code:permission_mode=dontAsk",
4191            ),
4192            (
4193                permission_resolution(
4194                    SessionPermissionMode::Bypass,
4195                    PermissionMode::BypassPermissions,
4196                ),
4197                "claude_code:permission_mode=default",
4198            ),
4199        ] {
4200            assert_eq!(
4201                expected_permission_executor_mapping(&claude, resolution, false)
4202                    .unwrap()
4203                    .as_deref(),
4204                Some(mapping)
4205            );
4206        }
4207        assert_eq!(
4208            expected_permission_executor_mapping(&claude, default, true)
4209                .unwrap()
4210                .as_deref(),
4211            Some("claude_code:blocked_explicit_deny")
4212        );
4213
4214        let mut codex_exec = codex_executor(Some("inherit"), Some(true));
4215        if let ExecutorSpec::Codex {
4216            approval_policy, ..
4217        } = &mut codex_exec
4218        {
4219            *approval_policy = Some("on-failure".to_string());
4220        }
4221        assert_eq!(
4222            expected_permission_executor_mapping(&codex_exec, default, false)
4223                .unwrap()
4224                .as_deref(),
4225            Some("codex_exec:approval_policy=on-failure")
4226        );
4227        assert_eq!(
4228            expected_permission_executor_mapping(
4229                &codex_exec,
4230                permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
4231                false,
4232            )
4233            .unwrap()
4234            .as_deref(),
4235            Some("codex_exec:approval_policy=never")
4236        );
4237        assert_eq!(
4238            expected_permission_executor_mapping(&codex_exec, default, true)
4239                .unwrap()
4240                .as_deref(),
4241            Some("codex_exec:blocked_explicit_deny")
4242        );
4243
4244        let mut codex_app_server = codex_executor(Some("inherit"), Some(true));
4245        if let ExecutorSpec::Codex {
4246            mode,
4247            approval_policy,
4248            ..
4249        } = &mut codex_app_server
4250        {
4251            *mode = Some("app_server".to_string());
4252            *approval_policy = Some("on-request".to_string());
4253        }
4254        assert_eq!(
4255            expected_permission_executor_mapping(&codex_app_server, default, false)
4256                .unwrap()
4257                .as_deref(),
4258            Some("codex_app_server:approvalPolicy=on-request")
4259        );
4260        assert_eq!(
4261            expected_permission_executor_mapping(
4262                &codex_app_server,
4263                permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
4264                false,
4265            )
4266            .unwrap()
4267            .as_deref(),
4268            Some("codex_app_server:approvalPolicy=never")
4269        );
4270        assert_eq!(
4271            expected_permission_executor_mapping(&codex_app_server, default, true)
4272                .unwrap()
4273                .as_deref(),
4274            Some("codex_app_server:blocked_explicit_deny")
4275        );
4276    }
4277
4278    #[test]
4279    fn only_bamboo_managed_non_git_workspaces_are_marked_owned() {
4280        let project = tempfile::tempdir().unwrap();
4281        let managed = project.path().join(".bamboo/worktree/child-571");
4282        std::fs::create_dir_all(&managed).unwrap();
4283        assert!(!workspace_is_bamboo_owned(managed.to_str().unwrap()));
4284        let marker = project
4285            .path()
4286            .join(".bamboo/worktree/.bamboo-owned/child-571");
4287        std::fs::create_dir_all(marker.parent().unwrap()).unwrap();
4288        std::fs::write(&marker, "bamboo/child-571").unwrap();
4289        assert!(workspace_is_bamboo_owned(managed.to_str().unwrap()));
4290        let nested = managed.join("nested/path");
4291        std::fs::create_dir_all(&nested).unwrap();
4292        assert!(workspace_is_bamboo_owned(nested.to_str().unwrap()));
4293
4294        let arbitrary = tempfile::tempdir().unwrap();
4295        assert!(!workspace_is_bamboo_owned(
4296            arbitrary.path().to_str().unwrap()
4297        ));
4298    }
4299
4300    #[test]
4301    fn bamboo_codex_token_is_per_run_redacted_and_revoked_on_guard_drop() {
4302        let authority = Arc::new(RecordingCodexTokenAuthority::default());
4303        let authority_dyn: Arc<dyn CodexRunTokenAuthority> = authority.clone();
4304
4305        let (secrets, guard) = build_codex_run_secrets(
4306            &codex_executor(Some("bamboo"), None),
4307            Some(authority_dyn),
4308            "child-570",
4309        )
4310        .unwrap();
4311
4312        let token = secrets
4313            .codex_provider_token
4314            .as_ref()
4315            .expect("bamboo mode mints a token");
4316        assert_eq!(token.expose(), "bcx1_secret-child-570");
4317        assert!(!format!("{token:?}").contains("secret-child-570"));
4318        assert_eq!(
4319            authority.issued_for.lock().unwrap().as_slice(),
4320            ["child-570"]
4321        );
4322        assert!(authority.revoked.lock().unwrap().is_empty());
4323
4324        drop(guard);
4325        assert_eq!(
4326            authority.revoked.lock().unwrap().as_slice(),
4327            ["id-child-570"]
4328        );
4329    }
4330
4331    #[test]
4332    fn non_bamboo_codex_never_mints_and_bamboo_fails_closed_without_authority() {
4333        let authority = Arc::new(RecordingCodexTokenAuthority::default());
4334        let authority_dyn: Arc<dyn CodexRunTokenAuthority> = authority.clone();
4335        let (secrets, guard) = build_codex_run_secrets(
4336            &codex_executor(Some("custom"), None),
4337            Some(authority_dyn),
4338            "child-custom",
4339        )
4340        .unwrap();
4341        assert!(secrets.codex_provider_token.is_none());
4342        assert!(guard.is_none());
4343        assert!(authority.issued_for.lock().unwrap().is_empty());
4344
4345        let error = build_codex_run_secrets(
4346            &codex_executor(Some("bamboo"), None),
4347            None,
4348            "child-no-authority",
4349        )
4350        .err()
4351        .expect("bamboo mode without an authority must fail closed");
4352        assert!(error.to_string().contains("per-run token authority"));
4353    }
4354
4355    #[test]
4356    fn codex_provisioning_never_leaks_the_session_provider_credential() {
4357        let credentials = vec![ScopedCredential {
4358            provider: "openai".to_string(),
4359            api_key: "upstream-secret-must-not-cross".to_string(),
4360            base_url: None,
4361            provider_type: Some("openai".to_string()),
4362            credential_ref: Some("provider.openai.api_key".to_string()),
4363        }];
4364
4365        for (mode, label) in [
4366            (Some("inherit"), "inherit"),
4367            (Some("api_key"), "api_key"),
4368            (Some("bamboo"), "bamboo"),
4369            (None, "default-bamboo"),
4370        ] {
4371            let runner = ActorChildRunner::new(
4372                format!("codex-{label}-test"),
4373                PathBuf::from("/bin/false"),
4374                Vec::new(),
4375                std::env::temp_dir().join(format!("bamboo-codex-{label}-570")),
4376                codex_executor(mode, None),
4377                credentials.clone(),
4378                "openai".to_string(),
4379                1,
4380            );
4381            let mut session = Session::new(format!("child-{label}"), "model");
4382            session.add_message(bamboo_agent_core::Message::user("test"));
4383            let spec = runner.build_spec(
4384                &session,
4385                &crate::runtime::execution::SpawnJob {
4386                    parent_session_id: "parent".to_string(),
4387                    child_session_id: format!("child-{label}"),
4388                    model: "gpt-5.4".to_string(),
4389                    disabled_tools: None,
4390                },
4391            );
4392            assert!(
4393                spec.secrets.provider_credentials.is_empty(),
4394                "{label} Codex must not receive the session provider key"
4395            );
4396        }
4397    }
4398
4399    #[test]
4400    fn non_codex_provisioning_still_receives_only_its_selected_provider_credential() {
4401        let credentials = vec![
4402            ScopedCredential {
4403                provider: "openai".to_string(),
4404                api_key: "selected-openai-secret".to_string(),
4405                base_url: None,
4406                provider_type: Some("openai".to_string()),
4407                credential_ref: Some("provider.openai.api_key".to_string()),
4408            },
4409            ScopedCredential {
4410                provider: "other".to_string(),
4411                api_key: "unrelated-secret".to_string(),
4412                base_url: None,
4413                provider_type: Some("openai".to_string()),
4414                credential_ref: Some("provider.other.api_key".to_string()),
4415            },
4416        ];
4417        let runner = ActorChildRunner::new(
4418            "echo-test".to_string(),
4419            PathBuf::from("/bin/false"),
4420            Vec::new(),
4421            std::env::temp_dir().join("bamboo-echo-provider-570"),
4422            ExecutorSpec::Echo,
4423            credentials,
4424            "openai".to_string(),
4425            1,
4426        );
4427        let spec = runner.build_spec(
4428            &Session::new("child-echo", "model"),
4429            &crate::runtime::execution::SpawnJob {
4430                parent_session_id: "parent".to_string(),
4431                child_session_id: "child-echo".to_string(),
4432                model: "gpt-5.4".to_string(),
4433                disabled_tools: None,
4434            },
4435        );
4436
4437        assert_eq!(spec.secrets.provider_credentials.len(), 1);
4438        assert_eq!(
4439            spec.secrets.provider_credentials[0].api_key,
4440            "selected-openai-secret"
4441        );
4442    }
4443
4444    #[test]
4445    fn custom_codex_provisioning_scopes_only_the_referenced_credential() {
4446        let mut executor = codex_executor(Some("custom"), None);
4447        if let ExecutorSpec::Codex {
4448            base_url,
4449            provider_key_ref,
4450            ..
4451        } = &mut executor
4452        {
4453            *base_url = Some("https://provider.example/v1".to_string());
4454            *provider_key_ref = Some("provider.custom.api_key".to_string());
4455        }
4456        let credentials = vec![
4457            ScopedCredential {
4458                provider: "openai".to_string(),
4459                api_key: "session-provider-secret".to_string(),
4460                base_url: None,
4461                provider_type: Some("openai".to_string()),
4462                credential_ref: Some("provider.openai.api_key".to_string()),
4463            },
4464            ScopedCredential {
4465                provider: "custom".to_string(),
4466                api_key: "selected-secret".to_string(),
4467                base_url: None,
4468                provider_type: Some("openai".to_string()),
4469                credential_ref: Some("provider.custom.api_key".to_string()),
4470            },
4471            ScopedCredential {
4472                provider: "other".to_string(),
4473                api_key: "unrelated-secret".to_string(),
4474                base_url: None,
4475                provider_type: Some("openai".to_string()),
4476                credential_ref: Some("provider.other.api_key".to_string()),
4477            },
4478        ];
4479        let runner = ActorChildRunner::new(
4480            "codex-test".to_string(),
4481            PathBuf::from("/bin/false"),
4482            Vec::new(),
4483            std::env::temp_dir().join("bamboo-codex-570"),
4484            executor,
4485            credentials,
4486            "openai".to_string(),
4487            1,
4488        );
4489        let mut session = Session::new("child-custom", "model");
4490        session.add_message(bamboo_agent_core::Message::user("test"));
4491        let spec = runner.build_spec(
4492            &session,
4493            &crate::runtime::execution::SpawnJob {
4494                parent_session_id: "parent".to_string(),
4495                child_session_id: "child-custom".to_string(),
4496                model: "gpt-5.4".to_string(),
4497                disabled_tools: None,
4498            },
4499        );
4500
4501        assert_eq!(spec.secrets.provider_credentials.len(), 1);
4502        assert_eq!(
4503            spec.secrets.provider_credentials[0]
4504                .credential_ref
4505                .as_deref(),
4506            Some("provider.custom.api_key")
4507        );
4508        assert_eq!(
4509            spec.secrets.provider_credentials[0].api_key,
4510            "selected-secret"
4511        );
4512    }
4513
4514    fn spec_with(
4515        role: &str,
4516        provider: &str,
4517        model: &str,
4518        workspace: Option<&str>,
4519        disabled: Option<Vec<&str>>,
4520    ) -> ProvisionSpec {
4521        let mut spec = ProvisionSpec::new(
4522            ChildIdentity {
4523                child_id: "c".into(),
4524                parent_id: None,
4525                project_key: None,
4526                role: role.into(),
4527                depth: 0,
4528            },
4529            ExecutorSpec::Echo,
4530            "/tmp/fab".into(),
4531        );
4532        spec.workspace = workspace.map(|w| w.to_string());
4533        spec.model = Some(ModelRefSpec {
4534            provider: provider.into(),
4535            model: model.into(),
4536        });
4537        spec.disabled_tools = disabled.map(|d| d.into_iter().map(String::from).collect());
4538        spec
4539    }
4540
4541    #[test]
4542    fn fingerprint_matches_interchangeable_children() {
4543        // Same role/provider/model/workspace and equal tool sets (order-insensitive)
4544        // are interchangeable on one warm worker — and differ only in child_id.
4545        let a = spec_with(
4546            "explorer",
4547            "p",
4548            "m",
4549            Some("/ws"),
4550            Some(vec!["Bash", "Edit"]),
4551        );
4552        let mut b = spec_with(
4553            "explorer",
4554            "p",
4555            "m",
4556            Some("/ws"),
4557            Some(vec!["Edit", "Bash"]),
4558        );
4559        b.identity.child_id = "other".into();
4560        assert_eq!(
4561            ActorChildRunner::fingerprint(&a),
4562            ActorChildRunner::fingerprint(&b)
4563        );
4564    }
4565
4566    #[test]
4567    fn logical_identity_is_invariant_across_local_remote_scheduled_and_warm_reuse() {
4568        let mut session =
4569            Session::new_child("logical-child-681", "logical-parent-681", "model", "child");
4570        session.root_session_id = "logical-root-681".to_string();
4571        let job = SpawnJob {
4572            parent_session_id: "logical-parent-681".to_string(),
4573            child_session_id: "logical-child-681".to_string(),
4574            model: "model".to_string(),
4575            disabled_tools: None,
4576        };
4577        let expected = LogicalSessionIdentity {
4578            session_id: "logical-child-681".to_string(),
4579            parent_session_id: Some("logical-parent-681".to_string()),
4580            root_session_id: "logical-root-681".to_string(),
4581        };
4582
4583        let placements_and_transport_ids = [
4584            (Placement::Local, "local-mailbox-first"),
4585            (
4586                Placement::Remote {
4587                    endpoint: "wss://remote.example/actor".to_string(),
4588                },
4589                "remote-process-44",
4590            ),
4591            (
4592                Placement::Schedulable {
4593                    pool: "gpu-pool".to_string(),
4594                },
4595                "scheduled-mailbox-9",
4596            ),
4597            // Same logical child reactivated on a different pooled mailbox.
4598            (Placement::Local, "warm-mailbox-reused-77"),
4599        ];
4600        for (placement, transport_id) in placements_and_transport_ids {
4601            let mut provision = spec_with("worker", "provider", "model", None, None);
4602            provision.placement = placement;
4603            provision.identity.child_id = transport_id.to_string();
4604            assert_eq!(logical_identity_for_actor_run(&session, &job), expected);
4605            assert_ne!(
4606                provision.identity.child_id, expected.session_id,
4607                "test fixture must prove transport identity is independent"
4608            );
4609        }
4610    }
4611
4612    #[test]
4613    fn fingerprint_separates_distinct_runtimes() {
4614        let base = spec_with("explorer", "p", "m", Some("/ws"), None);
4615        let base_fp = ActorChildRunner::fingerprint(&base);
4616        // Each axis that is baked into the worker must split the pool bucket.
4617        assert_ne!(
4618            base_fp,
4619            ActorChildRunner::fingerprint(&spec_with("writer", "p", "m", Some("/ws"), None))
4620        );
4621        assert_ne!(
4622            base_fp,
4623            ActorChildRunner::fingerprint(&spec_with("explorer", "p2", "m", Some("/ws"), None))
4624        );
4625        assert_ne!(
4626            base_fp,
4627            ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m2", Some("/ws"), None))
4628        );
4629        assert_ne!(
4630            base_fp,
4631            ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m", Some("/ws2"), None))
4632        );
4633        assert_ne!(
4634            base_fp,
4635            ActorChildRunner::fingerprint(&spec_with(
4636                "explorer",
4637                "p",
4638                "m",
4639                Some("/ws"),
4640                Some(vec!["Bash"])
4641            ))
4642        );
4643    }
4644
4645    #[test]
4646    fn fingerprint_splits_on_baked_capabilities() {
4647        // Every capability baked once at provision time must split the pool
4648        // bucket, else a worker baked for one posture gets reused for another
4649        // (e.g. a depth-1 worker re-stamping spawn_depth onto a depth-4 child,
4650        // breaking the depth cap; or a bypass worker reused for a non-bypass one).
4651        let base_fp =
4652            ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m", Some("/ws"), None));
4653
4654        let mut depth = spec_with("explorer", "p", "m", Some("/ws"), None);
4655        depth.identity.depth = 2;
4656        assert_ne!(
4657            base_fp,
4658            ActorChildRunner::fingerprint(&depth),
4659            "depth must split"
4660        );
4661
4662        let mut nested = spec_with("explorer", "p", "m", Some("/ws"), None);
4663        nested.capabilities.nested_spawn = true;
4664        assert_ne!(
4665            base_fp,
4666            ActorChildRunner::fingerprint(&nested),
4667            "nested_spawn must split"
4668        );
4669
4670        let mut bypass = spec_with("explorer", "p", "m", Some("/ws"), None);
4671        bypass.capabilities.bypass = true;
4672        assert_ne!(
4673            base_fp,
4674            ActorChildRunner::fingerprint(&bypass),
4675            "bypass must split"
4676        );
4677
4678        let mut auto = spec_with("explorer", "p", "m", Some("/ws"), None);
4679        auto.capabilities.auto_approve_permissions = true;
4680        assert_ne!(
4681            base_fp,
4682            ActorChildRunner::fingerprint(&auto),
4683            "auto_approve_permissions must split"
4684        );
4685
4686        let mut global_auto = spec_with("explorer", "p", "m", Some("/ws"), None);
4687        global_auto.capabilities.permission_requested_mode = "default".to_string();
4688        global_auto.capabilities.permission_effective_mode = "auto".to_string();
4689        global_auto.capabilities.auto_approve_permissions = true;
4690        let mut explicit_auto = global_auto.clone();
4691        explicit_auto.capabilities.permission_requested_mode = "auto".to_string();
4692        assert_ne!(
4693            ActorChildRunner::fingerprint(&global_auto),
4694            ActorChildRunner::fingerprint(&explicit_auto),
4695            "permission_requested_mode must split global and explicit Auto"
4696        );
4697
4698        let mut plan_overlay = explicit_auto.clone();
4699        plan_overlay.capabilities.permission_effective_mode = "plan".to_string();
4700        assert_ne!(
4701            ActorChildRunner::fingerprint(&explicit_auto),
4702            ActorChildRunner::fingerprint(&plan_overlay),
4703            "permission_effective_mode must split Plan overlay from Auto"
4704        );
4705
4706        let mut enforce = spec_with("explorer", "p", "m", Some("/ws"), None);
4707        enforce.capabilities.enforce_permissions = true;
4708        assert_ne!(
4709            base_fp,
4710            ActorChildRunner::fingerprint(&enforce),
4711            "enforce_permissions must split"
4712        );
4713
4714        let mut cap = spec_with("explorer", "p", "m", Some("/ws"), None);
4715        cap.capabilities.max_spawn_depth = Some(8);
4716        assert_ne!(
4717            base_fp,
4718            ActorChildRunner::fingerprint(&cap),
4719            "max_spawn_depth must split"
4720        );
4721
4722        // #73 (P1): the worker bakes `no_human_review` from this flag once at
4723        // build(), so it MUST split the pool or a worker baked for one approval
4724        // posture is reused for the opposite one.
4725        let mut nha = spec_with("explorer", "p", "m", Some("/ws"), None);
4726        nha.capabilities.no_human_approver = true;
4727        assert_ne!(
4728            base_fp,
4729            ActorChildRunner::fingerprint(&nha),
4730            "no_human_approver must split"
4731        );
4732
4733        // The generic read-only no-shell checker is baked once at build(), so a
4734        // planner/Guardian worker must not be reused for an ordinary child.
4735        let mut read_only = spec_with("explorer", "p", "m", Some("/ws"), None);
4736        read_only.capabilities.read_only = true;
4737        assert_ne!(
4738            base_fp,
4739            ActorChildRunner::fingerprint(&read_only),
4740            "read_only must split"
4741        );
4742
4743        // Preserve the legacy Guardian bit as its own rolling-upgrade axis.
4744        let mut gro = spec_with("explorer", "p", "m", Some("/ws"), None);
4745        gro.capabilities.guardian_read_only = true;
4746        assert_ne!(
4747            base_fp,
4748            ActorChildRunner::fingerprint(&gro),
4749            "guardian_read_only must split"
4750        );
4751    }
4752
4753    #[test]
4754    fn fingerprint_splits_codex_exec_and_app_server_workers() {
4755        let mut exec = spec_with("explorer", "p", "m", Some("/ws"), None);
4756        exec.executor = codex_executor(Some("inherit"), None);
4757        let mut app_server = exec.clone();
4758        if let ExecutorSpec::Codex { mode, .. } = &mut app_server.executor {
4759            *mode = Some("app_server".to_string());
4760        }
4761        assert_ne!(
4762            ActorChildRunner::fingerprint(&exec),
4763            ActorChildRunner::fingerprint(&app_server)
4764        );
4765    }
4766
4767    struct StaticDecider(bool);
4768
4769    #[async_trait]
4770    impl ChildApprovalDecider for StaticDecider {
4771        async fn decide(&self, _child: &str, _req: &serde_json::Value) -> bool {
4772            self.0
4773        }
4774    }
4775
4776    struct RecordingReviewer {
4777        reviewed: mpsc::UnboundedSender<(String, String, serde_json::Value)>,
4778    }
4779
4780    #[async_trait]
4781    impl ChildApprovalReviewer for RecordingReviewer {
4782        async fn review(&self, parent: &str, child: &str, request: &serde_json::Value) -> bool {
4783            let _ = self
4784                .reviewed
4785                .send((parent.to_string(), child.to_string(), request.clone()));
4786            true
4787        }
4788    }
4789
4790    // ---- first-frame watchdog (dead-pooled-worker recovery) -----------------
4791
4792    /// A link that never yields a frame — models a worker that died (or never
4793    /// subscribed) so its Run sits queued with no server.
4794    struct SilentLink;
4795    #[async_trait]
4796    impl bamboo_subagent::ChildLink for SilentLink {
4797        async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
4798            Ok(())
4799        }
4800        async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
4801            std::future::pending().await
4802        }
4803    }
4804
4805    /// A link that immediately yields one terminal frame (a healthy fast worker).
4806    struct InstantTerminalLink {
4807        done: bool,
4808    }
4809
4810    struct ApprovalRoundTripLink {
4811        step: u8,
4812        approval_reply: Option<(String, bool)>,
4813    }
4814
4815    #[async_trait]
4816    impl bamboo_subagent::ChildLink for ApprovalRoundTripLink {
4817        async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
4818            if let ParentFrame::ApprovalReply { id, approved } = frame {
4819                self.approval_reply = Some((id, approved));
4820                self.step = 2;
4821            }
4822            Ok(())
4823        }
4824
4825        async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
4826            match self.step {
4827                0 => {
4828                    self.step = 1;
4829                    Ok(Some(ChildFrame::ApprovalRequest {
4830                        id: "approval-1".into(),
4831                        body: serde_json::json!({
4832                            "tool_name": "Bash",
4833                            "permission": "execute",
4834                            "resource": "rm -rf target",
4835                            "permission_request": {"reason_code": "hard_dangerous"}
4836                        }),
4837                    }))
4838                }
4839                1 => std::future::pending().await,
4840                2 => {
4841                    self.step = 3;
4842                    Ok(Some(ChildFrame::Terminal {
4843                        status: TerminalStatus::Completed,
4844                        result: Some("done".into()),
4845                        error: None,
4846                        transcript: vec![],
4847                    }))
4848                }
4849                _ => std::future::pending().await,
4850            }
4851        }
4852    }
4853
4854    #[tokio::test]
4855    async fn drive_routes_forced_ask_to_parent_reviewer_without_human_event() {
4856        let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(8);
4857        let (review_tx, mut review_rx) = mpsc::unbounded_channel();
4858        let reviewer: Arc<dyn ChildApprovalReviewer> = Arc::new(RecordingReviewer {
4859            reviewed: review_tx,
4860        });
4861        let cancel = CancellationToken::new();
4862        let (live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
4863        let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
4864        let mut logical_session = Session::new("child-reviewer", "model");
4865        let live_guard = crate::external_agents::live::register("child-reviewer", live_tx, 0, None);
4866        let mut link = ApprovalRoundTripLink {
4867            step: 0,
4868            approval_reply: None,
4869        };
4870
4871        let result = tokio::time::timeout(
4872            Duration::from_secs(1),
4873            drive(ActorDriveContext {
4874                client: &mut link,
4875                parent_session_id: "parent-reviewer",
4876                child_session_id: "child-reviewer",
4877                child_attempt: 0,
4878                approval_registry: None,
4879                approval_decider: None,
4880                approval_reviewer: Some(&reviewer),
4881                escalation_bridge: None,
4882                event_tx: &event_tx,
4883                cancel_token: &cancel,
4884                live_rx: &mut live_rx,
4885                delivery_rx: &mut delivery_rx,
4886                logical_session: &mut logical_session,
4887                expected_permission_posture: None,
4888                session_inbox_runtime: None,
4889                activation_run_id: None,
4890                execution_epoch: 0,
4891                expected_source_actor_id: "child-reviewer",
4892                initial_inflight_claims: VecDeque::new(),
4893                first_frame_timeout: None,
4894            }),
4895        )
4896        .await
4897        .expect("worker must receive the reviewer verdict before terminating");
4898
4899        assert_eq!(result.ok().flatten().as_deref(), Some("done"));
4900        assert_eq!(
4901            link.approval_reply,
4902            Some(("approval-1".to_string(), true)),
4903            "reviewer verdict must traverse the live route back to the worker"
4904        );
4905        let (parent, child, body) = tokio::time::timeout(Duration::from_secs(1), review_rx.recv())
4906            .await
4907            .expect("reviewer should be invoked off-loop")
4908            .expect("review channel should remain open");
4909        assert_eq!(parent, "parent-reviewer");
4910        assert_eq!(child, "child-reviewer");
4911        assert_eq!(
4912            body.pointer("/permission_request/reason_code")
4913                .and_then(serde_json::Value::as_str),
4914            Some("hard_dangerous")
4915        );
4916        assert!(
4917            event_rx.try_recv().is_err(),
4918            "must not emit a human-review event"
4919        );
4920        drop(live_guard);
4921    }
4922
4923    #[tokio::test]
4924    async fn drive_denies_forced_ask_without_parent_reviewer_or_manual_event() {
4925        let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(8);
4926        let cancel = CancellationToken::new();
4927        let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
4928        let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
4929        let mut logical_session = Session::new("child-no-reviewer", "model");
4930        let mut link = ApprovalRoundTripLink {
4931            step: 0,
4932            approval_reply: None,
4933        };
4934
4935        let result = tokio::time::timeout(
4936            Duration::from_secs(1),
4937            drive(ActorDriveContext {
4938                client: &mut link,
4939                parent_session_id: "parent-no-reviewer",
4940                child_session_id: "child-no-reviewer",
4941                child_attempt: 0,
4942                approval_registry: None,
4943                approval_decider: None,
4944                approval_reviewer: None,
4945                escalation_bridge: None,
4946                event_tx: &event_tx,
4947                cancel_token: &cancel,
4948                live_rx: &mut live_rx,
4949                delivery_rx: &mut delivery_rx,
4950                logical_session: &mut logical_session,
4951                expected_permission_posture: None,
4952                session_inbox_runtime: None,
4953                activation_run_id: None,
4954                execution_epoch: 0,
4955                expected_source_actor_id: "child-no-reviewer",
4956                initial_inflight_claims: VecDeque::new(),
4957                first_frame_timeout: None,
4958            }),
4959        )
4960        .await
4961        .expect("fail-closed reply must unblock the child immediately");
4962
4963        assert_eq!(result.ok().flatten().as_deref(), Some("done"));
4964        assert_eq!(link.approval_reply, Some(("approval-1".to_string(), false)));
4965        assert!(
4966            event_rx.try_recv().is_err(),
4967            "missing parent review must not surface a manual approval event"
4968        );
4969    }
4970    #[async_trait]
4971    impl bamboo_subagent::ChildLink for InstantTerminalLink {
4972        async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
4973            Ok(())
4974        }
4975        async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
4976            if self.done {
4977                std::future::pending().await
4978            } else {
4979                self.done = true;
4980                Ok(Some(ChildFrame::Terminal {
4981                    status: TerminalStatus::Completed,
4982                    result: Some("done".into()),
4983                    error: None,
4984                    transcript: vec![],
4985                }))
4986            }
4987        }
4988    }
4989
4990    #[tokio::test]
4991    async fn drive_trips_first_frame_watchdog_on_a_silent_worker() {
4992        let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
4993        let cancel = CancellationToken::new();
4994        let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
4995        let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
4996        let mut logical_session = Session::new("child-x", "model");
4997        let mut link = SilentLink;
4998        let r = drive(ActorDriveContext {
4999            client: &mut link,
5000            parent_session_id: "parent-x",
5001            child_session_id: "child-x",
5002            child_attempt: 0,
5003            approval_registry: None,
5004            approval_decider: None,
5005            approval_reviewer: None,
5006            escalation_bridge: None,
5007            event_tx: &event_tx,
5008            cancel_token: &cancel,
5009            live_rx: &mut live_rx,
5010            delivery_rx: &mut delivery_rx,
5011            logical_session: &mut logical_session,
5012            expected_permission_posture: None,
5013            session_inbox_runtime: None,
5014            activation_run_id: None,
5015            execution_epoch: 0,
5016            expected_source_actor_id: "child-x",
5017            initial_inflight_claims: VecDeque::new(),
5018            first_frame_timeout: Some(Duration::from_millis(100)),
5019        })
5020        .await;
5021        assert!(
5022            matches!(r, Err(AgentError::WorkerUnresponsive(_))),
5023            "a silent worker must trip the first-frame watchdog, got {r:?}"
5024        );
5025    }
5026
5027    #[tokio::test]
5028    async fn drive_does_not_trip_when_a_frame_arrives() {
5029        let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
5030        let cancel = CancellationToken::new();
5031        let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
5032        let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
5033        let mut logical_session = Session::new("child-y", "model");
5034        let mut link = InstantTerminalLink { done: false };
5035        // Even a tiny timeout must NOT trip: the terminal frame arrives first and
5036        // disarms the watchdog.
5037        let r = drive(ActorDriveContext {
5038            client: &mut link,
5039            parent_session_id: "parent-y",
5040            child_session_id: "child-y",
5041            child_attempt: 0,
5042            approval_registry: None,
5043            approval_decider: None,
5044            approval_reviewer: None,
5045            escalation_bridge: None,
5046            event_tx: &event_tx,
5047            cancel_token: &cancel,
5048            live_rx: &mut live_rx,
5049            delivery_rx: &mut delivery_rx,
5050            logical_session: &mut logical_session,
5051            expected_permission_posture: None,
5052            session_inbox_runtime: None,
5053            activation_run_id: None,
5054            execution_epoch: 0,
5055            expected_source_actor_id: "child-y",
5056            initial_inflight_claims: VecDeque::new(),
5057            first_frame_timeout: Some(Duration::from_millis(50)),
5058        })
5059        .await;
5060        assert_eq!(r.ok().flatten().as_deref(), Some("done"));
5061    }
5062
5063    #[tokio::test]
5064    async fn child_approval_fails_closed_without_decider() {
5065        // No decider wired ⇒ the host denies (safe default), unchanged behavior.
5066        let body = serde_json::json!({"tool_name":"Bash","permission":"run","resource":"rm -rf /"});
5067        assert!(!decide_child_approval(None, "child-1", &body).await);
5068    }
5069
5070    #[tokio::test]
5071    async fn child_approval_honors_wired_decider() {
5072        let body =
5073            serde_json::json!({"tool_name":"Write","permission":"write","resource":"/tmp/x"});
5074        let approve: Arc<dyn ChildApprovalDecider> = Arc::new(StaticDecider(true));
5075        let deny: Arc<dyn ChildApprovalDecider> = Arc::new(StaticDecider(false));
5076        assert!(decide_child_approval(Some(&approve), "child-1", &body).await);
5077        assert!(!decide_child_approval(Some(&deny), "child-1", &body).await);
5078    }
5079
5080    // ---- #193: remote placement routing -------------------------------------
5081
5082    use crate::runtime::execution::SpawnJob;
5083    use bamboo_agent_core::Session;
5084
5085    /// A runner with a BOGUS worker_bin (`/bin/false`): a local spawn here would
5086    /// FAIL, so a passing remote test proves the remote path never spawns.
5087    fn bogus_runner(placements: HashMap<String, ResolvedRemotePlacement>) -> ActorChildRunner {
5088        ActorChildRunner::new(
5089            "test-actor".into(),
5090            PathBuf::from("/bin/false"),
5091            vec![],
5092            std::env::temp_dir().join("bamboo-test-fab-193"),
5093            ExecutorSpec::Echo,
5094            vec![],
5095            "anthropic".into(),
5096            4,
5097        )
5098        .with_remote_placements(placements)
5099    }
5100
5101    /// A child session of the given role (the role rides `subagent_type`, the
5102    /// path build_spec + the remote lookup both read).
5103    fn session_of_role(role: &str, assignment: &str) -> Session {
5104        let mut s = Session::new("child-1", "test-model");
5105        s.metadata
5106            .insert("subagent_type".to_string(), role.to_string());
5107        s.add_message(bamboo_agent_core::Message::user(assignment));
5108        s
5109    }
5110
5111    fn job_for(child: &str) -> SpawnJob {
5112        SpawnJob {
5113            parent_session_id: "parent-1".into(),
5114            child_session_id: child.into(),
5115            model: String::new(),
5116            disabled_tools: None,
5117        }
5118    }
5119
5120    #[derive(Default)]
5121    struct RecordingChildSessionPort {
5122        saved: std::sync::Mutex<Option<Session>>,
5123    }
5124
5125    impl RecordingChildSessionPort {
5126        fn saved_child(&self) -> Session {
5127            self.saved
5128                .lock()
5129                .expect("saved-child fixture lock")
5130                .clone()
5131                .expect("create_child_action must save the child")
5132        }
5133    }
5134
5135    #[async_trait]
5136    impl crate::session_app::child_session::ChildSessionPort for RecordingChildSessionPort {
5137        async fn load_root_session(
5138            &self,
5139            _root_id: &str,
5140        ) -> Result<Session, crate::session_app::child_session::ChildSessionError> {
5141            unreachable!("create_child_action does not load the root")
5142        }
5143
5144        async fn load_child_for_parent(
5145            &self,
5146            _parent_id: &str,
5147            _child_id: &str,
5148        ) -> Result<Session, crate::session_app::child_session::ChildSessionError> {
5149            unreachable!("create_child_action does not reload the child")
5150        }
5151
5152        async fn save_child_session(
5153            &self,
5154            child: &mut Session,
5155        ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
5156            *self.saved.lock().expect("saved-child fixture lock") = Some(child.clone());
5157            Ok(())
5158        }
5159
5160        async fn save_child_session_authoritative_flags(
5161            &self,
5162            _child: &mut Session,
5163        ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
5164            unreachable!("new-child creation uses the ordinary save")
5165        }
5166
5167        async fn is_child_running(&self, _child_id: &str) -> bool {
5168            false
5169        }
5170
5171        async fn list_children(
5172            &self,
5173            _parent_id: &str,
5174        ) -> Vec<crate::session_app::child_session::ChildSessionEntry> {
5175            Vec::new()
5176        }
5177
5178        async fn enqueue_child_run(
5179            &self,
5180            _parent: &Session,
5181            _child: &Session,
5182        ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
5183            unreachable!("fixture creates the child with auto_run=false")
5184        }
5185
5186        async fn cancel_child_run_and_wait(
5187            &self,
5188            _child_id: &str,
5189        ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
5190            unreachable!("create_child_action does not cancel")
5191        }
5192
5193        async fn delete_child_session(
5194            &self,
5195            _parent_id: &str,
5196            _child_id: &str,
5197        ) -> Result<
5198            crate::session_app::child_session::DeleteChildResult,
5199            crate::session_app::child_session::ChildSessionError,
5200        > {
5201            unreachable!("create_child_action does not delete")
5202        }
5203
5204        async fn get_child_runner_info(
5205            &self,
5206            _child_id: &str,
5207        ) -> Option<crate::session_app::child_session::ChildRunnerInfo> {
5208            None
5209        }
5210
5211        async fn register_parent_wait_for_child(
5212            &self,
5213            _parent_session_id: &str,
5214            _child_session_id: &str,
5215            _tool_call_id: Option<&str>,
5216        ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
5217            unreachable!("create_child_action does not register a wait")
5218        }
5219
5220        async fn register_parent_wait_for_children(
5221            &self,
5222            _parent_session_id: &str,
5223            _child_session_ids: &[String],
5224            _policy: bamboo_domain::session::runtime_state::ChildWaitPolicy,
5225        ) -> Result<usize, crate::session_app::child_session::ChildSessionError> {
5226            unreachable!("create_child_action does not register a wait")
5227        }
5228
5229        async fn active_child_ids(&self, _parent_session_id: &str) -> Vec<String> {
5230            Vec::new()
5231        }
5232
5233        async fn find_resident_child(
5234            &self,
5235            _root_session_id: &str,
5236            _resident_name: &str,
5237        ) -> Option<String> {
5238            None
5239        }
5240
5241        async fn ensure_child_indexed(&self, _child_session_id: &str) {}
5242    }
5243
5244    #[test]
5245    fn build_spec_sets_remote_placement_for_matching_role() {
5246        let mut placements = HashMap::new();
5247        placements.insert(
5248            "explorer".to_string(),
5249            ResolvedRemotePlacement {
5250                endpoint: "wss://gpu-host:8443".into(),
5251                token: Some("T-secret".into()),
5252                ca_cert_file: None,
5253                host_label: None,
5254            },
5255        );
5256        let runner = bogus_runner(placements);
5257
5258        // Matching role -> Placement::Remote + the bearer on the secrets envelope.
5259        let s = session_of_role("explorer", "do the thing");
5260        let spec = runner.build_spec(&s, &job_for("child-1"));
5261        match &spec.placement {
5262            Placement::Remote { endpoint } => assert_eq!(endpoint, "wss://gpu-host:8443"),
5263            other => panic!("expected Remote, got {other:?}"),
5264        }
5265        assert_eq!(spec.secrets.worker_auth_token.as_deref(), Some("T-secret"));
5266    }
5267
5268    #[test]
5269    fn build_spec_leaves_local_for_unmatched_role() {
5270        let mut placements = HashMap::new();
5271        placements.insert(
5272            "explorer".to_string(),
5273            ResolvedRemotePlacement {
5274                endpoint: "wss://gpu-host:8443".into(),
5275                token: Some("T".into()),
5276                ca_cert_file: None,
5277                host_label: None,
5278            },
5279        );
5280        let runner = bogus_runner(placements);
5281
5282        // A DIFFERENT role keeps the default Local placement + no bearer.
5283        let s = session_of_role("writer", "do the thing");
5284        let spec = runner.build_spec(&s, &job_for("child-1"));
5285        assert_eq!(spec.placement, Placement::Local);
5286        assert!(spec.secrets.worker_auth_token.is_none());
5287    }
5288
5289    #[test]
5290    fn build_spec_local_when_no_placements() {
5291        let runner = bogus_runner(HashMap::new());
5292        let s = session_of_role("explorer", "do the thing");
5293        let spec = runner.build_spec(&s, &job_for("child-1"));
5294        assert_eq!(spec.placement, Placement::Local);
5295        assert!(spec.secrets.worker_auth_token.is_none());
5296    }
5297
5298    #[test]
5299    fn typed_child_state_is_the_read_only_authority_under_auto_and_bypass() {
5300        for mode in [
5301            bamboo_domain::SessionPermissionMode::Auto,
5302            bamboo_domain::SessionPermissionMode::Bypass,
5303        ] {
5304            let runner = bogus_runner(HashMap::new());
5305            let mut child = session_of_role("planner", "inspect and plan");
5306            let runtime = child
5307                .agent_runtime_state
5308                .get_or_insert_with(bamboo_domain::AgentRuntimeState::default);
5309            runtime.set_permission_mode(mode);
5310            runtime.read_only = true;
5311
5312            let spec = runner.build_spec(&child, &job_for("read-only-child"));
5313
5314            assert!(spec.capabilities.read_only);
5315            assert!(spec.capabilities.read_only_enforced());
5316            assert!(
5317                spec.capabilities.guardian_read_only,
5318                "the compatibility alias must protect planners on older workers"
5319            );
5320            assert_eq!(spec.capabilities.permission_requested_mode, mode.as_str());
5321            assert_eq!(spec.capabilities.permission_effective_mode, "plan");
5322            assert!(
5323                !spec.capabilities.bypass,
5324                "read-only must win over requested {mode:?}"
5325            );
5326            assert_eq!(
5327                spec.capabilities.auto_approve_permissions,
5328                mode == bamboo_domain::SessionPermissionMode::Auto,
5329                "Auto may suppress prompts but must not widen Plan authorization"
5330            );
5331            let disabled = spec
5332                .disabled_tools
5333                .as_ref()
5334                .expect("read-only state must provision a host-owned denylist");
5335            for tool_name in [
5336                "Edit",
5337                "Write",
5338                "Plan",
5339                "SubAgent",
5340                "session_control",
5341                "request_permissions",
5342            ] {
5343                assert!(
5344                    disabled.iter().any(|name| name == tool_name),
5345                    "read-only provisioning must disable {tool_name}"
5346                );
5347            }
5348        }
5349
5350        let runner = bogus_runner(HashMap::new());
5351        let cosmetic_planner = runner.build_spec(
5352            &session_of_role("planner", "label only"),
5353            &job_for("cosmetic"),
5354        );
5355        assert!(
5356            !cosmetic_planner.capabilities.read_only_enforced(),
5357            "a cosmetic role label must not change authority"
5358        );
5359        assert!(cosmetic_planner.disabled_tools.is_none());
5360
5361        let cosmetic_guardian = runner.build_spec(
5362            &session_of_role("guardian", "label only"),
5363            &job_for("cosmetic-guardian"),
5364        );
5365        assert!(
5366            !cosmetic_guardian.capabilities.read_only_enforced(),
5367            "a Guardian label without typed state or the legacy denylist is cosmetic"
5368        );
5369
5370        let legacy_guardian = session_of_role("guardian", "pre-upgrade reviewer");
5371        let mut legacy_job = job_for("legacy-guardian");
5372        legacy_job.disabled_tools = Some(vec![
5373            "Edit".to_string(),
5374            "Write".to_string(),
5375            "SubAgent".to_string(),
5376            "WebFetch".to_string(),
5377        ]);
5378        let legacy_spec = runner.build_spec(&legacy_guardian, &legacy_job);
5379        assert!(
5380            legacy_spec.capabilities.read_only_enforced(),
5381            "an in-flight pre-upgrade Guardian keeps its read-only authority"
5382        );
5383
5384        let mut differently_named = session_of_role("explorer", "typed authority");
5385        differently_named
5386            .agent_runtime_state
5387            .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
5388            .read_only = true;
5389        let typed = runner.build_spec(&differently_named, &job_for("typed"));
5390        assert!(
5391            typed.capabilities.read_only_enforced(),
5392            "typed state must enforce read-only independently of role"
5393        );
5394    }
5395
5396    #[test]
5397    fn typed_read_only_forces_native_external_executor_profiles() {
5398        let mut planner = session_of_role("planner", "inspect and plan");
5399        planner
5400            .agent_runtime_state
5401            .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
5402            .read_only = true;
5403
5404        let claude_runner = ActorChildRunner::new(
5405            "test-actor".into(),
5406            PathBuf::from("/bin/false"),
5407            vec![],
5408            std::env::temp_dir().join("bamboo-test-plan-claude"),
5409            ExecutorSpec::ClaudeCode {
5410                binary: None,
5411                model: None,
5412                permission_mode: Some("bypassPermissions".to_string()),
5413                inherit_user_config: Some(true),
5414                forward_env: None,
5415            },
5416            vec![],
5417            "anthropic".into(),
5418            4,
5419        );
5420        let claude_spec = claude_runner.build_spec(&planner, &job_for("planner-claude"));
5421        let disabled = claude_spec
5422            .disabled_tools
5423            .as_ref()
5424            .expect("typed read-only child must receive a host-owned denylist");
5425        for tool in ["Bash", "BashInput", "BashOutput", "KillShell"] {
5426            assert!(
5427                disabled.iter().any(|name| name == tool),
5428                "typed read-only child must not advertise {tool}"
5429            );
5430        }
5431        assert!(matches!(
5432            claude_spec.executor,
5433            ExecutorSpec::ClaudeCode {
5434                permission_mode: Some(ref mode),
5435                inherit_user_config: Some(false),
5436                ..
5437            } if mode == "plan"
5438        ));
5439        assert_eq!(claude_spec.capabilities.permission_effective_mode, "plan");
5440
5441        let mut codex_exec = codex_executor(Some("inherit"), Some(true));
5442        if let ExecutorSpec::Codex {
5443            sandbox,
5444            approval_policy,
5445            network_access,
5446            allow_danger_bypass,
5447            permission_profile,
5448            ..
5449        } = &mut codex_exec
5450        {
5451            *sandbox = Some("danger-full-access".to_string());
5452            *approval_policy = Some("on-failure".to_string());
5453            *network_access = Some(true);
5454            *allow_danger_bypass = Some(true);
5455            *permission_profile = Some("full-access".to_string());
5456        }
5457        let codex_runner = ActorChildRunner::new(
5458            "test-actor".into(),
5459            PathBuf::from("/bin/false"),
5460            vec![],
5461            std::env::temp_dir().join("bamboo-test-plan-codex"),
5462            codex_exec,
5463            vec![],
5464            "openai".into(),
5465            4,
5466        );
5467        let codex_spec = codex_runner.build_spec(&planner, &job_for("planner-codex"));
5468        assert_eq!(
5469            expected_permission_executor_mapping(
5470                &codex_spec.executor,
5471                codex_spec.capabilities.permission_resolution().unwrap(),
5472                false,
5473            )
5474            .unwrap()
5475            .as_deref(),
5476            Some("codex_exec:approval_policy=never")
5477        );
5478        assert!(matches!(
5479            codex_spec.executor,
5480            ExecutorSpec::Codex {
5481                sandbox: Some(ref sandbox),
5482                inherit_user_config: Some(false),
5483                approval_policy: Some(ref approval_policy),
5484                network_access: Some(false),
5485                allow_danger_bypass: Some(false),
5486                permission_profile: Some(ref profile),
5487                ..
5488            } if sandbox == "read-only"
5489                && approval_policy == "never"
5490                && profile == "read-only"
5491        ));
5492
5493        let mut app_server = codex_executor(Some("inherit"), Some(true));
5494        if let ExecutorSpec::Codex {
5495            mode,
5496            sandbox,
5497            approval_policy,
5498            network_access,
5499            allow_danger_bypass,
5500            permission_profile,
5501            ..
5502        } = &mut app_server
5503        {
5504            *mode = Some("app_server".to_string());
5505            *sandbox = Some("danger-full-access".to_string());
5506            *approval_policy = Some("on-request".to_string());
5507            *network_access = Some(true);
5508            *allow_danger_bypass = Some(true);
5509            *permission_profile = Some("full-access".to_string());
5510        }
5511        let app_server_runner = ActorChildRunner::new(
5512            "test-actor".into(),
5513            PathBuf::from("/bin/false"),
5514            vec![],
5515            std::env::temp_dir().join("bamboo-test-plan-codex-app-server"),
5516            app_server,
5517            vec![],
5518            "openai".into(),
5519            4,
5520        );
5521        let app_server_spec =
5522            app_server_runner.build_spec(&planner, &job_for("planner-codex-app-server"));
5523        assert_eq!(
5524            expected_permission_executor_mapping(
5525                &app_server_spec.executor,
5526                app_server_spec
5527                    .capabilities
5528                    .permission_resolution()
5529                    .unwrap(),
5530                false,
5531            )
5532            .unwrap()
5533            .as_deref(),
5534            Some("codex_app_server:approvalPolicy=never")
5535        );
5536        assert!(matches!(
5537            app_server_spec.executor,
5538            ExecutorSpec::Codex {
5539                mode: Some(ref mode),
5540                sandbox: Some(ref sandbox),
5541                inherit_user_config: Some(false),
5542                approval_policy: Some(ref approval_policy),
5543                network_access: Some(false),
5544                allow_danger_bypass: Some(false),
5545                permission_profile: Some(ref profile),
5546                ..
5547            } if mode == "app_server"
5548                && sandbox == "read-only"
5549                && approval_policy == "on-request"
5550                && profile == "read-only"
5551        ));
5552    }
5553
5554    #[test]
5555    fn typed_read_only_rejects_both_codex_executor_modes() {
5556        let mut planner = session_of_role("planner", "inspect and plan");
5557        planner
5558            .agent_runtime_state
5559            .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
5560            .read_only = true;
5561
5562        for mode in ["exec", "app_server"] {
5563            let mut executor = codex_executor(Some("inherit"), Some(true));
5564            if let ExecutorSpec::Codex {
5565                mode: configured_mode,
5566                ..
5567            } = &mut executor
5568            {
5569                *configured_mode = Some(mode.to_string());
5570            }
5571            let runner = ActorChildRunner::new(
5572                "test-actor".into(),
5573                PathBuf::from("/bin/false"),
5574                vec![],
5575                std::env::temp_dir().join(format!("bamboo-test-plan-codex-{mode}")),
5576                executor,
5577                vec![],
5578                "openai".into(),
5579                4,
5580            );
5581            let spec = runner.build_spec(&planner, &job_for(&format!("planner-codex-{mode}")));
5582            let error = ensure_read_only_activation_is_enforceable(&spec)
5583                .expect_err("Codex command execution must fail closed for read-only activations");
5584            let message = error.to_string();
5585            assert!(message.contains("Codex executor"), "{message}");
5586            assert!(message.contains("command execution"), "{message}");
5587        }
5588    }
5589
5590    #[tokio::test]
5591    async fn typed_read_only_codex_fails_before_worker_spawn_or_dispatch() {
5592        let mut planner = session_of_role("planner", "inspect and plan");
5593        planner
5594            .agent_runtime_state
5595            .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
5596            .read_only = true;
5597
5598        let runner = ActorChildRunner::new(
5599            "test-actor".into(),
5600            PathBuf::from("/bin/false"),
5601            vec![],
5602            std::env::temp_dir().join("bamboo-test-plan-codex-no-spawn"),
5603            codex_executor(Some("inherit"), Some(true)),
5604            vec![],
5605            "openai".into(),
5606            4,
5607        );
5608        let (event_tx, _event_rx) = mpsc::channel::<AgentEvent>(4);
5609        let error = runner
5610            .execute_external_child(
5611                &mut planner,
5612                &job_for("planner-codex-no-spawn"),
5613                event_tx,
5614                CancellationToken::new(),
5615            )
5616            .await
5617            .expect_err("typed read-only Codex must fail before invoking /bin/false");
5618        let message = error.to_string();
5619        assert!(message.contains("Codex executor"), "{message}");
5620        assert!(message.contains("command execution"), "{message}");
5621    }
5622
5623    #[test]
5624    fn typed_read_only_rejects_resident_actor_placements() {
5625        let runner = bogus_runner(HashMap::new());
5626        let mut planner = session_of_role("planner", "inspect and plan");
5627        planner
5628            .agent_runtime_state
5629            .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
5630            .read_only = true;
5631
5632        for placement in [
5633            Placement::Remote {
5634                endpoint: "wss://resident.example/actor".to_string(),
5635            },
5636            Placement::Schedulable {
5637                pool: "resident-pool".to_string(),
5638            },
5639        ] {
5640            let mut spec = runner.build_spec(&planner, &job_for("read-only-resident"));
5641            spec.placement = placement;
5642
5643            let error = ensure_read_only_activation_is_enforceable(&spec)
5644                .expect_err("resident placement must fail closed for read-only activation");
5645            let message = error.to_string();
5646            assert!(message.contains("requires local placement"), "{message}");
5647            assert!(message.contains("read-only tool boundary"), "{message}");
5648        }
5649
5650        let local = runner.build_spec(&planner, &job_for("read-only-local"));
5651        ensure_read_only_activation_is_enforceable(&local)
5652            .expect("a locally provisioned read-only worker is enforceable");
5653    }
5654
5655    #[tokio::test]
5656    async fn build_spec_preserves_exact_inherited_permission_mode_for_child_worker() {
5657        // Exercise the real creation path instead of pre-seeding the child by
5658        // hand: both legacy Bypass and zero-prompt Auto must survive child
5659        // creation and the actor provisioning boundary without collapsing.
5660        for (label, mode) in [
5661            ("bypass", bamboo_domain::SessionPermissionMode::Bypass),
5662            ("auto", bamboo_domain::SessionPermissionMode::Auto),
5663        ] {
5664            let runner = bogus_runner(HashMap::new());
5665            let mut parent = Session::new(format!("parent-{label}"), "test-model");
5666            parent
5667                .agent_runtime_state
5668                .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
5669                .set_permission_mode(mode);
5670            let workspace = tempfile::tempdir().expect("workspace fixture");
5671            let port = RecordingChildSessionPort::default();
5672            let child_id = format!("child-{label}-{}", uuid::Uuid::new_v4());
5673            crate::session_app::child_session::create_child_action(
5674                &port,
5675                crate::session_app::child_session::CreateChildInput {
5676                    parent_session: parent,
5677                    child_id: child_id.clone(),
5678                    title: format!("{label} child"),
5679                    responsibility: "Run ordinary commands".to_string(),
5680                    assignment_prompt: "run an ordinary command".to_string(),
5681                    subagent_type: "explorer".to_string(),
5682                    workspace: workspace.path().to_string_lossy().into_owned(),
5683                    workspace_source: crate::project_context::WorkspaceSource::Explicit,
5684                    model_override: None,
5685                    model_ref_override: None,
5686                    runtime_metadata: HashMap::new(),
5687                    read_only: false,
5688                    auto_run: false,
5689                    reasoning_effort: None,
5690                    lifecycle: None,
5691                    resident_name: None,
5692                    resident_context: None,
5693                    disabled_tools: None,
5694                    context_fork: None,
5695                },
5696            )
5697            .await
5698            .expect("create inherited-permission child");
5699            let child = port.saved_child();
5700
5701            assert_eq!(
5702                child
5703                    .agent_runtime_state
5704                    .as_ref()
5705                    .map(bamboo_domain::AgentRuntimeState::effective_permission_mode),
5706                Some(mode),
5707                "create_child_action must inherit {label} from the parent"
5708            );
5709
5710            let spec = runner.build_spec(&child, &job_for(&child_id));
5711
5712            assert_eq!(
5713                spec.capabilities.bypass,
5714                mode == bamboo_domain::SessionPermissionMode::Bypass
5715            );
5716            assert_eq!(
5717                spec.capabilities.auto_approve_permissions,
5718                mode == bamboo_domain::SessionPermissionMode::Auto
5719            );
5720            assert!(
5721                spec.capabilities.enforce_permissions,
5722                "policy evaluation must remain active under {label}"
5723            );
5724        }
5725    }
5726
5727    #[tokio::test]
5728    async fn child_resident_and_guardian_inherit_project_through_actor_run_spec() {
5729        let project_id = bamboo_domain::ProjectId::parse("project-inherited").expect("Project id");
5730        let workspace = tempfile::tempdir().expect("workspace fixture");
5731
5732        for (role, lifecycle, resident_name) in [
5733            ("explorer", None, None),
5734            ("resident", Some("resident"), Some("stable-reviewer")),
5735            ("guardian", None, None),
5736        ] {
5737            let mut parent = Session::new(format!("parent-{role}"), "test-model");
5738            parent.set_project_id_meta(project_id.to_string());
5739            let port = RecordingChildSessionPort::default();
5740            let child_id = format!("child-{role}-{}", uuid::Uuid::new_v4());
5741            crate::session_app::child_session::create_child_action(
5742                &port,
5743                crate::session_app::child_session::CreateChildInput {
5744                    parent_session: parent,
5745                    child_id: child_id.clone(),
5746                    title: format!("{role} child"),
5747                    responsibility: "Review the assigned work".to_string(),
5748                    assignment_prompt: "inspect the change".to_string(),
5749                    subagent_type: role.to_string(),
5750                    workspace: workspace.path().to_string_lossy().into_owned(),
5751                    workspace_source: crate::project_context::WorkspaceSource::Explicit,
5752                    model_override: None,
5753                    model_ref_override: None,
5754                    runtime_metadata: HashMap::new(),
5755                    read_only: false,
5756                    auto_run: false,
5757                    reasoning_effort: None,
5758                    lifecycle: lifecycle.map(str::to_string),
5759                    resident_name: resident_name.map(str::to_string),
5760                    resident_context: None,
5761                    disabled_tools: None,
5762                    context_fork: None,
5763                },
5764            )
5765            .await
5766            .expect("create Project-inheriting child");
5767            let child = port.saved_child();
5768            assert_eq!(
5769                crate::project_context::ProjectContextResolver::project_id_from_session(&child),
5770                Some(project_id.clone()),
5771                "{role} child must inherit its parent's Project"
5772            );
5773
5774            assert_eq!(
5775                project_id_for_actor_run(&child).expect("valid actor Project identity"),
5776                Some(project_id.clone()),
5777                "{role} actor RunSpec must preserve inherited Project identity"
5778            );
5779        }
5780    }
5781
5782    #[test]
5783    fn placement_metadata_stamps_remote_and_schedulable_not_local() {
5784        // Local children carry no stamp — the DTO defaults them to the backend host.
5785        assert_eq!(placement_metadata(&Placement::Local, None), None);
5786
5787        // Remote, no node label → host derived from the endpoint.
5788        let r = placement_metadata(
5789            &Placement::Remote {
5790                endpoint: "wss://10.0.0.5:8443/stream".into(),
5791            },
5792            None,
5793        )
5794        .unwrap();
5795        assert!(r.contains(r#""kind":"remote""#), "{r}");
5796        assert!(r.contains(r#""host":"10.0.0.5""#), "{r}");
5797
5798        // A cluster node's label (its metadata) OVERRIDES the raw endpoint host.
5799        let labeled = placement_metadata(
5800            &Placement::Remote {
5801                endpoint: "ws://169.254.230.101:8899".into(),
5802            },
5803            Some("mini"),
5804        )
5805        .unwrap();
5806        assert!(labeled.contains(r#""host":"mini""#), "{labeled}");
5807
5808        // Schedulable → {kind:"remote", host:<node label, else pool>}.
5809        let s = placement_metadata(
5810            &Placement::Schedulable {
5811                pool: "explorers".into(),
5812            },
5813            Some("mini"),
5814        )
5815        .unwrap();
5816        assert!(s.contains(r#""kind":"remote""#), "{s}");
5817        assert!(s.contains(r#""host":"mini""#), "{s}");
5818
5819        // The stamp round-trips through the storage placement type.
5820        let p: bamboo_storage::SessionPlacement = serde_json::from_str(&labeled).unwrap();
5821        assert_eq!(p.kind, "remote");
5822        assert_eq!(p.host, "mini");
5823    }
5824
5825    /// End-to-end remote run through `execute_external_child`: a resident worker
5826    /// (Bearer-gated `WsServer` + `EchoExecutor`) serves the role; the runner is
5827    /// built with a `remote_placements` entry pointing at it AND a BOGUS
5828    /// worker_bin (`/bin/false`). A passing test proves the remote path CONNECTS
5829    /// to the resident worker and NEVER spawns (a spawn would fail on /bin/false),
5830    /// and that a terminal/echo result flows back.
5831    #[tokio::test]
5832    async fn execute_external_child_routes_role_to_remote_worker_without_spawning() {
5833        // 1. Stand up the resident worker on loopback with a required bearer.
5834        let token = "remote-test-token";
5835        let server = bamboo_subagent::transport::WsServer::bind_with_token(
5836            (std::net::Ipv4Addr::LOCALHOST, 0).into(),
5837            Some(token.to_string()),
5838        )
5839        .await
5840        .expect("bind resident worker");
5841        let endpoint = server.ws_endpoint(); // ws://127.0.0.1:<port>
5842        let srv = tokio::spawn(async move {
5843            // serve() loops connection-after-connection; the test exits, dropping it.
5844            let _ = server
5845                .serve(Arc::new(bamboo_subagent::executor::EchoExecutor))
5846                .await;
5847        });
5848
5849        // 2. Build the runner: role "explorer" pinned remote, bogus worker_bin.
5850        let mut placements = HashMap::new();
5851        placements.insert(
5852            "explorer".to_string(),
5853            ResolvedRemotePlacement {
5854                endpoint: endpoint.clone(),
5855                token: Some(token.to_string()),
5856                ca_cert_file: None,
5857                host_label: Some("mini-e2e".into()), // node label, surfaced on the badge
5858            },
5859        );
5860        let runner = bogus_runner(placements);
5861
5862        // 3. Drive a real run for that role.
5863        let mut session = session_of_role("explorer", "hello remote");
5864        let job = job_for("child-1");
5865        let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(64);
5866        let cancel = CancellationToken::new();
5867
5868        let result = tokio::time::timeout(
5869            Duration::from_secs(10),
5870            runner.execute_external_child(&mut session, &job, event_tx, cancel),
5871        )
5872        .await
5873        .expect("run did not hang")
5874        .expect("remote run succeeded (connected to resident worker, did not spawn)");
5875
5876        let _ = result;
5877        // The EchoExecutor's reply is written back onto the child session as an
5878        // assistant message — proof a terminal result flowed back over the link.
5879        let last = session
5880            .messages
5881            .iter()
5882            .rev()
5883            .find(|m| matches!(m.role, Role::Assistant))
5884            .expect("an assistant reply was written back");
5885        assert!(
5886            last.content.contains("echo:"),
5887            "expected echo reply, got {:?}",
5888            last.content
5889        );
5890
5891        // A remote run must stamp WHICH machine it ran on onto the child session
5892        // (mirrored to the UI badge) using the placement's node label.
5893        let placement = session
5894            .metadata
5895            .get("placement")
5896            .expect("remote child session stamped with a placement");
5897        assert!(placement.contains(r#""kind":"remote""#), "{placement}");
5898        assert!(placement.contains(r#""host":"mini-e2e""#), "{placement}");
5899
5900        // Drain a couple of streamed events to confirm the event pipe carried the
5901        // worker's tokens too (best-effort; the reply assertion above is primary).
5902        let mut saw_event = false;
5903        while let Ok(Some(_ev)) =
5904            tokio::time::timeout(Duration::from_millis(50), event_rx.recv()).await
5905        {
5906            saw_event = true;
5907        }
5908        let _ = saw_event;
5909
5910        srv.abort();
5911    }
5912
5913    // ---- #181 (P2b): schedulable placement routing --------------------------
5914
5915    /// A bogus-worker_bin runner carrying SCHEDULABLE placements (and optionally
5916    /// remote ones, to test precedence). A local spawn here would fail on
5917    /// `/bin/false`, so a passing schedulable test proves no subprocess spawned.
5918    fn bogus_sched_runner(
5919        remote: HashMap<String, ResolvedRemotePlacement>,
5920        sched: HashMap<String, ResolvedSchedulablePlacement>,
5921    ) -> ActorChildRunner {
5922        ActorChildRunner::new(
5923            "test-actor".into(),
5924            PathBuf::from("/bin/false"),
5925            vec![],
5926            std::env::temp_dir().join("bamboo-test-fab-181"),
5927            ExecutorSpec::Echo,
5928            vec![],
5929            "anthropic".into(),
5930            4,
5931        )
5932        .with_remote_placements(remote)
5933        .with_schedulable_placements(sched)
5934    }
5935
5936    fn sched_placement(
5937        pool: &str,
5938        _registry_url: impl Into<String>,
5939    ) -> ResolvedSchedulablePlacement {
5940        ResolvedSchedulablePlacement {
5941            pool: pool.into(),
5942            host_label: None,
5943        }
5944    }
5945
5946    #[test]
5947    fn build_spec_sets_schedulable_placement_for_matching_role() {
5948        let mut sched = HashMap::new();
5949        sched.insert(
5950            "explorer".to_string(),
5951            sched_placement("gpu-pool", "unused"),
5952        );
5953        let runner = bogus_sched_runner(HashMap::new(), sched);
5954
5955        let s = session_of_role("explorer", "do the thing");
5956        let spec = runner.build_spec(&s, &job_for("child-1"));
5957        match &spec.placement {
5958            Placement::Schedulable { pool } => assert_eq!(pool, "gpu-pool"),
5959            other => panic!("expected Schedulable, got {other:?}"),
5960        }
5961        // No per-placement bearer now — the bus connection carries the bus token.
5962        assert!(spec.secrets.worker_auth_token.is_none());
5963    }
5964
5965    #[test]
5966    fn build_spec_remote_wins_when_role_in_both_maps() {
5967        // A role present in BOTH remote_placements and schedulable_placements must
5968        // resolve to the FIXED remote placement (documented precedence).
5969        let mut remote = HashMap::new();
5970        remote.insert(
5971            "explorer".to_string(),
5972            ResolvedRemotePlacement {
5973                endpoint: "wss://fixed-host:8443".into(),
5974                token: Some("T-remote".into()),
5975                ca_cert_file: None,
5976                host_label: None,
5977            },
5978        );
5979        let mut sched = HashMap::new();
5980        sched.insert(
5981            "explorer".to_string(),
5982            sched_placement("gpu-pool", "https://control-plane:9562"),
5983        );
5984        let runner = bogus_sched_runner(remote, sched);
5985
5986        let s = session_of_role("explorer", "do the thing");
5987        let spec = runner.build_spec(&s, &job_for("child-1"));
5988        match &spec.placement {
5989            Placement::Remote { endpoint } => assert_eq!(endpoint, "wss://fixed-host:8443"),
5990            other => panic!("expected Remote (precedence), got {other:?}"),
5991        }
5992        assert_eq!(spec.secrets.worker_auth_token.as_deref(), Some("T-remote"));
5993    }
5994
5995    #[test]
5996    fn build_spec_local_for_unmatched_schedulable_role() {
5997        let mut sched = HashMap::new();
5998        sched.insert(
5999            "explorer".to_string(),
6000            sched_placement("gpu-pool", "https://control-plane:9562"),
6001        );
6002        let runner = bogus_sched_runner(HashMap::new(), sched);
6003        let s = session_of_role("writer", "do the thing");
6004        let spec = runner.build_spec(&s, &job_for("child-1"));
6005        assert_eq!(spec.placement, Placement::Local);
6006        assert!(spec.secrets.worker_auth_token.is_none());
6007    }
6008
6009    /// The full role → resolved-placement → badge-host chain: a child routed to a
6010    /// remote/schedulable placement carrying a cluster node's `host_label` stamps
6011    /// that label; without a label it falls back to the endpoint host / pool; a
6012    /// Local child gets no stamp (the DTO defaults it to the backend host).
6013    #[test]
6014    fn placement_stamp_uses_node_label_for_remote_and_schedulable() {
6015        // Remote WITH a node label → {remote, <label>}, overriding the raw IP.
6016        let mut remote = HashMap::new();
6017        remote.insert(
6018            "explorer".to_string(),
6019            ResolvedRemotePlacement {
6020                endpoint: "ws://169.254.230.101:8899".into(),
6021                token: None,
6022                ca_cert_file: None,
6023                host_label: Some("mini".into()),
6024            },
6025        );
6026        let runner = bogus_runner(remote);
6027        let spec = runner.build_spec(&session_of_role("explorer", "go"), &job_for("c1"));
6028        let stamp = runner
6029            .placement_stamp_for(&spec)
6030            .expect("remote child is stamped");
6031        assert!(stamp.contains(r#""kind":"remote""#), "{stamp}");
6032        assert!(stamp.contains(r#""host":"mini""#), "{stamp}");
6033
6034        // Remote WITHOUT a node label → falls back to the endpoint host.
6035        let mut remote_nolabel = HashMap::new();
6036        remote_nolabel.insert(
6037            "explorer".to_string(),
6038            ResolvedRemotePlacement {
6039                endpoint: "ws://169.254.230.101:8899".into(),
6040                token: None,
6041                ca_cert_file: None,
6042                host_label: None,
6043            },
6044        );
6045        let r2 = bogus_runner(remote_nolabel);
6046        let spec2 = r2.build_spec(&session_of_role("explorer", "go"), &job_for("c1"));
6047        assert!(r2
6048            .placement_stamp_for(&spec2)
6049            .unwrap()
6050            .contains(r#""host":"169.254.230.101""#));
6051
6052        // Schedulable WITH a node label → {remote, <label>} (a node, not a pool name).
6053        let mut sched = HashMap::new();
6054        sched.insert(
6055            "mac-mini-monitor".to_string(),
6056            ResolvedSchedulablePlacement {
6057                pool: "mac-mini-monitor".into(),
6058                host_label: Some("mini".into()),
6059            },
6060        );
6061        let sr = bogus_sched_runner(HashMap::new(), sched);
6062        let spec3 = sr.build_spec(&session_of_role("mac-mini-monitor", "go"), &job_for("c1"));
6063        let stamp3 = sr
6064            .placement_stamp_for(&spec3)
6065            .expect("scheduled child is stamped");
6066        assert!(stamp3.contains(r#""kind":"remote""#), "{stamp3}");
6067        assert!(stamp3.contains(r#""host":"mini""#), "{stamp3}");
6068
6069        // A Local (unmatched) child gets NO stamp.
6070        let local = bogus_runner(HashMap::new());
6071        let spec4 = local.build_spec(&session_of_role("writer", "go"), &job_for("c1"));
6072        assert_eq!(local.placement_stamp_for(&spec4), None);
6073    }
6074
6075    // ---- #181: schedulable selection over the BUS (Phase 3 cutover) ----------
6076
6077    async fn start_bus() -> (String, tempfile::TempDir) {
6078        let dir = tempfile::tempdir().unwrap();
6079        let core = std::sync::Arc::new(bamboo_broker::BrokerCore::new(dir.path()));
6080        let server = std::sync::Arc::new(bamboo_broker::BrokerServer::new(core, "t"));
6081        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6082        let addr = listener.local_addr().unwrap();
6083        tokio::spawn(async move {
6084            let _ = server.serve(listener).await;
6085        });
6086        (format!("ws://{addr}"), dir)
6087    }
6088
6089    async fn join_pool(endpoint: &str, id: &str, pool: &str) -> bamboo_broker::BrokerClient {
6090        let mut c = bamboo_broker::BrokerClient::connect(
6091            endpoint,
6092            bamboo_subagent::AgentRef {
6093                session_id: id.into(),
6094                role: Some(pool.into()),
6095            },
6096            "t",
6097        )
6098        .await
6099        .unwrap();
6100        c.subscribe().await.unwrap();
6101        c
6102    }
6103
6104    fn sched_runner_on_bus(endpoint: &str, child_role: &str, pool: &str) -> ActorChildRunner {
6105        let mut sched = HashMap::new();
6106        sched.insert(child_role.to_string(), sched_placement(pool, "unused"));
6107        bogus_sched_runner(HashMap::new(), sched).with_bus(Some(bamboo_subagent::BusEndpoint {
6108            endpoint: endpoint.into(),
6109            token: "t".into(),
6110        }))
6111    }
6112
6113    #[tokio::test]
6114    async fn resolve_schedulable_picks_a_live_bus_worker() {
6115        let (endpoint, _dir) = start_bus().await;
6116        let _w = join_pool(&endpoint, "w-gpu", "gpu-pool").await;
6117        let runner = sched_runner_on_bus(&endpoint, "explorer", "gpu-pool");
6118
6119        let mailbox = runner
6120            .resolve_schedulable_worker("explorer")
6121            .await
6122            .expect("a live pool worker is found on the bus");
6123        assert_eq!(mailbox, "w-gpu");
6124    }
6125
6126    #[tokio::test]
6127    async fn resolve_schedulable_round_robins_over_pool_workers() {
6128        let (endpoint, _dir) = start_bus().await;
6129        let _a = join_pool(&endpoint, "w-a", "gpu-pool").await;
6130        let _b = join_pool(&endpoint, "w-b", "gpu-pool").await;
6131        let runner = sched_runner_on_bus(&endpoint, "explorer", "gpu-pool");
6132
6133        // Successive resolves spread across both connected workers.
6134        let mut picked = std::collections::HashSet::new();
6135        for _ in 0..6 {
6136            picked.insert(runner.resolve_schedulable_worker("explorer").await.unwrap());
6137        }
6138        assert_eq!(
6139            picked,
6140            ["w-a".to_string(), "w-b".to_string()].into_iter().collect(),
6141            "round-robin must cover every connected pool worker"
6142        );
6143    }
6144
6145    #[tokio::test]
6146    async fn resolve_schedulable_errors_on_empty_pool() {
6147        let (endpoint, _dir) = start_bus().await;
6148        // No worker subscribes to "gpu-pool".
6149        let runner = sched_runner_on_bus(&endpoint, "explorer", "gpu-pool");
6150
6151        let err = runner
6152            .resolve_schedulable_worker("explorer")
6153            .await
6154            .expect_err("an empty pool is terminal — no local fallback")
6155            .to_string();
6156        assert!(err.contains("no live worker in pool"), "got: {err}");
6157        assert!(err.contains("NOT spawning"), "got: {err}");
6158    }
6159
6160    /// FULL schedulable run over the bus: a worker SERVING `EchoExecutor` joins the
6161    /// pool by role; `execute_external_child` with a Schedulable placement resolves
6162    /// it from the bus (no local subprocess — the worker_bin is `/bin/false`),
6163    /// drives the run, gets the echo back, AND stamps the child session with the
6164    /// pool's cluster-node label — `{kind:remote, host:"mini"}`. The end-to-end
6165    /// analogue of the live `mac-mini-monitor`→mini run.
6166    #[tokio::test]
6167    async fn execute_external_child_runs_schedulable_over_bus_and_stamps_node_label() {
6168        let (endpoint, _dir) = start_bus().await;
6169
6170        // A bus worker SERVING runs (not just presence), joined to the pool by role.
6171        let ep = endpoint.clone();
6172        let worker = tokio::spawn(async move {
6173            let _ = bamboo_broker::serve_executor(
6174                &ep,
6175                bamboo_subagent::AgentRef {
6176                    session_id: "mmm-worker".into(),
6177                    role: Some("mac-mini-monitor".into()),
6178                },
6179                "t",
6180                std::sync::Arc::new(bamboo_subagent::executor::EchoExecutor),
6181            )
6182            .await;
6183        });
6184
6185        // Wait until the worker is visible on the bus so the pool is non-empty
6186        // when execute_external_child resolves it (serve_executor connects async).
6187        let mut probe = bamboo_broker::BrokerClient::connect(
6188            &endpoint,
6189            bamboo_subagent::AgentRef {
6190                session_id: "probe".into(),
6191                role: None,
6192            },
6193            "t",
6194        )
6195        .await
6196        .unwrap();
6197        let mut ready = false;
6198        for _ in 0..100 {
6199            if probe
6200                .list_connected("mac-mini-monitor")
6201                .await
6202                .unwrap()
6203                .iter()
6204                .any(|id| id == "mmm-worker")
6205            {
6206                ready = true;
6207                break;
6208            }
6209            tokio::time::sleep(Duration::from_millis(30)).await;
6210        }
6211        assert!(ready, "worker never joined the pool");
6212
6213        // Runner: child role → schedulable pool "mac-mini-monitor" carrying the
6214        // cluster node's label "mini"; bogus worker_bin so any local spawn fails.
6215        let mut sched = HashMap::new();
6216        sched.insert(
6217            "mac-mini-monitor".to_string(),
6218            ResolvedSchedulablePlacement {
6219                pool: "mac-mini-monitor".into(),
6220                host_label: Some("mini".into()),
6221            },
6222        );
6223        let runner = bogus_sched_runner(HashMap::new(), sched).with_bus(Some(
6224            bamboo_subagent::BusEndpoint {
6225                endpoint: endpoint.clone(),
6226                token: "t".into(),
6227            },
6228        ));
6229
6230        let mut session = session_of_role("mac-mini-monitor", "hello scheduled");
6231        let job = job_for("child-1");
6232        let (event_tx, _rx) = mpsc::channel::<AgentEvent>(64);
6233        let cancel = CancellationToken::new();
6234
6235        tokio::time::timeout(
6236            Duration::from_secs(10),
6237            runner.execute_external_child(&mut session, &job, event_tx, cancel),
6238        )
6239        .await
6240        .expect("run did not hang")
6241        .expect("schedulable run succeeded over the bus (no local spawn)");
6242
6243        // Echo reply flowed back — proves it routed to the bus worker, not local.
6244        let last = session
6245            .messages
6246            .iter()
6247            .rev()
6248            .find(|m| matches!(m.role, Role::Assistant))
6249            .expect("an assistant reply was written back");
6250        assert!(last.content.contains("echo:"), "got {:?}", last.content);
6251
6252        // ...and the child is stamped with the pool's cluster-node label.
6253        let placement = session
6254            .metadata
6255            .get("placement")
6256            .expect("scheduled child session stamped with a placement");
6257        assert!(placement.contains(r#""kind":"remote""#), "{placement}");
6258        assert!(placement.contains(r#""host":"mini""#), "{placement}");
6259
6260        worker.abort();
6261    }
6262}