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