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