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;
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 tokio::sync::mpsc;
22use tokio_util::sync::CancellationToken;
23
24use bamboo_subagent::fleet::{spawn_worker_on_bus, SpawnedChild};
25use bamboo_subagent::proto::{
26    AgentRecord, ChildFrame, ParentFrame, PermissionPolicyContext, RunSpec, TerminalStatus,
27};
28use bamboo_subagent::provision::{
29    ChildIdentity, ExecutorSpec, ModelRefSpec, Placement, ProvisionSpec, ScopedCredential,
30};
31use bamboo_subagent::transport::{client_config_trusting_cert, ChildClient};
32
33use crate::runtime::execution::{ExternalChildRunner, SpawnJob};
34
35/// Default cap on simultaneously running actor processes.
36pub const DEFAULT_MAX_CONCURRENT_ACTORS: usize = 8;
37
38/// Max nesting depth for direct nested execution (Phase 6). A worker whose
39/// session `spawn_depth` is below this gets its own spawn stack + the real
40/// SubAgent tool; at/over it, neither (and the tool itself refuses). Mirrors
41/// `bamboo_server_tools::DEFAULT_MAX_SPAWN_DEPTH` (kept in sync; engine can't
42/// depend on server-tools). Root orchestrator = 0 ⇒ 4 levels of sub-agents.
43pub const MAX_SPAWN_DEPTH: u32 = 4;
44
45/// Default cap on idle pooled (warm, reusable) workers kept per fingerprint.
46const DEFAULT_MAX_IDLE_PER_KEY: usize = 4;
47
48/// How long a pooled worker waits for its next assignment before reclaiming
49/// itself (must comfortably exceed the gap between sibling spawns).
50const POOLED_IDLE_TIMEOUT_SECS: u64 = 300;
51
52/// Deadline for a local worker's FIRST frame after a Run is dispatched. A warm
53/// worker answers in seconds; a cold spawn within tens. Total silence past this
54/// means the worker is dead (e.g. a pooled worker that exited right after its
55/// liveness check) and its Run is queued with nobody to serve it — trip it so the
56/// runner respawns once instead of hanging forever. Generous, to never false-trip
57/// a slow-but-healthy cold start.
58const WORKER_FIRST_FRAME_TIMEOUT: Duration = Duration::from_secs(60);
59
60/// Plaintext token returned once by the server authority for one Codex run.
61/// `token_id` is non-secret and is the handle used for guaranteed revocation.
62pub struct IssuedCodexRunToken {
63    pub token_id: String,
64    pub token: String,
65}
66
67/// Server-owned authority for Bamboo-as-provider Codex credentials. The engine
68/// only needs mint/revoke; verification remains inside the HTTP server.
69pub trait CodexRunTokenAuthority: Send + Sync + 'static {
70    fn issue(&self, session_id: &str) -> Result<IssuedCodexRunToken, String>;
71    fn revoke(&self, token_id: &str);
72}
73
74struct CodexRunTokenGuard {
75    authority: Arc<dyn CodexRunTokenAuthority>,
76    token_id: String,
77}
78
79impl Drop for CodexRunTokenGuard {
80    fn drop(&mut self) {
81        self.authority.revoke(&self.token_id);
82    }
83}
84
85fn executor_uses_bamboo_codex(executor: &ExecutorSpec) -> bool {
86    matches!(
87        executor,
88        ExecutorSpec::Codex {
89            auth_mode: Some(mode),
90            ..
91        } if mode == "bamboo"
92    ) || matches!(
93        executor,
94        ExecutorSpec::Codex {
95            auth_mode: None,
96            inherit_user_config,
97            ..
98        } if !inherit_user_config.unwrap_or(false)
99    )
100}
101
102fn workspace_is_bamboo_owned(raw: &str) -> bool {
103    let workspace = std::fs::canonicalize(raw).unwrap_or_else(|_| PathBuf::from(raw));
104    let configured_root = bamboo_config::paths::resolve_workspace_root();
105    let configured_root = std::fs::canonicalize(&configured_root).unwrap_or(configured_root);
106    if workspace.starts_with(&configured_root) {
107        return true;
108    }
109
110    // Project worktrees created by Bamboo live under
111    // `<project>/.bamboo/worktree/<name>` and carry the ownership marker used
112    // by the project-worktree lifecycle. A path that merely imitates the
113    // directory shape is not sufficient to bypass Codex's git guard.
114    workspace.ancestors().any(|candidate| {
115        let Some(name) = candidate.file_name().and_then(|name| name.to_str()) else {
116            return false;
117        };
118        let Some(worktree_root) = candidate.parent() else {
119            return false;
120        };
121        if worktree_root.file_name() != Some(std::ffi::OsStr::new("worktree"))
122            || worktree_root.parent().and_then(Path::file_name)
123                != Some(std::ffi::OsStr::new(".bamboo"))
124        {
125            return false;
126        }
127        let marker = worktree_root.join(".bamboo-owned").join(name);
128        std::fs::read_to_string(marker).is_ok_and(|branch| branch == format!("bamboo/{name}"))
129    })
130}
131
132fn build_codex_run_secrets(
133    executor: &ExecutorSpec,
134    authority: Option<Arc<dyn CodexRunTokenAuthority>>,
135    child_session_id: &str,
136) -> Result<
137    (
138        bamboo_subagent::proto::RunSecrets,
139        Option<CodexRunTokenGuard>,
140    ),
141    AgentError,
142> {
143    if !executor_uses_bamboo_codex(executor) {
144        return Ok((bamboo_subagent::proto::RunSecrets::default(), None));
145    }
146
147    let authority = authority.ok_or_else(|| {
148        AgentError::LLM(
149            "Codex auth mode 'bamboo' requires the server per-run token authority".to_string(),
150        )
151    })?;
152    let issued = authority
153        .issue(child_session_id)
154        .map_err(|error| AgentError::LLM(format!("mint Codex per-run provider token: {error}")))?;
155    let guard = CodexRunTokenGuard {
156        authority,
157        token_id: issued.token_id,
158    };
159    Ok((
160        bamboo_subagent::proto::RunSecrets {
161            codex_provider_token: Some(bamboo_subagent::proto::SecretValue::new(issued.token)),
162        },
163        Some(guard),
164    ))
165}
166
167/// A warm worker on the mailbox bus, parked for reuse between runs. It stays
168/// dialed-in + subscribed to `mailbox_id`; the next interchangeable child
169/// delivers its `Run` there instead of spawning a fresh process. Dropping it
170/// kills a local kill-on-drop subprocess; a remote / schedulable handle is
171/// process-less (`kill()` is a no-op — it self-manages via its idle timeout).
172struct PooledWorker {
173    worker: SpawnedChild,
174    /// The bus mailbox this worker subscribes to (where its `Run`s are delivered).
175    mailbox_id: String,
176}
177
178/// A role pinned to a remote resident worker (remote-actor-plan §3.4 / P1.5,
179/// #193), resolved at runner-build time from `SubagentsConfig.remote_placements`:
180/// the env-named bearer is already READ into `token` here (the raw token never
181/// rides the config), and `ca_cert_file` is the path to a PEM pinning a
182/// self-signed worker cert (`None` ⇒ default webpki roots / plaintext `ws://`).
183#[derive(Debug, Clone)]
184pub struct ResolvedRemotePlacement {
185    pub endpoint: String,
186    pub token: Option<String>,
187    pub ca_cert_file: Option<PathBuf>,
188    /// Display name for the machine this role runs on — the matching cluster
189    /// node's `label`/host, surfaced on the UI placement badge. `None` ⇒ derive
190    /// from the endpoint host.
191    pub host_label: Option<String>,
192}
193
194/// A role routed to a SCHEDULED worker (remote-actor-plan §3.4 / P2b, #181),
195/// resolved at runner-build time from `SubagentsConfig.schedulable_placements`.
196/// Names the logical `pool` (= the bus role) whose LIVE connected workers are the
197/// scheduling candidates — the runner picks one via the bus presence query
198/// (`BrokerClient::list_connected`). Phase 3 retired the old HTTP registry, so a
199/// pool is now just a role on the bus.
200#[derive(Debug, Clone)]
201pub struct ResolvedSchedulablePlacement {
202    pub pool: String,
203    /// Display name for the machine this pool's workers run on — the matching
204    /// cluster node's `label`/host, surfaced on the UI placement badge. `None` ⇒
205    /// fall back to the pool name.
206    pub host_label: Option<String>,
207}
208
209/// How `execute_external_child` should obtain its worker connection, decided
210/// once from `spec.placement`. Splits the divergent acquire/connect + retire
211/// logic three ways while the shared middle (Run dispatch, live registration,
212/// drive, close) stays identical. `Local` is the unchanged pre-#193 path;
213/// `Remote` is the unchanged #194 path; `Schedulable` (#181, P2b) is new.
214enum PlacementKind {
215    Local,
216    Remote,
217    Schedulable,
218}
219
220/// Spawns and drives a child session as an independent actor: a `bamboo-subagent` worker process.
221pub struct ActorChildRunner {
222    approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
223    permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
224    agent_id: String,
225    worker_bin: PathBuf,
226    worker_args: Vec<String>,
227    fabric_dir: PathBuf,
228    executor: ExecutorSpec,
229    /// Per-provider credentials snapshotted from the parent config at build
230    /// time; the spec carries only the ONE the child's provider needs.
231    credentials: Vec<ScopedCredential>,
232    /// Parent's default provider (used when the child has no explicit one).
233    default_provider: String,
234    /// The mailbox bus to run local children over (the unified transport). Local
235    /// sub-agents require it; `None` only when no broker could be embedded.
236    bus: Option<bamboo_subagent::BusEndpoint>,
237    /// Backpressure: bounds the number of concurrently *running* actors; further
238    /// runs wait for a slot instead of exploding the process table. (Idle pooled
239    /// workers do not hold a slot.)
240    concurrency: std::sync::Arc<tokio::sync::Semaphore>,
241    /// Warm-worker pool keyed by a reuse fingerprint
242    /// (role/provider/model/workspace/disabled-tools/baked-caps). A finished run
243    /// parks its bus worker here so the next interchangeable child reuses it
244    /// (delivers its `Run` to the same mailbox) instead of spawning a fresh
245    /// process — collapsing N sibling sub-agents onto a few warm workers.
246    pool: Arc<tokio::sync::Mutex<HashMap<String, Vec<PooledWorker>>>>,
247    max_idle_per_key: usize,
248    /// Host-side decision for a child's gated-tool approval request (Phase 2).
249    /// `None` ⇒ fail-closed DENY (the safe default). A wired decider (policy or
250    /// human-routing bridge) returns approve/deny over the actor WS.
251    approval_decider: Option<Arc<dyn ChildApprovalDecider>>,
252    /// Off-loop parent-agent reviewer for forced-ask requests. The root server
253    /// wires a session-aware reviewer; nested workers wire their owning model
254    /// reviewer directly into the per-run runner.
255    approval_reviewer: Option<Arc<dyn ChildApprovalReviewer>>,
256    /// Per-run escalation host bridge for non-bypass child-approval routing (#68;
257    /// Phase 6, Part B). The owning worker's `run()` installs its OWN host bridge
258    /// here via `set_escalation_bridge`; `execute_external_child` CAPTURES it at
259    /// grandchild-spawn time and hands the owned value to `drive()`, which uses it
260    /// to RE-PROXY a child's approval request UP to the parent run — chaining up
261    /// every level until a bypass level (model-review) or the top orchestrator
262    /// (human) decides, then relaying the reply back down. Was a process-global
263    /// slot; now per-runner so a fire-and-forget grandchild that OUTLIVES the run
264    /// that spawned it keeps that run's bridge for its whole lifetime instead of
265    /// reading a stale/overwritten global at approval time (→ fail-closed deny).
266    escalation_bridge: Arc<std::sync::Mutex<Option<bamboo_subagent::executor::HostBridge>>>,
267    /// Roles pinned to a REMOTE resident worker (#193), keyed by sub-agent role
268    /// (the child's `subagent_type`). A role present here routes through the
269    /// dedicated remote branch in `execute_external_child` (Bearer-authenticated
270    /// `wss://` connect, no spawn, no pool, no kill) instead of the local
271    /// subprocess + warm-pool path. Empty (the default) = all-local behavior.
272    remote_placements: HashMap<String, ResolvedRemotePlacement>,
273    /// Roles routed to a REGISTRY-SCHEDULED worker (#181, P2b), keyed by sub-agent
274    /// role. A role present here (AND not already in `remote_placements`, which
275    /// wins) routes through the dedicated SCHEDULABLE branch in
276    /// `execute_external_child`: query the registry for live workers in the pool,
277    /// pick one (round-robin), connect over `wss://` — no spawn, no pool, no kill,
278    /// and NO local-subprocess fallback (no live worker ⇒ a clear error). Empty
279    /// (the default) = all-local behavior.
280    schedulable_placements: HashMap<String, ResolvedSchedulablePlacement>,
281    /// Per-pool round-robin cursor for schedulable scheduling (#181, P2b). Bumped
282    /// once per pick so successive sibling spawns SPREAD across a pool's live
283    /// workers instead of all landing on the first candidate. Best-effort spread,
284    /// not a load balancer — the registry's live set can change between picks.
285    schedule_cursor: Arc<std::sync::Mutex<HashMap<String, usize>>>,
286    /// Optional server authority used only by `Codex` in `bamboo` auth mode.
287    codex_run_tokens: Option<Arc<dyn CodexRunTokenAuthority>>,
288}
289
290/// Decides how the host answers a child worker's gated-tool approval request
291/// (Phase 2: child → parent approval delegation). Async so an implementation
292/// can consult a policy. With no decider wired the host replies with a
293/// fail-closed DENY.
294///
295/// NOTE: `decide` is awaited inside the per-child frame pump, so an
296/// implementation must resolve promptly (e.g. a policy lookup). Model-based
297/// review belongs in [`ChildApprovalReviewer`], which runs off-loop and returns
298/// through the live steering channel without stalling the frame pump.
299#[async_trait]
300pub trait ChildApprovalDecider: Send + Sync {
301    /// Decide whether `child_session_id` may perform the gated action described
302    /// by `request` (`{tool_name, permission, resource}`).
303    async fn decide(&self, child_session_id: &str, request: &serde_json::Value) -> bool;
304}
305
306/// Resolve a child approval request to approve/deny. Fail-closed (DENY) when no
307/// decider is wired — the single, testable seam for the host-side decision.
308async fn decide_child_approval(
309    decider: Option<&Arc<dyn ChildApprovalDecider>>,
310    child_session_id: &str,
311    request: &serde_json::Value,
312) -> bool {
313    match decider {
314        Some(decider) => decider.decide(child_session_id, request).await,
315        None => false,
316    }
317}
318
319/// How long a chained parent-agent review may take before the child's gated
320/// tool fails closed (DENY). Bounds an unanswered request so it cannot hang the
321/// worker indefinitely.
322const CHILD_APPROVAL_TIMEOUT: Duration = Duration::from_secs(300);
323
324/// Off-loop reviewer for a child's gated-tool approval request (Phase 6, Part B).
325///
326/// Installed (process-global) by a BYPASSED self-orchestrating worker so its
327/// children's forced-ask (dangerous) gated actions — which still raise
328/// `ConfirmationRequired` even under bypass — get an LLM reasonableness check
329/// rather than a blind pass. `review` is an LLM call: `drive()` invokes it in a
330/// SPAWNED task (NEVER in the frame pump) and delivers the verdict async via the
331/// live channel, so the agent loop is never blocked.
332#[async_trait]
333pub trait ChildApprovalReviewer: Send + Sync {
334    /// Judge whether the gated action `request` (`{tool_name, permission,
335    /// resource}`) is reasonable for `child_session_id`'s task. `true` = approve.
336    async fn review(
337        &self,
338        parent_session_id: &str,
339        child_session_id: &str,
340        request: &serde_json::Value,
341    ) -> bool;
342}
343
344fn child_approval_reviewer_slot() -> &'static std::sync::OnceLock<Arc<dyn ChildApprovalReviewer>> {
345    static SLOT: std::sync::OnceLock<Arc<dyn ChildApprovalReviewer>> = std::sync::OnceLock::new();
346    &SLOT
347}
348
349/// Install the process-global child-approval reviewer (idempotent; first wins).
350pub fn set_child_approval_reviewer(reviewer: Arc<dyn ChildApprovalReviewer>) {
351    let _ = child_approval_reviewer_slot().set(reviewer);
352}
353
354/// The process-global child-approval reviewer, if installed.
355pub fn child_approval_reviewer() -> Option<Arc<dyn ChildApprovalReviewer>> {
356    child_approval_reviewer_slot().get().cloned()
357}
358
359impl ActorChildRunner {
360    #[allow(clippy::too_many_arguments)]
361    pub fn new(
362        agent_id: String,
363        worker_bin: PathBuf,
364        worker_args: Vec<String>,
365        fabric_dir: PathBuf,
366        executor: ExecutorSpec,
367        credentials: Vec<ScopedCredential>,
368        default_provider: String,
369        max_concurrent: usize,
370    ) -> Self {
371        Self {
372            approval_registry: None,
373            permission_config: None,
374            agent_id,
375            worker_bin,
376            worker_args,
377            fabric_dir,
378            executor,
379            credentials,
380            default_provider,
381            bus: None,
382            concurrency: std::sync::Arc::new(tokio::sync::Semaphore::new(max_concurrent.max(1))),
383            pool: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
384            max_idle_per_key: DEFAULT_MAX_IDLE_PER_KEY,
385            approval_decider: None,
386            approval_reviewer: None,
387            escalation_bridge: Arc::new(std::sync::Mutex::new(None)),
388            remote_placements: HashMap::new(),
389            schedulable_placements: HashMap::new(),
390            schedule_cursor: Arc::new(std::sync::Mutex::new(HashMap::new())),
391            codex_run_tokens: None,
392        }
393    }
394
395    pub fn with_approval_registry(
396        mut self,
397        registry: super::approval_registry::SharedApprovalRegistry,
398    ) -> Self {
399        self.approval_registry = Some(registry);
400        self
401    }
402
403    pub fn with_permission_config(
404        mut self,
405        config: Arc<bamboo_tools::permission::PermissionConfig>,
406    ) -> Self {
407        self.permission_config = Some(config);
408        self
409    }
410
411    /// Run children over the mailbox bus (the unified actor+mailbox transport).
412    /// When set, local children dial this bus and are driven by mailbox id; when
413    /// unset they use the legacy direct-WS path. The server passes its in-process
414    /// broker here (`subagents.broker`); tests without a broker leave it unset.
415    pub fn with_bus(mut self, bus: Option<bamboo_subagent::BusEndpoint>) -> Self {
416        self.bus = bus.filter(|b| !b.endpoint.trim().is_empty());
417        self
418    }
419
420    /// Wire the host-side decider for child gated-tool approval requests
421    /// (Phase 2). Without this the host fail-closed DENYs every request.
422    pub fn with_approval_decider(mut self, decider: Arc<dyn ChildApprovalDecider>) -> Self {
423        self.approval_decider = Some(decider);
424        self
425    }
426
427    pub fn with_approval_reviewer(mut self, reviewer: Arc<dyn ChildApprovalReviewer>) -> Self {
428        self.approval_reviewer = Some(reviewer);
429        self
430    }
431
432    pub fn with_codex_run_tokens(
433        mut self,
434        authority: Option<Arc<dyn CodexRunTokenAuthority>>,
435    ) -> Self {
436        self.codex_run_tokens = authority;
437        self
438    }
439
440    /// Pin specific sub-agent roles to remote resident workers (#193). The map
441    /// is keyed by role (`subagent_type`); a child whose role is present connects
442    /// over `wss://` to the resolved endpoint instead of spawning a local
443    /// subprocess. Default (empty) keeps every role on the local path — exactly
444    /// today's behavior.
445    pub fn with_remote_placements(
446        mut self,
447        placements: HashMap<String, ResolvedRemotePlacement>,
448    ) -> Self {
449        self.remote_placements = placements;
450        self
451    }
452
453    /// Route specific sub-agent roles to a registry-SCHEDULED worker (#181, P2b).
454    /// The map is keyed by role (`subagent_type`); a child whose role is present
455    /// (and NOT already pinned by `remote_placements`, which takes precedence) is
456    /// run on a live worker discovered from the registry instead of a local
457    /// subprocess. Default (empty) keeps every role on the local path.
458    pub fn with_schedulable_placements(
459        mut self,
460        placements: HashMap<String, ResolvedSchedulablePlacement>,
461    ) -> Self {
462        self.schedulable_placements = placements;
463        self
464    }
465
466    /// Reuse fingerprint: two children are interchangeable on one warm worker iff
467    /// they share role, provider, model, workspace, disabled-tool set, AND every
468    /// capability the worker BAKES at provision time (`BambooRuntimeExecutor`
469    /// stamps these once and reuses them across runs): nesting depth, nested-spawn
470    /// stack, bypass mode, permission enforcement, and the depth cap. Omitting any
471    /// of these lets the pool hand a run a worker baked for a DIFFERENT posture —
472    /// e.g. a depth-1 worker (with its own spawn stack) reused for a depth-4
473    /// child would re-stamp `spawn_depth=1` and pass the depth-cap check, breaking
474    /// the recursion bound; or a bypass worker reused for a non-bypass child. So
475    /// these MUST split the pool bucket. Everything else (assignment, history) is
476    /// shipped per-run in the `RunSpec` and does not affect the fingerprint.
477    /// Reuse fingerprint (role/provider/model/workspace/disabled-tools/baked
478    /// caps): two children with the same fingerprint are interchangeable on one
479    /// warm worker, so they share a pool bucket. Any axis the worker bakes ONCE
480    /// at provision time MUST be in here, else a worker baked for one posture
481    /// gets reused for another (see the `fingerprint_*` tests).
482    fn fingerprint(spec: &ProvisionSpec) -> String {
483        let role = spec.identity.role.as_str();
484        let (provider, model) = spec
485            .model
486            .as_ref()
487            .map(|m| (m.provider.as_str(), m.model.as_str()))
488            .unwrap_or(("", ""));
489        let workspace = spec.workspace.as_deref().unwrap_or("");
490        let mut tools = spec.disabled_tools.clone().unwrap_or_default();
491        tools.sort();
492        let caps = &spec.capabilities;
493        // The worker constructs its executor exactly once. In particular,
494        // Codex exec and app-server workers are not interchangeable.
495        let executor = serde_json::to_string(&spec.executor).unwrap_or_default();
496        format!(
497            "{role}\u{1}{provider}\u{1}{model}\u{1}{workspace}\u{1}{}\u{1}d={}\u{1}ns={}\u{1}by={}\u{1}ep={}\u{1}md={}\u{1}nha={}\u{1}gro={}\u{1}executor={executor}",
498            tools.join(","),
499            spec.identity.depth,
500            caps.nested_spawn,
501            caps.bypass,
502            caps.enforce_permissions,
503            caps.max_spawn_depth.unwrap_or(0),
504            // #73 review (P1): a worker bakes `no_human_review` ONCE from this flag
505            // at build() and never re-reads it per run, so the pool MUST NOT hand a
506            // worker baked for one approval posture to a run of the opposite one —
507            // else a scheduled-root worker reused for an interactive child would
508            // silently model-review instead of asking the human (and vice-versa,
509            // reintroducing the 300s-deny). Split the bucket on it.
510            caps.no_human_approver,
511            // #71: the read-only Bash checker is baked once at build() from this
512            // flag, so a guardian-reviewer worker must NOT be reused for an
513            // ordinary child (which expects unrestricted Bash), and vice-versa.
514            caps.guardian_read_only,
515        )
516    }
517
518    /// Check out a warm bus worker for `key`, reusing a live parked one if any,
519    /// else spawning a fresh one that dials the bus. The returned worker is OWNED
520    /// by the caller for the run's duration (checkout removes it from the pool, so
521    /// a concurrent sibling gets a different worker or spawns its own — one run per
522    /// worker at a time, matching the pre-bus pool semantics).
523    async fn acquire_bus_worker(
524        &self,
525        key: &str,
526        spec: &ProvisionSpec,
527    ) -> crate::runtime::runner::Result<PooledWorker> {
528        // Drain the bucket, skipping (and reaping) any worker whose process exited
529        // while parked. A live one is handed straight out for reuse.
530        loop {
531            let candidate = {
532                let mut pool = self.pool.lock().await;
533                pool.get_mut(key).and_then(|bucket| bucket.pop())
534            };
535            let Some(mut candidate) = candidate else {
536                break;
537            };
538            if candidate.worker.is_alive() {
539                return Ok(candidate);
540            }
541            candidate.worker.kill().await;
542        }
543
544        let spawned = spawn_worker_on_bus(&self.worker_bin, &self.worker_args, spec)
545            .await
546            .map_err(|e| AgentError::LLM(format!("actor spawn (bus) failed: {e}")))?;
547        let mailbox_id = spawned.record.agent_id.clone();
548        Ok(PooledWorker {
549            worker: spawned,
550            mailbox_id,
551        })
552    }
553
554    /// Park a warm bus worker for reuse after a clean run; if its bucket is full
555    /// (or it died), kill it instead. The worker stays dialed-in + subscribed
556    /// while parked, so a reusing child just delivers a new `Run` to its mailbox.
557    async fn release_bus_worker(&self, key: &str, mut worker: PooledWorker) {
558        if !worker.worker.is_alive() {
559            worker.worker.kill().await;
560            return;
561        }
562        let mut pool = self.pool.lock().await;
563        let bucket = pool.entry(key.to_string()).or_default();
564        if bucket.len() >= self.max_idle_per_key {
565            drop(pool);
566            worker.worker.kill().await;
567            return;
568        }
569        bucket.push(worker);
570    }
571
572    /// Assemble the parent-resolved provisioning document for this child.
573    fn build_spec(&self, session: &Session, job: &SpawnJob) -> ProvisionSpec {
574        let mut spec = ProvisionSpec::new(
575            ChildIdentity {
576                child_id: job.child_session_id.clone(),
577                parent_id: Some(job.parent_session_id.clone()),
578                project_key: None,
579                role: session
580                    .metadata
581                    .get("subagent_type")
582                    .cloned()
583                    .unwrap_or_else(|| "worker".to_string()),
584                // The child session already carries the correct depth
585                // (create_child_action's new_child_of did parent.spawn_depth+1);
586                // stamp it so the worker can re-establish it on its run session
587                // and enforce the max-depth cap across the actor boundary.
588                depth: session.spawn_depth,
589            },
590            self.executor.clone(),
591            self.fabric_dir.to_string_lossy().into_owned(),
592        );
593        spec.workspace = session.workspace.clone();
594        if let ExecutorSpec::Codex {
595            workspace_owned, ..
596        } = &mut spec.executor
597        {
598            *workspace_owned = Some(
599                spec.workspace
600                    .as_deref()
601                    .is_some_and(workspace_is_bamboo_owned),
602            );
603        }
604        // Unified transport: when a bus is configured, the child dials it (no
605        // listen socket / file discovery) and the parent drives it by mailbox id.
606        spec.bus = self.bus.clone();
607        // Final model: the session's pinned model_ref (create.model / routing already applied),
608        // falling back to the job's bare model on the parent's default provider.
609        spec.model = session
610            .model_ref
611            .as_ref()
612            .map(|r| ModelRefSpec {
613                provider: r.provider.clone(),
614                model: r.model.clone(),
615            })
616            .or_else(|| {
617                let m = job.model.trim();
618                (!m.is_empty()).then(|| ModelRefSpec {
619                    provider: self.default_provider.clone(),
620                    model: m.to_string(),
621                })
622            });
623        spec.disabled_tools = job.disabled_tools.clone();
624        match &spec.executor {
625            // Codex auth is independent of the session's normal Bamboo model
626            // provider. Inherit/API-key/Bamboo modes need no credential-store
627            // secret at provisioning; custom mode gets exactly its referenced
628            // key. This prevents an unrelated upstream provider key from
629            // reaching a Codex worker that only needs a per-run bcx1_ token.
630            ExecutorSpec::Codex {
631                auth_mode,
632                provider_key_ref,
633                ..
634            } => {
635                if auth_mode.as_deref() == Some("custom") {
636                    if let Some(reference) = provider_key_ref {
637                        if let Some(credential) = self.credentials.iter().find(|credential| {
638                            credential.credential_ref.as_deref() == Some(reference)
639                        }) {
640                            spec.secrets.provider_credentials.push(credential.clone());
641                        } else {
642                            tracing::warn!(
643                                "actor child {}: custom Codex credential reference '{}' did not resolve",
644                                job.child_session_id,
645                                reference
646                            );
647                        }
648                    } else {
649                        tracing::warn!(
650                            "actor child {}: custom Codex executor has no credential reference",
651                            job.child_session_id
652                        );
653                    }
654                }
655            }
656            // Other executors keep the existing least-privilege contract: only
657            // the credential for the child session's selected provider.
658            _ => {
659                let provider = spec
660                    .model
661                    .as_ref()
662                    .map(|model| model.provider.as_str())
663                    .filter(|provider| !provider.trim().is_empty())
664                    .unwrap_or(&self.default_provider);
665                if let Some(credential) = self
666                    .credentials
667                    .iter()
668                    .find(|credential| credential.provider == provider)
669                {
670                    spec.secrets.provider_credentials.push(credential.clone());
671                } else {
672                    tracing::warn!(
673                        "actor child {}: no credential found for provider '{}'",
674                        job.child_session_id,
675                        provider
676                    );
677                }
678            }
679        }
680        // Phase 6 (direct nested execution): a worker BELOW the depth cap may
681        // orchestrate its OWN children — on startup it builds its own spawn
682        // stack and runs the real SubAgent tool (no host proxy). The cap (the
683        // SubAgent tool refuses to spawn at/over `max_spawn_depth`) bounds the
684        // recursion. Driven purely by the child's depth, so it auto-propagates
685        // down the tree without any extra config threading.
686        spec.capabilities.nested_spawn = session.spawn_depth < MAX_SPAWN_DEPTH;
687        spec.capabilities.max_spawn_depth = Some(MAX_SPAWN_DEPTH);
688        // #69: activate child-approval review. Sub-agents enforce permissions so
689        // their DANGEROUS actions (the worker uses a HIGH threshold) reach the
690        // parent for review — escalated to the human, or model-reviewed off-loop
691        // when the parent is in bypass. The worker installs no checker without
692        // this, so the whole review chain would otherwise stay dormant.
693        spec.capabilities.enforce_permissions = true;
694        // Propagate "bypass permissions" so a self-orchestrating worker knows it
695        // is a bypassed parent and installs the off-loop model-reviewer for its
696        // children's forced-ask actions (Phase 6, Part B). The child session
697        // already carries the inherited flag (create_child_action seeds it).
698        spec.capabilities.bypass = session
699            .agent_runtime_state
700            .as_ref()
701            .is_some_and(|s| s.bypass_permissions);
702        // #73: propagate "no interactive human approver" (headless / scheduled /
703        // deployed root, inherited by the child session). When set, the worker's
704        // per-run approval proxy model-reviews a gated action locally instead of
705        // escalating to a human who will never answer (which would 300s-deny).
706        spec.capabilities.no_human_approver = session
707            .agent_runtime_state
708            .as_ref()
709            .is_some_and(|s| s.no_human_approver);
710        // #71: mark a READ-ONLY Guardian reviewer so the worker installs the
711        // read-only Bash allowlist checker. The reviewer is spawned by
712        // `spawn_guardian_review` with `subagent_type == "guardian"` (the SAME
713        // marker the completion coordinator branches on to parse the verdict) AND
714        // the `guardian_read_only_disabled_tools` denylist. Keyed off that role
715        // marker (already read above to set `identity.role`), so it rides the same
716        // session-metadata path the denylist/subagent_type use — no new wire seam.
717        // Without this the worker keeps an UNRESTRICTED Bash, so the reviewer could
718        // still `rm -rf` / `git push` / `curl | sh`, defeating "read-only".
719        spec.capabilities.guardian_read_only =
720            session.metadata.get("subagent_type").map(String::as_str) == Some("guardian");
721        if spec.capabilities.guardian_read_only {
722            if let ExecutorSpec::Codex {
723                permission_profile, ..
724            } = &mut spec.executor
725            {
726                *permission_profile = Some("read-only".to_string());
727            }
728        }
729        // #193: route this role to a REMOTE resident worker when one is pinned.
730        // `spec.identity.role` was just computed from `subagent_type` above; a
731        // match flips the placement to Remote and rides the worker's bearer on the
732        // scoped secrets envelope (TLS handshake / Authorization header only — the
733        // token is never logged). No match leaves the default `Placement::Local`,
734        // so the local path is byte-for-byte unchanged for every non-pinned role.
735        if let Some(placement) = self.remote_placements.get(spec.identity.role.as_str()) {
736            spec.placement = Placement::Remote {
737                endpoint: placement.endpoint.clone(),
738            };
739            spec.secrets.worker_auth_token = placement.token.clone();
740        } else if let Some(placement) = self.schedulable_placements.get(spec.identity.role.as_str())
741        {
742            // #181 (P2b): route this role to a SCHEDULED worker — ONLY when it is
743            // NOT already pinned to a fixed remote endpoint (the `else if` makes
744            // remote_placements take precedence for a role in both). The concrete
745            // worker is picked at run time in `execute_external_child` from the bus
746            // (a live connected worker of the pool role). No per-placement bearer
747            // now — the bus connection uses the bus token. No match in either map
748            // leaves the default `Placement::Local`.
749            spec.placement = Placement::Schedulable {
750                pool: placement.pool.clone(),
751            };
752        }
753        spec
754    }
755
756    /// The `metadata["placement"]` JSON to stamp on a child from its resolved
757    /// placement, preferring the matching cluster node's `host_label` (its
758    /// operator label/host) over the raw endpoint/pool. `None` for a Local child
759    /// (the DTO defaults it to the backend's own host). Split out of
760    /// `execute_external_child` so the role→placement→host resolution is unit-testable.
761    fn placement_stamp_for(&self, spec: &ProvisionSpec) -> Option<String> {
762        let host_label = match &spec.placement {
763            Placement::Remote { .. } => self
764                .remote_placements
765                .get(spec.identity.role.as_str())
766                .and_then(|p| p.host_label.as_deref()),
767            Placement::Schedulable { .. } => self
768                .schedulable_placements
769                .get(spec.identity.role.as_str())
770                .and_then(|p| p.host_label.as_deref()),
771            Placement::Local => None,
772        };
773        placement_metadata(&spec.placement, host_label)
774    }
775
776    /// Pick a live worker for a SCHEDULABLE role from the BUS (#181, Phase 3):
777    /// ask the broker which actors are connected serving the pool role (presence
778    /// is connection-truth — no HTTP registry, no leases, no connect-fail
779    /// failover), then round-robin one per resolve for spread. Returns the chosen
780    /// worker's mailbox id. An empty pool ⇒ a terminal `AgentError` — NEVER a
781    /// local-subprocess fallback (that would silently defeat the placement).
782    async fn resolve_schedulable_worker(
783        &self,
784        role: &str,
785    ) -> std::result::Result<String, AgentError> {
786        let pool = self
787            .schedulable_placements
788            .get(role)
789            .ok_or_else(|| {
790                AgentError::LLM(format!(
791                    "schedulable placement for role '{role}' vanished before scheduling"
792                ))
793            })?
794            .pool
795            .clone();
796        let bus = self.bus.as_ref().ok_or_else(|| {
797            AgentError::LLM(format!(
798                "schedulable role '{role}': no mailbox bus configured (subagents.broker)"
799            ))
800        })?;
801
802        // Ask the BUS who is connected serving the pool role — presence is
803        // connection-truth (no HTTP registry, no leases, no stale-record failover).
804        let mut q = bamboo_broker::BrokerClient::connect(
805            &bus.endpoint,
806            bamboo_subagent::AgentRef {
807                session_id: format!("sched-q-{role}"),
808                role: None,
809            },
810            &bus.token,
811        )
812        .await
813        .map_err(|e| {
814            AgentError::LLM(format!(
815                "schedulable role '{role}': bus connect failed: {e}"
816            ))
817        })?;
818        let candidates = q.list_connected(&pool).await.map_err(|e| {
819            AgentError::LLM(format!(
820                "schedulable role '{role}': bus presence query failed: {e}"
821            ))
822        })?;
823
824        if candidates.is_empty() {
825            return Err(AgentError::LLM(format!(
826                "schedulable role '{role}': no live worker in pool '{pool}' on the bus \
827                 (NOT spawning a local subprocess — a schedulable role has no local fallback)"
828            )));
829        }
830
831        // Round-robin: advance a per-pool cursor once per resolve so successive
832        // sibling spawns spread across the connected pool workers. No failover
833        // needed — a listed worker is connected NOW (the bus only lists live
834        // subscribers), so there is no stale-but-leased candidate to skip.
835        let idx = {
836            let mut cursors = self.schedule_cursor.lock().recover_poison();
837            let cursor = cursors.entry(pool.clone()).or_insert(0);
838            let i = *cursor % candidates.len();
839            *cursor = cursor.wrapping_add(1);
840            i
841        };
842        Ok(candidates[idx].clone())
843    }
844}
845
846#[async_trait]
847impl ExternalChildRunner for ActorChildRunner {
848    async fn should_handle(&self, session: &Session) -> bool {
849        session.metadata.get("runtime.kind") == Some(&"external".to_string())
850            && session.metadata.get("external.protocol") == Some(&"actor".to_string())
851            && session.metadata.get("external.agent_id") == Some(&self.agent_id)
852    }
853
854    fn set_escalation_bridge(&self, bridge: Option<bamboo_subagent::executor::HostBridge>) {
855        *self.escalation_bridge.lock().recover_poison() = bridge;
856    }
857
858    async fn execute_external_child(
859        &self,
860        session: &mut Session,
861        job: &SpawnJob,
862        event_tx: mpsc::Sender<AgentEvent>,
863        cancel_token: CancellationToken,
864    ) -> crate::runtime::runner::Result<()> {
865        // #68 CORRECTNESS CRUX: capture the per-run escalation bridge HERE, at the
866        // moment this grandchild is spawned — while the parent run's bridge is
867        // still in our slot — into an owned local handed to `drive()` for this
868        // grandchild's whole lifetime. A fire-and-forget grandchild that OUTLIVES
869        // the run that spawned it must NOT re-read `self.escalation_bridge` at
870        // approval time: by then `run()` may have cleared/overwritten it (a worker
871        // serves runs sequentially), and re-proxying through a closed bridge
872        // fail-closed denies. Capturing at spawn pins the right bridge per run.
873        let escalation = self.escalation_bridge.lock().recover_poison().clone();
874        let assignment = extract_assignment(session);
875        let mut spec = self.build_spec(session, job);
876        // Mark the worker reusable + give it an idle timeout so it self-reaps if
877        // orphaned. Warm bus workers are pooled per fingerprint and reused.
878        spec.reusable = true;
879        if spec.limits.idle_timeout_secs.is_none() {
880            spec.limits.idle_timeout_secs = Some(POOLED_IDLE_TIMEOUT_SECS);
881        }
882        let pool_key = Self::fingerprint(&spec);
883
884        // The recommended provider URL is deliberately parent-loopback. A
885        // resident remote worker would interpret 127.0.0.1 as itself, not this
886        // server, so reject that ambiguous deployment instead of minting a
887        // credential that can never authenticate to the intended parent.
888        if executor_uses_bamboo_codex(&spec.executor) && !matches!(spec.placement, Placement::Local)
889        {
890            return Err(AgentError::LLM(
891                "Codex auth mode 'bamboo' requires local actor placement; use custom mode with a reachable URL for remote workers"
892                    .to_string(),
893            ));
894        }
895        // Rehydration: the child session in the parent's store is the actor's
896        // durable state. Ship the full conversation so a reactivation
897        // (send_message / update / rerun) carries its history. A reused worker is
898        // stateless between runs, so this is also what isolates each child's
899        // context on a shared process.
900        let messages: Vec<serde_json::Value> = session
901            .messages
902            .iter()
903            .filter_map(|m| serde_json::to_value(m).ok())
904            .collect();
905        let project_id = project_id_for_actor_run(session)?;
906        // Policy is captured per activation (not only when a worker is
907        // provisioned), so reused local workers and resident remote/broker
908        // workers observe the latest durable revision and bypass flag at the
909        // next run boundary. Session grants are intentionally not inherited.
910        let permission_policy = self.permission_config.as_ref().and_then(|config| {
911            serde_json::to_value(config.to_serializable())
912                .ok()
913                .map(|policy| PermissionPolicyContext {
914                    revision: config.policy_revision(),
915                    bypass_permissions: session
916                        .agent_runtime_state
917                        .as_ref()
918                        .is_some_and(|state| state.bypass_permissions),
919                    session_id: session.id.clone(),
920                    workspace_path: session.workspace.clone(),
921                    inherit_session_grants: false,
922                    policy,
923                })
924        });
925
926        // Backpressure: hold a concurrency slot for the lifetime of the *run*
927        // (cancellation still proceeds — the cancel branch in drive() runs while
928        // we hold the permit). Released when this fn returns, i.e. once the worker
929        // is parked back into the pool, so idle workers don't pin slots.
930        let _slot = self
931            .concurrency
932            .acquire()
933            .await
934            .map_err(|_| AgentError::LLM("actor concurrency limiter closed".to_string()))?;
935
936        // Bamboo-as-provider credentials are minted at the activation boundary,
937        // after backpressure admits the run and never at worker provisioning.
938        // This is load-bearing for warm workers: a parked process must never
939        // retain a token from its previous run. The guard revokes on every return
940        // path (success, error, cancellation, dispatch failure, or first-frame
941        // retry exhaustion).
942        let (run_secrets, _codex_token_guard) = build_codex_run_secrets(
943            &spec.executor,
944            self.codex_run_tokens.clone(),
945            &job.child_session_id,
946        )?;
947
948        // Split LOCAL (spawn + warm-pool) from the two process-less remote paths
949        // ONLY at the divergent spots — acquire/connect here and the park/retire at
950        // the end. Everything between (Run dispatch, live-actor registration,
951        // drive, the close) is identical for all three. `kind` is the single guard.
952        //   - Local       (#0):  byte-for-byte the pre-#193 reuse-or-spawn path.
953        //   - Remote       (#194): connect to a FIXED resident endpoint, no spawn.
954        //   - Schedulable  (#181): resolve a live worker from the registry, connect.
955        let kind = match spec.placement {
956            Placement::Remote { .. } => PlacementKind::Remote,
957            Placement::Schedulable { .. } => PlacementKind::Schedulable,
958            Placement::Local => PlacementKind::Local,
959        };
960        let remote = !matches!(kind, PlacementKind::Local);
961
962        // Stamp WHICH machine this child runs on onto its session metadata, so the
963        // UI can show it (mirrored into the session index → SessionSummary.placement).
964        // Only remote/scheduled placements need a stamp — a Local child falls through
965        // to the DTO default (this backend's own host). Persisted by the caller with
966        // the rest of the child session after we return.
967        if let Some(placement_meta) = self.placement_stamp_for(&spec) {
968            session
969                .metadata
970                .insert("placement".to_string(), placement_meta);
971        }
972
973        // Retry-once loop: a pooled local worker can die between its liveness
974        // check and handling the Run (a tiny TOCTOU window) — its Run then sits
975        // queued with no server. The first-frame watchdog in `drive` surfaces that
976        // as `WorkerUnresponsive`; we reap the dead worker and re-acquire ONCE
977        // (which spawns fresh / reuses the next live one). Remote/schedulable have
978        // no spawn fallback, so they never retry.
979        let mut attempt = 0u8;
980        let (result, actor) = loop {
981            let (actor, mut client) = match kind {
982                PlacementKind::Remote => {
983                    // REMOTE branch: connect to a resident worker. No spawn, no pool
984                    // touch, no drain. We do not own the worker, so a connect failure
985                    // has NO respawn fallback — it is a clear, terminal error.
986                    let placement = self
987                        .remote_placements
988                        .get(spec.identity.role.as_str())
989                        .ok_or_else(|| {
990                            AgentError::LLM(format!(
991                                "remote placement for role '{}' vanished before connect",
992                                spec.identity.role
993                            ))
994                        })?;
995                    let endpoint = placement.endpoint.clone();
996                    // Build the TLS trust: a pinned CA pins a self-signed worker cert;
997                    // otherwise default webpki roots (or plaintext for `ws://`).
998                    let trust_cfg = match placement.ca_cert_file.as_deref() {
999                        Some(path) => Some(client_config_trusting_cert(path).map_err(|e| {
1000                            AgentError::LLM(format!(
1001                                "remote worker CA cert '{}': {e}",
1002                                path.display()
1003                            ))
1004                        })?),
1005                        None => None,
1006                    };
1007                    let client = ChildClient::connect_with_auth_tls(
1008                        &endpoint,
1009                        placement.token.as_deref(),
1010                        trust_cfg,
1011                    )
1012                    .await
1013                    .map_err(|e| {
1014                        AgentError::LLM(format!("remote actor connect to '{endpoint}' failed: {e}"))
1015                    })?;
1016                    // Process-less handle so live-actor registration (in-band steering)
1017                    // works exactly as for a local worker; `kill()` is a no-op.
1018                    let record = AgentRecord {
1019                        agent_id: job.child_session_id.clone(),
1020                        role: spec.identity.role.clone(),
1021                        labels: Vec::new(),
1022                        endpoint: endpoint.clone(),
1023                        pid: 0,
1024                        version: String::new(),
1025                        started_at: chrono::Utc::now(),
1026                        lease_expires_at: chrono::Utc::now(),
1027                    };
1028                    let _ = endpoint;
1029                    let actor = PooledWorker {
1030                        worker: SpawnedChild::remote(record),
1031                        mailbox_id: job.child_session_id.clone(),
1032                    };
1033                    let client: Box<dyn bamboo_subagent::ChildLink> = Box::new(client);
1034                    (actor, client)
1035                }
1036                PlacementKind::Schedulable => {
1037                    // SCHEDULABLE branch (#181): pick a LIVE worker of the pool role
1038                    // from the BUS (presence = connection-truth; no HTTP registry, no
1039                    // leases, no failover) and drive it by mailbox id. The pool worker
1040                    // stays connected and is reused next time. No spawn, no kill, NO
1041                    // local fallback — an empty pool is a terminal error (raised in
1042                    // resolve_schedulable_worker).
1043                    let bus = self.bus.as_ref().ok_or_else(|| {
1044                        AgentError::LLM(
1045                            "schedulable sub-agents require a mailbox bus (subagents.broker)"
1046                                .to_string(),
1047                        )
1048                    })?;
1049                    let mailbox_id = self
1050                        .resolve_schedulable_worker(spec.identity.role.as_str())
1051                        .await?;
1052                    let parent = bamboo_subagent::AgentRef {
1053                        session_id: format!("p-{}", job.child_session_id),
1054                        role: None,
1055                    };
1056                    let link = bamboo_broker::BrokerChildLink::connect(
1057                        &bus.endpoint,
1058                        parent,
1059                        &bus.token,
1060                        mailbox_id.clone(),
1061                    )
1062                    .await
1063                    .map_err(|e| {
1064                        AgentError::LLM(format!(
1065                            "schedulable link connect to '{mailbox_id}' failed: {e}"
1066                        ))
1067                    })?;
1068                    // Process-less handle — a bus-resident pool worker is never ours to
1069                    // kill (remote ⇒ dropped, not pooled, after the run).
1070                    let actor = PooledWorker {
1071                        worker: SpawnedChild::remote(AgentRecord {
1072                            agent_id: mailbox_id.clone(),
1073                            role: spec.identity.role.clone(),
1074                            labels: Vec::new(),
1075                            endpoint: bus.endpoint.clone(),
1076                            pid: 0,
1077                            version: String::new(),
1078                            started_at: chrono::Utc::now(),
1079                            lease_expires_at: chrono::Utc::now(),
1080                        }),
1081                        mailbox_id,
1082                    };
1083                    let client: Box<dyn bamboo_subagent::ChildLink> = Box::new(link);
1084                    (actor, client)
1085                }
1086                PlacementKind::Local => {
1087                    // LOCAL = the mailbox bus (the unified transport): check out a warm
1088                    // pooled worker (reuse a live parked one, else spawn fresh) and
1089                    // drive it by mailbox id — no listen socket, no file discovery, no
1090                    // respawn-on-connect-miss (the broker queues the Run until the
1091                    // worker handles it). The legacy direct-WS path was retired; the bus
1092                    // is required.
1093                    let bus = self.bus.as_ref().ok_or_else(|| {
1094                        AgentError::LLM(
1095                            "local sub-agents require a mailbox bus (subagents.broker); none is \
1096                         configured and the bus could not be embedded"
1097                                .to_string(),
1098                        )
1099                    })?;
1100                    let actor = self.acquire_bus_worker(&pool_key, &spec).await?;
1101                    let parent = bamboo_subagent::AgentRef {
1102                        session_id: format!("p-{}", job.child_session_id),
1103                        role: None,
1104                    };
1105                    let link = bamboo_broker::BrokerChildLink::connect(
1106                        &bus.endpoint,
1107                        parent,
1108                        &bus.token,
1109                        actor.mailbox_id.clone(),
1110                    )
1111                    .await
1112                    .map_err(|e| {
1113                        AgentError::LLM(format!("broker child link connect failed: {e}"))
1114                    })?;
1115                    let client: Box<dyn bamboo_subagent::ChildLink> = Box::new(link);
1116                    (actor, client)
1117                }
1118            };
1119
1120            if let Err(e) = client
1121                .send(ParentFrame::Run(RunSpec {
1122                    // Cloned (not moved) so a retry can re-dispatch to a fresh worker.
1123                    assignment: assignment.clone(),
1124                    project_id: project_id.clone(),
1125                    reasoning_effort: None,
1126                    permission_policy: permission_policy.clone(),
1127                    messages: messages.clone(),
1128                    secrets: run_secrets.clone(),
1129                }))
1130                .await
1131            {
1132                if !remote {
1133                    actor.worker.kill().await;
1134                }
1135                return Err(AgentError::LLM(format!("actor run dispatch failed: {e}")));
1136            }
1137
1138            // Register as a live actor so send_message (running, no interrupt) can
1139            // steer this child in-band over the existing WS connection. The guard
1140            // unregisters on every exit path.
1141            let (live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
1142            let live_guard = super::live::register(
1143                &job.child_session_id,
1144                live_tx,
1145                attempt as u32,
1146                self.approval_registry.clone(),
1147            );
1148
1149            let result = drive(
1150                &mut *client,
1151                &job.parent_session_id,
1152                &job.child_session_id,
1153                attempt as u32,
1154                self.approval_registry.as_ref(),
1155                self.approval_decider.as_ref(),
1156                self.approval_reviewer.as_ref(),
1157                escalation.clone(),
1158                &event_tx,
1159                &cancel_token,
1160                &mut live_rx,
1161                // First-frame watchdog for EVERY placement: a wedged-but-connected
1162                // worker (subscribed ≠ serving — e.g. stuck on a prior LLM call) emits
1163                // no first frame; without a deadline drive() blocks forever. Bounding it
1164                // turns the "running-but-unresponsive" hang into a recoverable
1165                // WorkerUnresponsive (reap+respawn local / re-pick schedulable / error
1166                // on a fixed remote endpoint).
1167                Some(WORKER_FIRST_FRAME_TIMEOUT),
1168            )
1169            .await;
1170            // Unregister IMMEDIATELY: after drive returns nobody consumes live_rx,
1171            // so a send_message landing in the close/park window below must see
1172            // "not live" and take the durable-queue fallback instead of vanishing.
1173            // (Even if one slipped in earlier, send_message also appends it to the
1174            // durable transcript, so the next activation still rehydrates it.)
1175            drop(live_guard);
1176            // Close the parent link (dropping it closes our broker connection; the
1177            // worker stays dialed-in + subscribed, ready for its next Run).
1178            drop(client);
1179
1180            // No first frame ⇒ the worker is wedged. Recover ONCE before giving up:
1181            //   - Local: reap the dead pooled worker + respawn.
1182            //   - Schedulable: not ours to kill — drop it and re-select a live pool
1183            //     member (a wedged worker must not fail the run when the pool has others).
1184            //   - Remote: a FIXED endpoint has no alternative — fall through to a bounded
1185            //     WorkerUnresponsive error (far better than the previous infinite hang).
1186            if attempt == 0 && matches!(result, Err(AgentError::WorkerUnresponsive(_))) {
1187                match kind {
1188                    PlacementKind::Local => {
1189                        tracing::warn!(
1190                        "actor child {} got no first frame; reaping the worker and respawning once",
1191                        job.child_session_id
1192                    );
1193                        actor.worker.kill().await;
1194                        attempt += 1;
1195                        continue;
1196                    }
1197                    PlacementKind::Schedulable => {
1198                        tracing::warn!(
1199                        "scheduled actor child {} got no first frame; re-selecting a pool worker",
1200                        job.child_session_id
1201                    );
1202                        drop(actor);
1203                        attempt += 1;
1204                        continue;
1205                    }
1206                    PlacementKind::Remote => {}
1207                }
1208            }
1209            break (result, actor);
1210        };
1211
1212        // Park the warm worker for reuse on a clean run, or kill it on
1213        // error/cancel (a wedged worker must not be reused). Remote / schedulable
1214        // workers are registry-managed — never ours to pool/kill, just drop.
1215        if remote {
1216            drop(actor);
1217        } else {
1218            match &result {
1219                Ok(_) => self.release_bus_worker(&pool_key, actor).await,
1220                Err(_) => actor.worker.kill().await,
1221            }
1222        }
1223
1224        // Write-back: persist the actor's final reply onto the child session so
1225        // the transcript survives and the NEXT activation sees it as history.
1226        // (run_child_spawn saves the session right after we return.)
1227        match result {
1228            Ok(Some(text)) => {
1229                if !text.is_empty() {
1230                    session.add_message(bamboo_agent_core::Message::assistant(text, None));
1231                }
1232                Ok(())
1233            }
1234            Ok(None) => Ok(()),
1235            Err(e) => Err(e),
1236        }
1237    }
1238}
1239
1240/// The `{kind,host}` placement descriptor stamped onto a child session's metadata
1241/// under `"placement"` — read back by the storage index → `SessionSummary.placement`
1242/// → the UI's machine badge. `None` for `Local` (those fall through to the DTO's
1243/// default of this backend's own host). The value is a JSON string matching
1244/// `bamboo_storage::SessionPlacement { kind, host }`.
1245fn placement_metadata(placement: &Placement, host_label: Option<&str>) -> Option<String> {
1246    // Prefer the cluster node's own label/host (its metadata) when the placement
1247    // maps to a node; else fall back to the raw endpoint host / pool name.
1248    let value = match placement {
1249        Placement::Local => return None,
1250        Placement::Remote { endpoint } => serde_json::json!({
1251            "kind": "remote",
1252            "host": host_label.map(str::to_string).unwrap_or_else(|| host_of_endpoint(endpoint)),
1253        }),
1254        Placement::Schedulable { pool } => serde_json::json!({
1255            "kind": "remote",
1256            "host": host_label.unwrap_or(pool),
1257        }),
1258    };
1259    serde_json::to_string(&value).ok()
1260}
1261
1262/// Extract the host from a `ws[s]://host:port[/path]` bus endpoint, for display.
1263fn host_of_endpoint(endpoint: &str) -> String {
1264    endpoint
1265        .trim()
1266        .trim_start_matches("wss://")
1267        .trim_start_matches("ws://")
1268        .split(['/', ':'])
1269        .next()
1270        .unwrap_or(endpoint)
1271        .to_string()
1272}
1273
1274/// Pump child frames -> parent events until a terminal frame (or cancellation).
1275/// On success, yields the actor's final result text (for session write-back).
1276/// `live_rx` carries in-band frames (steering messages) from the live registry.
1277///
1278/// `escalation_bridge` (#68) is the per-run escalation host bridge CAPTURED BY
1279/// VALUE at spawn time in `execute_external_child` (NOT read live here): when a
1280/// non-bypass child re-proxies an approval request, this owned bridge routes it
1281/// UP to the parent run. Owning it for the call's lifetime is what lets a
1282/// fire-and-forget grandchild that outlives its spawning run still escalate to
1283/// the correct (then-current) parent bridge rather than a stale/overwritten one.
1284async fn drive(
1285    client: &mut dyn bamboo_subagent::ChildLink,
1286    parent_session_id: &str,
1287    child_session_id: &str,
1288    child_attempt: u32,
1289    approval_registry: Option<&super::approval_registry::SharedApprovalRegistry>,
1290    approval_decider: Option<&Arc<dyn ChildApprovalDecider>>,
1291    approval_reviewer: Option<&Arc<dyn ChildApprovalReviewer>>,
1292    escalation_bridge: Option<bamboo_subagent::executor::HostBridge>,
1293    event_tx: &mpsc::Sender<AgentEvent>,
1294    cancel_token: &CancellationToken,
1295    live_rx: &mut mpsc::UnboundedReceiver<ParentFrame>,
1296    first_frame_timeout: Option<Duration>,
1297) -> crate::runtime::runner::Result<Option<String>> {
1298    // First-frame watchdog: a live worker emits its first frame (run-started /
1299    // first token) within seconds; total silence past the deadline means the
1300    // worker is dead (e.g. a pooled worker that exited right after checkout), so
1301    // its Run sits queued forever. We trip ONLY before the first frame — once any
1302    // frame arrives the worker is proven live and a legitimately long run (a slow
1303    // tool between tokens) never trips it.
1304    let mut got_first_frame = false;
1305    let mut first_frame_watch = first_frame_timeout.map(|d| Box::pin(tokio::time::sleep(d)));
1306    loop {
1307        tokio::select! {
1308            _ = cancel_token.cancelled() => {
1309                // fall through to the cancel handling below
1310                break;
1311            }
1312            _ = async {
1313                match first_frame_watch.as_mut() {
1314                    Some(s) => s.as_mut().await,
1315                    None => std::future::pending::<()>().await,
1316                }
1317            }, if !got_first_frame => {
1318                return Err(AgentError::WorkerUnresponsive(format!(
1319                    "child {child_session_id} produced no frame within {:?}",
1320                    first_frame_timeout.unwrap_or_default()
1321                )));
1322            }
1323            Some(frame) = live_rx.recv() => {
1324                // Forward in-band steering to the worker over the existing WS.
1325                if client.send(frame).await.is_err() {
1326                    tracing::warn!("live steering frame could not be sent; connection failing");
1327                }
1328            }
1329            frame = client.next_frame() => {
1330                // Any frame (event / approval / terminal / close / error) proves
1331                // the worker responded — disarm the first-frame watchdog.
1332                got_first_frame = true;
1333                first_frame_watch = None;
1334                match frame {
1335                    Ok(Some(ChildFrame::Event { event })) => {
1336                        // AgentEvent is serialized verbatim on the wire (zero mapping).
1337                        if let Ok(ev) = serde_json::from_value::<AgentEvent>(event) {
1338                            let _ = event_tx.send(ev).await;
1339                        }
1340                    }
1341                    Ok(Some(ChildFrame::ApprovalRequest { id, body })) => {
1342                        // Phase 2: a worker proxied a gated-tool approval back to
1343                        // the host. The WORKER side is live — its executor installs
1344                        // a per-run task-local `ApprovalProxy` (subagent_worker.rs)
1345                        // that calls `host.approval_call`, so this frame arrives
1346                        // when a child hits `ConfirmationRequired`.
1347                        if let Some(reviewer) = approval_reviewer
1348                            .cloned()
1349                            .or_else(child_approval_reviewer)
1350                        {
1351                            // Phase 6, Part B: a BYPASSED parent worker
1352                            // model-reviews its children's forced-ask (dangerous)
1353                            // actions. The review is an LLM call, so run it OFF
1354                            // the frame pump in a spawned task and deliver the
1355                            // verdict async via the live channel — the pump keeps
1356                            // forwarding events and the agent loop never blocks. A
1357                            // timeout denies a hung review so the child can't hang.
1358                            let child = child_session_id.to_string();
1359                            let parent = parent_session_id.to_string();
1360                            let req_id = id.clone();
1361                            let body = body.clone();
1362                            let registry = approval_registry.cloned();
1363                            tokio::spawn(async move {
1364                                let approved = tokio::time::timeout(
1365                                    CHILD_APPROVAL_TIMEOUT,
1366                                    reviewer.review(&parent, &child, &body),
1367                                )
1368                                .await
1369                                .unwrap_or(false);
1370                                super::live::deliver_approval_scoped(
1371                                    registry.as_ref(),
1372                                    &child,
1373                                    child_attempt,
1374                                    &req_id,
1375                                    approved,
1376                                );
1377                            });
1378                        } else if approval_decider.is_some() {
1379                            // A decider is wired (policy / auto): decide promptly
1380                            // and reply inline. (Must not block the pump — see the
1381                            // `ChildApprovalDecider` doc.)
1382                            let approved =
1383                                decide_child_approval(approval_decider, child_session_id, &body)
1384                                    .await;
1385                            if client
1386                                .send(ParentFrame::ApprovalReply { id, approved })
1387                                .await
1388                                .is_err()
1389                            {
1390                                tracing::warn!(
1391                                    "failed to answer approval_request; connection failing"
1392                                );
1393                            }
1394                        } else if let Some(host) = escalation_bridge.clone() {
1395                            // Non-bypass WORKER: ESCALATE up our own actor link
1396                            // (re-proxy) so the request chains to our parent — and
1397                            // up every level until a bypass level or the top
1398                            // orchestrator's model reviewer decides. With no such
1399                            // reviewer the top level fails closed. Off-loop so the
1400                            // pump never blocks; relay the reply down to the child.
1401                            let child = child_session_id.to_string();
1402                            let req_id = id.clone();
1403                            let body = body.clone();
1404                            let registry = approval_registry.cloned();
1405                            tokio::spawn(async move {
1406                                let approved = match tokio::time::timeout(
1407                                    CHILD_APPROVAL_TIMEOUT,
1408                                    host.approval_call(body),
1409                                )
1410                                .await
1411                                {
1412                                    Ok(Ok(reply)) => reply
1413                                        .get("approved")
1414                                        .and_then(|v| v.as_bool())
1415                                        .unwrap_or(false),
1416                                    // Transport error or timeout ⇒ fail closed.
1417                                    _ => false,
1418                                };
1419                                super::live::deliver_approval_scoped(
1420                                    registry.as_ref(),
1421                                    &child,
1422                                    child_attempt,
1423                                    &req_id,
1424                                    approved,
1425                                );
1426                            });
1427                        } else {
1428                            // There is no parent-agent reviewer or upstream actor
1429                            // to own this decision. Never open a manual/UI approval
1430                            // path: forced-ask is parent-reviewed or fail-closed.
1431                            tracing::warn!(
1432                                parent_session_id,
1433                                child_session_id,
1434                                request_id = %id,
1435                                "forced-ask request has no parent-agent reviewer; denying"
1436                            );
1437                            if client
1438                                .send(ParentFrame::ApprovalReply {
1439                                    id,
1440                                    approved: false,
1441                                })
1442                                .await
1443                                .is_err()
1444                            {
1445                                tracing::warn!(
1446                                    "failed to send fail-closed approval reply; connection failing"
1447                                );
1448                            }
1449                        }
1450                    }
1451                    Ok(Some(ChildFrame::Terminal { status, result, error, .. })) => {
1452                        return match status {
1453                            TerminalStatus::Completed => Ok(result),
1454                            TerminalStatus::Cancelled => Err(AgentError::Cancelled),
1455                            TerminalStatus::Error => Err(AgentError::LLM(
1456                                error.unwrap_or_else(|| "actor child errored".to_string()),
1457                            )),
1458                            // The suspend/resume round-trip (host re-dispatch of a
1459                            // nested parent) is not wired here yet; a worker in
1460                            // this build never emits Suspended, so this is
1461                            // unreachable in practice.
1462                            TerminalStatus::Suspended => Err(AgentError::LLM(
1463                                "nested sub-agent suspend received but resume transport is not wired"
1464                                    .to_string(),
1465                            )),
1466                        };
1467                    }
1468                    Ok(None) => {
1469                        return Err(AgentError::LLM(
1470                            "actor child closed before terminal".to_string(),
1471                        ));
1472                    }
1473                    Err(e) => {
1474                        return Err(AgentError::LLM(format!("actor transport error: {e}")));
1475                    }
1476                }
1477            }
1478        }
1479    }
1480
1481    // Only reached on cancellation: ask the child to stop (best-effort), then report cancelled.
1482    let _ = client.send(ParentFrame::Cancel).await;
1483    Err(AgentError::Cancelled)
1484}
1485
1486/// The assignment text = the child session's latest user message (falls back to its title).
1487fn project_id_for_actor_run(
1488    session: &Session,
1489) -> Result<Option<bamboo_domain::ProjectId>, AgentError> {
1490    match crate::project_context::ProjectContextResolver::session_project_identity(session) {
1491        crate::project_context::SessionProjectIdentity::Assigned(project_id) => {
1492            Ok(Some(project_id))
1493        }
1494        crate::project_context::SessionProjectIdentity::Unassigned => Ok(None),
1495        crate::project_context::SessionProjectIdentity::Invalid { raw, message } => {
1496            Err(AgentError::LLM(format!(
1497                "child session carries an invalid Project identity '{raw}': {message}"
1498            )))
1499        }
1500    }
1501}
1502
1503fn extract_assignment(session: &Session) -> String {
1504    session
1505        .messages
1506        .iter()
1507        .rev()
1508        .find(|m| matches!(m.role, Role::User))
1509        .map(|m| m.content.clone())
1510        .unwrap_or_else(|| {
1511            session
1512                .metadata
1513                .get("title")
1514                .cloned()
1515                .unwrap_or_else(|| "Execute task".to_string())
1516        })
1517}
1518
1519#[cfg(test)]
1520mod tests {
1521    use super::*;
1522
1523    #[derive(Default)]
1524    struct RecordingCodexTokenAuthority {
1525        issued_for: std::sync::Mutex<Vec<String>>,
1526        revoked: std::sync::Mutex<Vec<String>>,
1527    }
1528
1529    impl CodexRunTokenAuthority for RecordingCodexTokenAuthority {
1530        fn issue(&self, session_id: &str) -> Result<IssuedCodexRunToken, String> {
1531            self.issued_for
1532                .lock()
1533                .expect("issued fixture lock")
1534                .push(session_id.to_string());
1535            Ok(IssuedCodexRunToken {
1536                token_id: format!("id-{session_id}"),
1537                token: format!("bcx1_secret-{session_id}"),
1538            })
1539        }
1540
1541        fn revoke(&self, token_id: &str) {
1542            self.revoked
1543                .lock()
1544                .expect("revoked fixture lock")
1545                .push(token_id.to_string());
1546        }
1547    }
1548
1549    fn codex_executor(auth_mode: Option<&str>, inherit_user_config: Option<bool>) -> ExecutorSpec {
1550        let bamboo_mode = auth_mode == Some("bamboo")
1551            || (auth_mode.is_none() && !inherit_user_config.unwrap_or(false));
1552        ExecutorSpec::Codex {
1553            binary: None,
1554            model: None,
1555            mode: None,
1556            sandbox: None,
1557            inherit_user_config,
1558            auth_mode: auth_mode.map(str::to_string),
1559            base_url: bamboo_mode.then(|| "http://127.0.0.1:9562/openai/v1".to_string()),
1560            wire_api: Some("responses".to_string()),
1561            provider_key_ref: None,
1562            forward_env: None,
1563            approval_policy: None,
1564            network_access: None,
1565            allow_danger_bypass: None,
1566            permission_profile: None,
1567            workspace_owned: None,
1568        }
1569    }
1570
1571    #[test]
1572    fn only_bamboo_managed_non_git_workspaces_are_marked_owned() {
1573        let project = tempfile::tempdir().unwrap();
1574        let managed = project.path().join(".bamboo/worktree/child-571");
1575        std::fs::create_dir_all(&managed).unwrap();
1576        assert!(!workspace_is_bamboo_owned(managed.to_str().unwrap()));
1577        let marker = project
1578            .path()
1579            .join(".bamboo/worktree/.bamboo-owned/child-571");
1580        std::fs::create_dir_all(marker.parent().unwrap()).unwrap();
1581        std::fs::write(&marker, "bamboo/child-571").unwrap();
1582        assert!(workspace_is_bamboo_owned(managed.to_str().unwrap()));
1583        let nested = managed.join("nested/path");
1584        std::fs::create_dir_all(&nested).unwrap();
1585        assert!(workspace_is_bamboo_owned(nested.to_str().unwrap()));
1586
1587        let arbitrary = tempfile::tempdir().unwrap();
1588        assert!(!workspace_is_bamboo_owned(
1589            arbitrary.path().to_str().unwrap()
1590        ));
1591    }
1592
1593    #[test]
1594    fn bamboo_codex_token_is_per_run_redacted_and_revoked_on_guard_drop() {
1595        let authority = Arc::new(RecordingCodexTokenAuthority::default());
1596        let authority_dyn: Arc<dyn CodexRunTokenAuthority> = authority.clone();
1597
1598        let (secrets, guard) = build_codex_run_secrets(
1599            &codex_executor(Some("bamboo"), None),
1600            Some(authority_dyn),
1601            "child-570",
1602        )
1603        .unwrap();
1604
1605        let token = secrets
1606            .codex_provider_token
1607            .as_ref()
1608            .expect("bamboo mode mints a token");
1609        assert_eq!(token.expose(), "bcx1_secret-child-570");
1610        assert!(!format!("{token:?}").contains("secret-child-570"));
1611        assert_eq!(
1612            authority.issued_for.lock().unwrap().as_slice(),
1613            ["child-570"]
1614        );
1615        assert!(authority.revoked.lock().unwrap().is_empty());
1616
1617        drop(guard);
1618        assert_eq!(
1619            authority.revoked.lock().unwrap().as_slice(),
1620            ["id-child-570"]
1621        );
1622    }
1623
1624    #[test]
1625    fn non_bamboo_codex_never_mints_and_bamboo_fails_closed_without_authority() {
1626        let authority = Arc::new(RecordingCodexTokenAuthority::default());
1627        let authority_dyn: Arc<dyn CodexRunTokenAuthority> = authority.clone();
1628        let (secrets, guard) = build_codex_run_secrets(
1629            &codex_executor(Some("custom"), None),
1630            Some(authority_dyn),
1631            "child-custom",
1632        )
1633        .unwrap();
1634        assert!(secrets.codex_provider_token.is_none());
1635        assert!(guard.is_none());
1636        assert!(authority.issued_for.lock().unwrap().is_empty());
1637
1638        let error = build_codex_run_secrets(
1639            &codex_executor(Some("bamboo"), None),
1640            None,
1641            "child-no-authority",
1642        )
1643        .err()
1644        .expect("bamboo mode without an authority must fail closed");
1645        assert!(error.to_string().contains("per-run token authority"));
1646    }
1647
1648    #[test]
1649    fn codex_provisioning_never_leaks_the_session_provider_credential() {
1650        let credentials = vec![ScopedCredential {
1651            provider: "openai".to_string(),
1652            api_key: "upstream-secret-must-not-cross".to_string(),
1653            base_url: None,
1654            provider_type: Some("openai".to_string()),
1655            credential_ref: Some("provider.openai.api_key".to_string()),
1656        }];
1657
1658        for (mode, label) in [
1659            (Some("inherit"), "inherit"),
1660            (Some("api_key"), "api_key"),
1661            (Some("bamboo"), "bamboo"),
1662            (None, "default-bamboo"),
1663        ] {
1664            let runner = ActorChildRunner::new(
1665                format!("codex-{label}-test"),
1666                PathBuf::from("/bin/false"),
1667                Vec::new(),
1668                std::env::temp_dir().join(format!("bamboo-codex-{label}-570")),
1669                codex_executor(mode, None),
1670                credentials.clone(),
1671                "openai".to_string(),
1672                1,
1673            );
1674            let mut session = Session::new(format!("child-{label}"), "model");
1675            session.add_message(bamboo_agent_core::Message::user("test"));
1676            let spec = runner.build_spec(
1677                &session,
1678                &crate::runtime::execution::SpawnJob {
1679                    parent_session_id: "parent".to_string(),
1680                    child_session_id: format!("child-{label}"),
1681                    model: "gpt-5.4".to_string(),
1682                    disabled_tools: None,
1683                },
1684            );
1685            assert!(
1686                spec.secrets.provider_credentials.is_empty(),
1687                "{label} Codex must not receive the session provider key"
1688            );
1689        }
1690    }
1691
1692    #[test]
1693    fn non_codex_provisioning_still_receives_only_its_selected_provider_credential() {
1694        let credentials = vec![
1695            ScopedCredential {
1696                provider: "openai".to_string(),
1697                api_key: "selected-openai-secret".to_string(),
1698                base_url: None,
1699                provider_type: Some("openai".to_string()),
1700                credential_ref: Some("provider.openai.api_key".to_string()),
1701            },
1702            ScopedCredential {
1703                provider: "other".to_string(),
1704                api_key: "unrelated-secret".to_string(),
1705                base_url: None,
1706                provider_type: Some("openai".to_string()),
1707                credential_ref: Some("provider.other.api_key".to_string()),
1708            },
1709        ];
1710        let runner = ActorChildRunner::new(
1711            "echo-test".to_string(),
1712            PathBuf::from("/bin/false"),
1713            Vec::new(),
1714            std::env::temp_dir().join("bamboo-echo-provider-570"),
1715            ExecutorSpec::Echo,
1716            credentials,
1717            "openai".to_string(),
1718            1,
1719        );
1720        let spec = runner.build_spec(
1721            &Session::new("child-echo", "model"),
1722            &crate::runtime::execution::SpawnJob {
1723                parent_session_id: "parent".to_string(),
1724                child_session_id: "child-echo".to_string(),
1725                model: "gpt-5.4".to_string(),
1726                disabled_tools: None,
1727            },
1728        );
1729
1730        assert_eq!(spec.secrets.provider_credentials.len(), 1);
1731        assert_eq!(
1732            spec.secrets.provider_credentials[0].api_key,
1733            "selected-openai-secret"
1734        );
1735    }
1736
1737    #[test]
1738    fn custom_codex_provisioning_scopes_only_the_referenced_credential() {
1739        let mut executor = codex_executor(Some("custom"), None);
1740        if let ExecutorSpec::Codex {
1741            base_url,
1742            provider_key_ref,
1743            ..
1744        } = &mut executor
1745        {
1746            *base_url = Some("https://provider.example/v1".to_string());
1747            *provider_key_ref = Some("provider.custom.api_key".to_string());
1748        }
1749        let credentials = vec![
1750            ScopedCredential {
1751                provider: "openai".to_string(),
1752                api_key: "session-provider-secret".to_string(),
1753                base_url: None,
1754                provider_type: Some("openai".to_string()),
1755                credential_ref: Some("provider.openai.api_key".to_string()),
1756            },
1757            ScopedCredential {
1758                provider: "custom".to_string(),
1759                api_key: "selected-secret".to_string(),
1760                base_url: None,
1761                provider_type: Some("openai".to_string()),
1762                credential_ref: Some("provider.custom.api_key".to_string()),
1763            },
1764            ScopedCredential {
1765                provider: "other".to_string(),
1766                api_key: "unrelated-secret".to_string(),
1767                base_url: None,
1768                provider_type: Some("openai".to_string()),
1769                credential_ref: Some("provider.other.api_key".to_string()),
1770            },
1771        ];
1772        let runner = ActorChildRunner::new(
1773            "codex-test".to_string(),
1774            PathBuf::from("/bin/false"),
1775            Vec::new(),
1776            std::env::temp_dir().join("bamboo-codex-570"),
1777            executor,
1778            credentials,
1779            "openai".to_string(),
1780            1,
1781        );
1782        let mut session = Session::new("child-custom", "model");
1783        session.add_message(bamboo_agent_core::Message::user("test"));
1784        let spec = runner.build_spec(
1785            &session,
1786            &crate::runtime::execution::SpawnJob {
1787                parent_session_id: "parent".to_string(),
1788                child_session_id: "child-custom".to_string(),
1789                model: "gpt-5.4".to_string(),
1790                disabled_tools: None,
1791            },
1792        );
1793
1794        assert_eq!(spec.secrets.provider_credentials.len(), 1);
1795        assert_eq!(
1796            spec.secrets.provider_credentials[0]
1797                .credential_ref
1798                .as_deref(),
1799            Some("provider.custom.api_key")
1800        );
1801        assert_eq!(
1802            spec.secrets.provider_credentials[0].api_key,
1803            "selected-secret"
1804        );
1805    }
1806
1807    fn spec_with(
1808        role: &str,
1809        provider: &str,
1810        model: &str,
1811        workspace: Option<&str>,
1812        disabled: Option<Vec<&str>>,
1813    ) -> ProvisionSpec {
1814        let mut spec = ProvisionSpec::new(
1815            ChildIdentity {
1816                child_id: "c".into(),
1817                parent_id: None,
1818                project_key: None,
1819                role: role.into(),
1820                depth: 0,
1821            },
1822            ExecutorSpec::Echo,
1823            "/tmp/fab".into(),
1824        );
1825        spec.workspace = workspace.map(|w| w.to_string());
1826        spec.model = Some(ModelRefSpec {
1827            provider: provider.into(),
1828            model: model.into(),
1829        });
1830        spec.disabled_tools = disabled.map(|d| d.into_iter().map(String::from).collect());
1831        spec
1832    }
1833
1834    #[test]
1835    fn fingerprint_matches_interchangeable_children() {
1836        // Same role/provider/model/workspace and equal tool sets (order-insensitive)
1837        // are interchangeable on one warm worker — and differ only in child_id.
1838        let a = spec_with(
1839            "explorer",
1840            "p",
1841            "m",
1842            Some("/ws"),
1843            Some(vec!["Bash", "Edit"]),
1844        );
1845        let mut b = spec_with(
1846            "explorer",
1847            "p",
1848            "m",
1849            Some("/ws"),
1850            Some(vec!["Edit", "Bash"]),
1851        );
1852        b.identity.child_id = "other".into();
1853        assert_eq!(
1854            ActorChildRunner::fingerprint(&a),
1855            ActorChildRunner::fingerprint(&b)
1856        );
1857    }
1858
1859    #[test]
1860    fn fingerprint_separates_distinct_runtimes() {
1861        let base = spec_with("explorer", "p", "m", Some("/ws"), None);
1862        let base_fp = ActorChildRunner::fingerprint(&base);
1863        // Each axis that is baked into the worker must split the pool bucket.
1864        assert_ne!(
1865            base_fp,
1866            ActorChildRunner::fingerprint(&spec_with("writer", "p", "m", Some("/ws"), None))
1867        );
1868        assert_ne!(
1869            base_fp,
1870            ActorChildRunner::fingerprint(&spec_with("explorer", "p2", "m", Some("/ws"), None))
1871        );
1872        assert_ne!(
1873            base_fp,
1874            ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m2", Some("/ws"), None))
1875        );
1876        assert_ne!(
1877            base_fp,
1878            ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m", Some("/ws2"), None))
1879        );
1880        assert_ne!(
1881            base_fp,
1882            ActorChildRunner::fingerprint(&spec_with(
1883                "explorer",
1884                "p",
1885                "m",
1886                Some("/ws"),
1887                Some(vec!["Bash"])
1888            ))
1889        );
1890    }
1891
1892    #[test]
1893    fn fingerprint_splits_on_baked_capabilities() {
1894        // Every capability baked once at provision time must split the pool
1895        // bucket, else a worker baked for one posture gets reused for another
1896        // (e.g. a depth-1 worker re-stamping spawn_depth onto a depth-4 child,
1897        // breaking the depth cap; or a bypass worker reused for a non-bypass one).
1898        let base_fp =
1899            ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m", Some("/ws"), None));
1900
1901        let mut depth = spec_with("explorer", "p", "m", Some("/ws"), None);
1902        depth.identity.depth = 2;
1903        assert_ne!(
1904            base_fp,
1905            ActorChildRunner::fingerprint(&depth),
1906            "depth must split"
1907        );
1908
1909        let mut nested = spec_with("explorer", "p", "m", Some("/ws"), None);
1910        nested.capabilities.nested_spawn = true;
1911        assert_ne!(
1912            base_fp,
1913            ActorChildRunner::fingerprint(&nested),
1914            "nested_spawn must split"
1915        );
1916
1917        let mut bypass = spec_with("explorer", "p", "m", Some("/ws"), None);
1918        bypass.capabilities.bypass = true;
1919        assert_ne!(
1920            base_fp,
1921            ActorChildRunner::fingerprint(&bypass),
1922            "bypass must split"
1923        );
1924
1925        let mut enforce = spec_with("explorer", "p", "m", Some("/ws"), None);
1926        enforce.capabilities.enforce_permissions = true;
1927        assert_ne!(
1928            base_fp,
1929            ActorChildRunner::fingerprint(&enforce),
1930            "enforce_permissions must split"
1931        );
1932
1933        let mut cap = spec_with("explorer", "p", "m", Some("/ws"), None);
1934        cap.capabilities.max_spawn_depth = Some(8);
1935        assert_ne!(
1936            base_fp,
1937            ActorChildRunner::fingerprint(&cap),
1938            "max_spawn_depth must split"
1939        );
1940
1941        // #73 (P1): the worker bakes `no_human_review` from this flag once at
1942        // build(), so it MUST split the pool or a worker baked for one approval
1943        // posture is reused for the opposite one.
1944        let mut nha = spec_with("explorer", "p", "m", Some("/ws"), None);
1945        nha.capabilities.no_human_approver = true;
1946        assert_ne!(
1947            base_fp,
1948            ActorChildRunner::fingerprint(&nha),
1949            "no_human_approver must split"
1950        );
1951
1952        // #71: the read-only Bash checker is baked once at build() from this flag,
1953        // so a guardian reviewer worker must not be reused for an ordinary child.
1954        let mut gro = spec_with("explorer", "p", "m", Some("/ws"), None);
1955        gro.capabilities.guardian_read_only = true;
1956        assert_ne!(
1957            base_fp,
1958            ActorChildRunner::fingerprint(&gro),
1959            "guardian_read_only must split"
1960        );
1961    }
1962
1963    #[test]
1964    fn fingerprint_splits_codex_exec_and_app_server_workers() {
1965        let mut exec = spec_with("explorer", "p", "m", Some("/ws"), None);
1966        exec.executor = codex_executor(Some("inherit"), None);
1967        let mut app_server = exec.clone();
1968        if let ExecutorSpec::Codex { mode, .. } = &mut app_server.executor {
1969            *mode = Some("app_server".to_string());
1970        }
1971        assert_ne!(
1972            ActorChildRunner::fingerprint(&exec),
1973            ActorChildRunner::fingerprint(&app_server)
1974        );
1975    }
1976
1977    struct StaticDecider(bool);
1978
1979    #[async_trait]
1980    impl ChildApprovalDecider for StaticDecider {
1981        async fn decide(&self, _child: &str, _req: &serde_json::Value) -> bool {
1982            self.0
1983        }
1984    }
1985
1986    struct RecordingReviewer {
1987        reviewed: mpsc::UnboundedSender<(String, String, serde_json::Value)>,
1988    }
1989
1990    #[async_trait]
1991    impl ChildApprovalReviewer for RecordingReviewer {
1992        async fn review(&self, parent: &str, child: &str, request: &serde_json::Value) -> bool {
1993            let _ = self
1994                .reviewed
1995                .send((parent.to_string(), child.to_string(), request.clone()));
1996            true
1997        }
1998    }
1999
2000    // ---- first-frame watchdog (dead-pooled-worker recovery) -----------------
2001
2002    /// A link that never yields a frame — models a worker that died (or never
2003    /// subscribed) so its Run sits queued with no server.
2004    struct SilentLink;
2005    #[async_trait]
2006    impl bamboo_subagent::ChildLink for SilentLink {
2007        async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
2008            Ok(())
2009        }
2010        async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
2011            std::future::pending().await
2012        }
2013    }
2014
2015    /// A link that immediately yields one terminal frame (a healthy fast worker).
2016    struct InstantTerminalLink {
2017        done: bool,
2018    }
2019
2020    struct ApprovalRoundTripLink {
2021        step: u8,
2022        approval_reply: Option<(String, bool)>,
2023    }
2024
2025    #[async_trait]
2026    impl bamboo_subagent::ChildLink for ApprovalRoundTripLink {
2027        async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
2028            if let ParentFrame::ApprovalReply { id, approved } = frame {
2029                self.approval_reply = Some((id, approved));
2030                self.step = 2;
2031            }
2032            Ok(())
2033        }
2034
2035        async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
2036            match self.step {
2037                0 => {
2038                    self.step = 1;
2039                    Ok(Some(ChildFrame::ApprovalRequest {
2040                        id: "approval-1".into(),
2041                        body: serde_json::json!({
2042                            "tool_name": "Bash",
2043                            "permission": "execute",
2044                            "resource": "rm -rf target",
2045                            "permission_request": {"reason_code": "hard_dangerous"}
2046                        }),
2047                    }))
2048                }
2049                1 => std::future::pending().await,
2050                2 => {
2051                    self.step = 3;
2052                    Ok(Some(ChildFrame::Terminal {
2053                        status: TerminalStatus::Completed,
2054                        result: Some("done".into()),
2055                        error: None,
2056                        transcript: vec![],
2057                    }))
2058                }
2059                _ => std::future::pending().await,
2060            }
2061        }
2062    }
2063
2064    #[tokio::test]
2065    async fn drive_routes_forced_ask_to_parent_reviewer_without_human_event() {
2066        let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(8);
2067        let (review_tx, mut review_rx) = mpsc::unbounded_channel();
2068        let reviewer: Arc<dyn ChildApprovalReviewer> = Arc::new(RecordingReviewer {
2069            reviewed: review_tx,
2070        });
2071        let cancel = CancellationToken::new();
2072        let (live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
2073        let live_guard = crate::external_agents::live::register("child-reviewer", live_tx, 0, None);
2074        let mut link = ApprovalRoundTripLink {
2075            step: 0,
2076            approval_reply: None,
2077        };
2078
2079        let result = tokio::time::timeout(
2080            Duration::from_secs(1),
2081            drive(
2082                &mut link,
2083                "parent-reviewer",
2084                "child-reviewer",
2085                0,
2086                None,
2087                None,
2088                Some(&reviewer),
2089                None,
2090                &event_tx,
2091                &cancel,
2092                &mut live_rx,
2093                None,
2094            ),
2095        )
2096        .await
2097        .expect("worker must receive the reviewer verdict before terminating");
2098
2099        assert_eq!(result.ok().flatten().as_deref(), Some("done"));
2100        assert_eq!(
2101            link.approval_reply,
2102            Some(("approval-1".to_string(), true)),
2103            "reviewer verdict must traverse the live route back to the worker"
2104        );
2105        let (parent, child, body) = tokio::time::timeout(Duration::from_secs(1), review_rx.recv())
2106            .await
2107            .expect("reviewer should be invoked off-loop")
2108            .expect("review channel should remain open");
2109        assert_eq!(parent, "parent-reviewer");
2110        assert_eq!(child, "child-reviewer");
2111        assert_eq!(
2112            body.pointer("/permission_request/reason_code")
2113                .and_then(serde_json::Value::as_str),
2114            Some("hard_dangerous")
2115        );
2116        assert!(
2117            event_rx.try_recv().is_err(),
2118            "must not emit a human-review event"
2119        );
2120        drop(live_guard);
2121    }
2122
2123    #[tokio::test]
2124    async fn drive_denies_forced_ask_without_parent_reviewer_or_manual_event() {
2125        let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(8);
2126        let cancel = CancellationToken::new();
2127        let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
2128        let mut link = ApprovalRoundTripLink {
2129            step: 0,
2130            approval_reply: None,
2131        };
2132
2133        let result = tokio::time::timeout(
2134            Duration::from_secs(1),
2135            drive(
2136                &mut link,
2137                "parent-no-reviewer",
2138                "child-no-reviewer",
2139                0,
2140                None,
2141                None,
2142                None,
2143                None,
2144                &event_tx,
2145                &cancel,
2146                &mut live_rx,
2147                None,
2148            ),
2149        )
2150        .await
2151        .expect("fail-closed reply must unblock the child immediately");
2152
2153        assert_eq!(result.ok().flatten().as_deref(), Some("done"));
2154        assert_eq!(link.approval_reply, Some(("approval-1".to_string(), false)));
2155        assert!(
2156            event_rx.try_recv().is_err(),
2157            "missing parent review must not surface a manual approval event"
2158        );
2159    }
2160    #[async_trait]
2161    impl bamboo_subagent::ChildLink for InstantTerminalLink {
2162        async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
2163            Ok(())
2164        }
2165        async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
2166            if self.done {
2167                std::future::pending().await
2168            } else {
2169                self.done = true;
2170                Ok(Some(ChildFrame::Terminal {
2171                    status: TerminalStatus::Completed,
2172                    result: Some("done".into()),
2173                    error: None,
2174                    transcript: vec![],
2175                }))
2176            }
2177        }
2178    }
2179
2180    #[tokio::test]
2181    async fn drive_trips_first_frame_watchdog_on_a_silent_worker() {
2182        let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
2183        let cancel = CancellationToken::new();
2184        let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
2185        let mut link = SilentLink;
2186        let r = drive(
2187            &mut link,
2188            "parent-x",
2189            "child-x",
2190            0,
2191            None,
2192            None,
2193            None,
2194            None,
2195            &event_tx,
2196            &cancel,
2197            &mut live_rx,
2198            Some(Duration::from_millis(100)),
2199        )
2200        .await;
2201        assert!(
2202            matches!(r, Err(AgentError::WorkerUnresponsive(_))),
2203            "a silent worker must trip the first-frame watchdog, got {r:?}"
2204        );
2205    }
2206
2207    #[tokio::test]
2208    async fn drive_does_not_trip_when_a_frame_arrives() {
2209        let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
2210        let cancel = CancellationToken::new();
2211        let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
2212        let mut link = InstantTerminalLink { done: false };
2213        // Even a tiny timeout must NOT trip: the terminal frame arrives first and
2214        // disarms the watchdog.
2215        let r = drive(
2216            &mut link,
2217            "parent-y",
2218            "child-y",
2219            0,
2220            None,
2221            None,
2222            None,
2223            None,
2224            &event_tx,
2225            &cancel,
2226            &mut live_rx,
2227            Some(Duration::from_millis(50)),
2228        )
2229        .await;
2230        assert_eq!(r.ok().flatten().as_deref(), Some("done"));
2231    }
2232
2233    #[tokio::test]
2234    async fn child_approval_fails_closed_without_decider() {
2235        // No decider wired ⇒ the host denies (safe default), unchanged behavior.
2236        let body = serde_json::json!({"tool_name":"Bash","permission":"run","resource":"rm -rf /"});
2237        assert!(!decide_child_approval(None, "child-1", &body).await);
2238    }
2239
2240    #[tokio::test]
2241    async fn child_approval_honors_wired_decider() {
2242        let body =
2243            serde_json::json!({"tool_name":"Write","permission":"write","resource":"/tmp/x"});
2244        let approve: Arc<dyn ChildApprovalDecider> = Arc::new(StaticDecider(true));
2245        let deny: Arc<dyn ChildApprovalDecider> = Arc::new(StaticDecider(false));
2246        assert!(decide_child_approval(Some(&approve), "child-1", &body).await);
2247        assert!(!decide_child_approval(Some(&deny), "child-1", &body).await);
2248    }
2249
2250    // ---- #193: remote placement routing -------------------------------------
2251
2252    use crate::runtime::execution::SpawnJob;
2253    use bamboo_agent_core::Session;
2254
2255    /// A runner with a BOGUS worker_bin (`/bin/false`): a local spawn here would
2256    /// FAIL, so a passing remote test proves the remote path never spawns.
2257    fn bogus_runner(placements: HashMap<String, ResolvedRemotePlacement>) -> ActorChildRunner {
2258        ActorChildRunner::new(
2259            "test-actor".into(),
2260            PathBuf::from("/bin/false"),
2261            vec![],
2262            std::env::temp_dir().join("bamboo-test-fab-193"),
2263            ExecutorSpec::Echo,
2264            vec![],
2265            "anthropic".into(),
2266            4,
2267        )
2268        .with_remote_placements(placements)
2269    }
2270
2271    /// A child session of the given role (the role rides `subagent_type`, the
2272    /// path build_spec + the remote lookup both read).
2273    fn session_of_role(role: &str, assignment: &str) -> Session {
2274        let mut s = Session::new("child-1", "test-model");
2275        s.metadata
2276            .insert("subagent_type".to_string(), role.to_string());
2277        s.add_message(bamboo_agent_core::Message::user(assignment));
2278        s
2279    }
2280
2281    fn job_for(child: &str) -> SpawnJob {
2282        SpawnJob {
2283            parent_session_id: "parent-1".into(),
2284            child_session_id: child.into(),
2285            model: String::new(),
2286            disabled_tools: None,
2287        }
2288    }
2289
2290    #[derive(Default)]
2291    struct RecordingChildSessionPort {
2292        saved: std::sync::Mutex<Option<Session>>,
2293    }
2294
2295    impl RecordingChildSessionPort {
2296        fn saved_child(&self) -> Session {
2297            self.saved
2298                .lock()
2299                .expect("saved-child fixture lock")
2300                .clone()
2301                .expect("create_child_action must save the child")
2302        }
2303    }
2304
2305    #[async_trait]
2306    impl crate::session_app::child_session::ChildSessionPort for RecordingChildSessionPort {
2307        async fn load_root_session(
2308            &self,
2309            _root_id: &str,
2310        ) -> Result<Session, crate::session_app::child_session::ChildSessionError> {
2311            unreachable!("create_child_action does not load the root")
2312        }
2313
2314        async fn load_child_for_parent(
2315            &self,
2316            _parent_id: &str,
2317            _child_id: &str,
2318        ) -> Result<Session, crate::session_app::child_session::ChildSessionError> {
2319            unreachable!("create_child_action does not reload the child")
2320        }
2321
2322        async fn save_child_session(
2323            &self,
2324            child: &mut Session,
2325        ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
2326            *self.saved.lock().expect("saved-child fixture lock") = Some(child.clone());
2327            Ok(())
2328        }
2329
2330        async fn save_child_session_authoritative_flags(
2331            &self,
2332            _child: &mut Session,
2333        ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
2334            unreachable!("new-child creation uses the ordinary save")
2335        }
2336
2337        async fn is_child_running(&self, _child_id: &str) -> bool {
2338            false
2339        }
2340
2341        async fn list_children(
2342            &self,
2343            _parent_id: &str,
2344        ) -> Vec<crate::session_app::child_session::ChildSessionEntry> {
2345            Vec::new()
2346        }
2347
2348        async fn enqueue_child_run(
2349            &self,
2350            _parent: &Session,
2351            _child: &Session,
2352        ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
2353            unreachable!("fixture creates the child with auto_run=false")
2354        }
2355
2356        async fn cancel_child_run_and_wait(
2357            &self,
2358            _child_id: &str,
2359        ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
2360            unreachable!("create_child_action does not cancel")
2361        }
2362
2363        async fn delete_child_session(
2364            &self,
2365            _parent_id: &str,
2366            _child_id: &str,
2367        ) -> Result<
2368            crate::session_app::child_session::DeleteChildResult,
2369            crate::session_app::child_session::ChildSessionError,
2370        > {
2371            unreachable!("create_child_action does not delete")
2372        }
2373
2374        async fn get_child_runner_info(
2375            &self,
2376            _child_id: &str,
2377        ) -> Option<crate::session_app::child_session::ChildRunnerInfo> {
2378            None
2379        }
2380
2381        async fn register_parent_wait_for_child(
2382            &self,
2383            _parent_session_id: &str,
2384            _child_session_id: &str,
2385            _tool_call_id: Option<&str>,
2386        ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
2387            unreachable!("create_child_action does not register a wait")
2388        }
2389
2390        async fn register_parent_wait_for_children(
2391            &self,
2392            _parent_session_id: &str,
2393            _child_session_ids: &[String],
2394            _policy: bamboo_domain::session::runtime_state::ChildWaitPolicy,
2395        ) -> Result<usize, crate::session_app::child_session::ChildSessionError> {
2396            unreachable!("create_child_action does not register a wait")
2397        }
2398
2399        async fn active_child_ids(&self, _parent_session_id: &str) -> Vec<String> {
2400            Vec::new()
2401        }
2402
2403        async fn find_resident_child(
2404            &self,
2405            _root_session_id: &str,
2406            _resident_name: &str,
2407        ) -> Option<String> {
2408            None
2409        }
2410
2411        async fn ensure_child_indexed(&self, _child_session_id: &str) {}
2412    }
2413
2414    #[test]
2415    fn build_spec_sets_remote_placement_for_matching_role() {
2416        let mut placements = HashMap::new();
2417        placements.insert(
2418            "explorer".to_string(),
2419            ResolvedRemotePlacement {
2420                endpoint: "wss://gpu-host:8443".into(),
2421                token: Some("T-secret".into()),
2422                ca_cert_file: None,
2423                host_label: None,
2424            },
2425        );
2426        let runner = bogus_runner(placements);
2427
2428        // Matching role -> Placement::Remote + the bearer on the secrets envelope.
2429        let s = session_of_role("explorer", "do the thing");
2430        let spec = runner.build_spec(&s, &job_for("child-1"));
2431        match &spec.placement {
2432            Placement::Remote { endpoint } => assert_eq!(endpoint, "wss://gpu-host:8443"),
2433            other => panic!("expected Remote, got {other:?}"),
2434        }
2435        assert_eq!(spec.secrets.worker_auth_token.as_deref(), Some("T-secret"));
2436    }
2437
2438    #[test]
2439    fn build_spec_leaves_local_for_unmatched_role() {
2440        let mut placements = HashMap::new();
2441        placements.insert(
2442            "explorer".to_string(),
2443            ResolvedRemotePlacement {
2444                endpoint: "wss://gpu-host:8443".into(),
2445                token: Some("T".into()),
2446                ca_cert_file: None,
2447                host_label: None,
2448            },
2449        );
2450        let runner = bogus_runner(placements);
2451
2452        // A DIFFERENT role keeps the default Local placement + no bearer.
2453        let s = session_of_role("writer", "do the thing");
2454        let spec = runner.build_spec(&s, &job_for("child-1"));
2455        assert_eq!(spec.placement, Placement::Local);
2456        assert!(spec.secrets.worker_auth_token.is_none());
2457    }
2458
2459    #[test]
2460    fn build_spec_local_when_no_placements() {
2461        let runner = bogus_runner(HashMap::new());
2462        let s = session_of_role("explorer", "do the thing");
2463        let spec = runner.build_spec(&s, &job_for("child-1"));
2464        assert_eq!(spec.placement, Placement::Local);
2465        assert!(spec.secrets.worker_auth_token.is_none());
2466    }
2467
2468    #[tokio::test]
2469    async fn build_spec_preserves_inherited_bypass_for_child_worker() {
2470        // Exercise the real creation path instead of pre-seeding the child by
2471        // hand: a bypassed parent's posture must survive both child creation and
2472        // the actor provisioning boundary.
2473        let runner = bogus_runner(HashMap::new());
2474        let mut parent = Session::new("parent-bypass", "test-model");
2475        parent
2476            .agent_runtime_state
2477            .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
2478            .bypass_permissions = true;
2479        let workspace = tempfile::tempdir().expect("workspace fixture");
2480        let port = RecordingChildSessionPort::default();
2481        let child_id = format!("child-bypass-{}", uuid::Uuid::new_v4());
2482        crate::session_app::child_session::create_child_action(
2483            &port,
2484            crate::session_app::child_session::CreateChildInput {
2485                parent_session: parent,
2486                child_id: child_id.clone(),
2487                title: "Bypassed child".to_string(),
2488                responsibility: "Run ordinary commands".to_string(),
2489                assignment_prompt: "run an ordinary command".to_string(),
2490                subagent_type: "explorer".to_string(),
2491                workspace: workspace.path().to_string_lossy().into_owned(),
2492                model_override: None,
2493                model_ref_override: None,
2494                runtime_metadata: HashMap::new(),
2495                auto_run: false,
2496                reasoning_effort: None,
2497                lifecycle: None,
2498                resident_name: None,
2499                resident_context: None,
2500                disabled_tools: None,
2501                context_fork: None,
2502            },
2503        )
2504        .await
2505        .expect("create inherited-bypass child");
2506        let child = port.saved_child();
2507
2508        assert!(
2509            child
2510                .agent_runtime_state
2511                .as_ref()
2512                .is_some_and(|state| state.bypass_permissions),
2513            "create_child_action must inherit bypass from the parent"
2514        );
2515
2516        let spec = runner.build_spec(&child, &job_for(&child_id));
2517
2518        assert!(spec.capabilities.bypass, "child worker must inherit bypass");
2519        assert!(
2520            spec.capabilities.enforce_permissions,
2521            "forced-ask evaluation must remain active under bypass"
2522        );
2523    }
2524
2525    #[tokio::test]
2526    async fn child_resident_and_guardian_inherit_project_through_actor_run_spec() {
2527        let project_id = bamboo_domain::ProjectId::parse("project-inherited").expect("Project id");
2528        let workspace = tempfile::tempdir().expect("workspace fixture");
2529
2530        for (role, lifecycle, resident_name) in [
2531            ("explorer", None, None),
2532            ("resident", Some("resident"), Some("stable-reviewer")),
2533            ("guardian", None, None),
2534        ] {
2535            let mut parent = Session::new(format!("parent-{role}"), "test-model");
2536            parent.set_project_id_meta(project_id.to_string());
2537            let port = RecordingChildSessionPort::default();
2538            let child_id = format!("child-{role}-{}", uuid::Uuid::new_v4());
2539            crate::session_app::child_session::create_child_action(
2540                &port,
2541                crate::session_app::child_session::CreateChildInput {
2542                    parent_session: parent,
2543                    child_id: child_id.clone(),
2544                    title: format!("{role} child"),
2545                    responsibility: "Review the assigned work".to_string(),
2546                    assignment_prompt: "inspect the change".to_string(),
2547                    subagent_type: role.to_string(),
2548                    workspace: workspace.path().to_string_lossy().into_owned(),
2549                    model_override: None,
2550                    model_ref_override: None,
2551                    runtime_metadata: HashMap::new(),
2552                    auto_run: false,
2553                    reasoning_effort: None,
2554                    lifecycle: lifecycle.map(str::to_string),
2555                    resident_name: resident_name.map(str::to_string),
2556                    resident_context: None,
2557                    disabled_tools: None,
2558                    context_fork: None,
2559                },
2560            )
2561            .await
2562            .expect("create Project-inheriting child");
2563            let child = port.saved_child();
2564            assert_eq!(
2565                crate::project_context::ProjectContextResolver::project_id_from_session(&child),
2566                Some(project_id.clone()),
2567                "{role} child must inherit its parent's Project"
2568            );
2569
2570            assert_eq!(
2571                project_id_for_actor_run(&child).expect("valid actor Project identity"),
2572                Some(project_id.clone()),
2573                "{role} actor RunSpec must preserve inherited Project identity"
2574            );
2575        }
2576    }
2577
2578    #[test]
2579    fn placement_metadata_stamps_remote_and_schedulable_not_local() {
2580        // Local children carry no stamp — the DTO defaults them to the backend host.
2581        assert_eq!(placement_metadata(&Placement::Local, None), None);
2582
2583        // Remote, no node label → host derived from the endpoint.
2584        let r = placement_metadata(
2585            &Placement::Remote {
2586                endpoint: "wss://10.0.0.5:8443/stream".into(),
2587            },
2588            None,
2589        )
2590        .unwrap();
2591        assert!(r.contains(r#""kind":"remote""#), "{r}");
2592        assert!(r.contains(r#""host":"10.0.0.5""#), "{r}");
2593
2594        // A cluster node's label (its metadata) OVERRIDES the raw endpoint host.
2595        let labeled = placement_metadata(
2596            &Placement::Remote {
2597                endpoint: "ws://169.254.230.101:8899".into(),
2598            },
2599            Some("mini"),
2600        )
2601        .unwrap();
2602        assert!(labeled.contains(r#""host":"mini""#), "{labeled}");
2603
2604        // Schedulable → {kind:"remote", host:<node label, else pool>}.
2605        let s = placement_metadata(
2606            &Placement::Schedulable {
2607                pool: "explorers".into(),
2608            },
2609            Some("mini"),
2610        )
2611        .unwrap();
2612        assert!(s.contains(r#""kind":"remote""#), "{s}");
2613        assert!(s.contains(r#""host":"mini""#), "{s}");
2614
2615        // The stamp round-trips through the storage placement type.
2616        let p: bamboo_storage::SessionPlacement = serde_json::from_str(&labeled).unwrap();
2617        assert_eq!(p.kind, "remote");
2618        assert_eq!(p.host, "mini");
2619    }
2620
2621    /// End-to-end remote run through `execute_external_child`: a resident worker
2622    /// (Bearer-gated `WsServer` + `EchoExecutor`) serves the role; the runner is
2623    /// built with a `remote_placements` entry pointing at it AND a BOGUS
2624    /// worker_bin (`/bin/false`). A passing test proves the remote path CONNECTS
2625    /// to the resident worker and NEVER spawns (a spawn would fail on /bin/false),
2626    /// and that a terminal/echo result flows back.
2627    #[tokio::test]
2628    async fn execute_external_child_routes_role_to_remote_worker_without_spawning() {
2629        // 1. Stand up the resident worker on loopback with a required bearer.
2630        let token = "remote-test-token";
2631        let server = bamboo_subagent::transport::WsServer::bind_with_token(
2632            (std::net::Ipv4Addr::LOCALHOST, 0).into(),
2633            Some(token.to_string()),
2634        )
2635        .await
2636        .expect("bind resident worker");
2637        let endpoint = server.ws_endpoint(); // ws://127.0.0.1:<port>
2638        let srv = tokio::spawn(async move {
2639            // serve() loops connection-after-connection; the test exits, dropping it.
2640            let _ = server
2641                .serve(Arc::new(bamboo_subagent::executor::EchoExecutor))
2642                .await;
2643        });
2644
2645        // 2. Build the runner: role "explorer" pinned remote, bogus worker_bin.
2646        let mut placements = HashMap::new();
2647        placements.insert(
2648            "explorer".to_string(),
2649            ResolvedRemotePlacement {
2650                endpoint: endpoint.clone(),
2651                token: Some(token.to_string()),
2652                ca_cert_file: None,
2653                host_label: Some("mini-e2e".into()), // node label, surfaced on the badge
2654            },
2655        );
2656        let runner = bogus_runner(placements);
2657
2658        // 3. Drive a real run for that role.
2659        let mut session = session_of_role("explorer", "hello remote");
2660        let job = job_for("child-1");
2661        let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(64);
2662        let cancel = CancellationToken::new();
2663
2664        let result = tokio::time::timeout(
2665            Duration::from_secs(10),
2666            runner.execute_external_child(&mut session, &job, event_tx, cancel),
2667        )
2668        .await
2669        .expect("run did not hang")
2670        .expect("remote run succeeded (connected to resident worker, did not spawn)");
2671
2672        let _ = result;
2673        // The EchoExecutor's reply is written back onto the child session as an
2674        // assistant message — proof a terminal result flowed back over the link.
2675        let last = session
2676            .messages
2677            .iter()
2678            .rev()
2679            .find(|m| matches!(m.role, Role::Assistant))
2680            .expect("an assistant reply was written back");
2681        assert!(
2682            last.content.contains("echo:"),
2683            "expected echo reply, got {:?}",
2684            last.content
2685        );
2686
2687        // A remote run must stamp WHICH machine it ran on onto the child session
2688        // (mirrored to the UI badge) using the placement's node label.
2689        let placement = session
2690            .metadata
2691            .get("placement")
2692            .expect("remote child session stamped with a placement");
2693        assert!(placement.contains(r#""kind":"remote""#), "{placement}");
2694        assert!(placement.contains(r#""host":"mini-e2e""#), "{placement}");
2695
2696        // Drain a couple of streamed events to confirm the event pipe carried the
2697        // worker's tokens too (best-effort; the reply assertion above is primary).
2698        let mut saw_event = false;
2699        while let Ok(Some(_ev)) =
2700            tokio::time::timeout(Duration::from_millis(50), event_rx.recv()).await
2701        {
2702            saw_event = true;
2703        }
2704        let _ = saw_event;
2705
2706        srv.abort();
2707    }
2708
2709    // ---- #181 (P2b): schedulable placement routing --------------------------
2710
2711    /// A bogus-worker_bin runner carrying SCHEDULABLE placements (and optionally
2712    /// remote ones, to test precedence). A local spawn here would fail on
2713    /// `/bin/false`, so a passing schedulable test proves no subprocess spawned.
2714    fn bogus_sched_runner(
2715        remote: HashMap<String, ResolvedRemotePlacement>,
2716        sched: HashMap<String, ResolvedSchedulablePlacement>,
2717    ) -> ActorChildRunner {
2718        ActorChildRunner::new(
2719            "test-actor".into(),
2720            PathBuf::from("/bin/false"),
2721            vec![],
2722            std::env::temp_dir().join("bamboo-test-fab-181"),
2723            ExecutorSpec::Echo,
2724            vec![],
2725            "anthropic".into(),
2726            4,
2727        )
2728        .with_remote_placements(remote)
2729        .with_schedulable_placements(sched)
2730    }
2731
2732    fn sched_placement(
2733        pool: &str,
2734        _registry_url: impl Into<String>,
2735    ) -> ResolvedSchedulablePlacement {
2736        ResolvedSchedulablePlacement {
2737            pool: pool.into(),
2738            host_label: None,
2739        }
2740    }
2741
2742    #[test]
2743    fn build_spec_sets_schedulable_placement_for_matching_role() {
2744        let mut sched = HashMap::new();
2745        sched.insert(
2746            "explorer".to_string(),
2747            sched_placement("gpu-pool", "unused"),
2748        );
2749        let runner = bogus_sched_runner(HashMap::new(), sched);
2750
2751        let s = session_of_role("explorer", "do the thing");
2752        let spec = runner.build_spec(&s, &job_for("child-1"));
2753        match &spec.placement {
2754            Placement::Schedulable { pool } => assert_eq!(pool, "gpu-pool"),
2755            other => panic!("expected Schedulable, got {other:?}"),
2756        }
2757        // No per-placement bearer now — the bus connection carries the bus token.
2758        assert!(spec.secrets.worker_auth_token.is_none());
2759    }
2760
2761    #[test]
2762    fn build_spec_remote_wins_when_role_in_both_maps() {
2763        // A role present in BOTH remote_placements and schedulable_placements must
2764        // resolve to the FIXED remote placement (documented precedence).
2765        let mut remote = HashMap::new();
2766        remote.insert(
2767            "explorer".to_string(),
2768            ResolvedRemotePlacement {
2769                endpoint: "wss://fixed-host:8443".into(),
2770                token: Some("T-remote".into()),
2771                ca_cert_file: None,
2772                host_label: None,
2773            },
2774        );
2775        let mut sched = HashMap::new();
2776        sched.insert(
2777            "explorer".to_string(),
2778            sched_placement("gpu-pool", "https://control-plane:9562"),
2779        );
2780        let runner = bogus_sched_runner(remote, sched);
2781
2782        let s = session_of_role("explorer", "do the thing");
2783        let spec = runner.build_spec(&s, &job_for("child-1"));
2784        match &spec.placement {
2785            Placement::Remote { endpoint } => assert_eq!(endpoint, "wss://fixed-host:8443"),
2786            other => panic!("expected Remote (precedence), got {other:?}"),
2787        }
2788        assert_eq!(spec.secrets.worker_auth_token.as_deref(), Some("T-remote"));
2789    }
2790
2791    #[test]
2792    fn build_spec_local_for_unmatched_schedulable_role() {
2793        let mut sched = HashMap::new();
2794        sched.insert(
2795            "explorer".to_string(),
2796            sched_placement("gpu-pool", "https://control-plane:9562"),
2797        );
2798        let runner = bogus_sched_runner(HashMap::new(), sched);
2799        let s = session_of_role("writer", "do the thing");
2800        let spec = runner.build_spec(&s, &job_for("child-1"));
2801        assert_eq!(spec.placement, Placement::Local);
2802        assert!(spec.secrets.worker_auth_token.is_none());
2803    }
2804
2805    /// The full role → resolved-placement → badge-host chain: a child routed to a
2806    /// remote/schedulable placement carrying a cluster node's `host_label` stamps
2807    /// that label; without a label it falls back to the endpoint host / pool; a
2808    /// Local child gets no stamp (the DTO defaults it to the backend host).
2809    #[test]
2810    fn placement_stamp_uses_node_label_for_remote_and_schedulable() {
2811        // Remote WITH a node label → {remote, <label>}, overriding the raw IP.
2812        let mut remote = HashMap::new();
2813        remote.insert(
2814            "explorer".to_string(),
2815            ResolvedRemotePlacement {
2816                endpoint: "ws://169.254.230.101:8899".into(),
2817                token: None,
2818                ca_cert_file: None,
2819                host_label: Some("mini".into()),
2820            },
2821        );
2822        let runner = bogus_runner(remote);
2823        let spec = runner.build_spec(&session_of_role("explorer", "go"), &job_for("c1"));
2824        let stamp = runner
2825            .placement_stamp_for(&spec)
2826            .expect("remote child is stamped");
2827        assert!(stamp.contains(r#""kind":"remote""#), "{stamp}");
2828        assert!(stamp.contains(r#""host":"mini""#), "{stamp}");
2829
2830        // Remote WITHOUT a node label → falls back to the endpoint host.
2831        let mut remote_nolabel = HashMap::new();
2832        remote_nolabel.insert(
2833            "explorer".to_string(),
2834            ResolvedRemotePlacement {
2835                endpoint: "ws://169.254.230.101:8899".into(),
2836                token: None,
2837                ca_cert_file: None,
2838                host_label: None,
2839            },
2840        );
2841        let r2 = bogus_runner(remote_nolabel);
2842        let spec2 = r2.build_spec(&session_of_role("explorer", "go"), &job_for("c1"));
2843        assert!(r2
2844            .placement_stamp_for(&spec2)
2845            .unwrap()
2846            .contains(r#""host":"169.254.230.101""#));
2847
2848        // Schedulable WITH a node label → {remote, <label>} (a node, not a pool name).
2849        let mut sched = HashMap::new();
2850        sched.insert(
2851            "mac-mini-monitor".to_string(),
2852            ResolvedSchedulablePlacement {
2853                pool: "mac-mini-monitor".into(),
2854                host_label: Some("mini".into()),
2855            },
2856        );
2857        let sr = bogus_sched_runner(HashMap::new(), sched);
2858        let spec3 = sr.build_spec(&session_of_role("mac-mini-monitor", "go"), &job_for("c1"));
2859        let stamp3 = sr
2860            .placement_stamp_for(&spec3)
2861            .expect("scheduled child is stamped");
2862        assert!(stamp3.contains(r#""kind":"remote""#), "{stamp3}");
2863        assert!(stamp3.contains(r#""host":"mini""#), "{stamp3}");
2864
2865        // A Local (unmatched) child gets NO stamp.
2866        let local = bogus_runner(HashMap::new());
2867        let spec4 = local.build_spec(&session_of_role("writer", "go"), &job_for("c1"));
2868        assert_eq!(local.placement_stamp_for(&spec4), None);
2869    }
2870
2871    // ---- #181: schedulable selection over the BUS (Phase 3 cutover) ----------
2872
2873    async fn start_bus() -> (String, tempfile::TempDir) {
2874        let dir = tempfile::tempdir().unwrap();
2875        let core = std::sync::Arc::new(bamboo_broker::BrokerCore::new(dir.path()));
2876        let server = std::sync::Arc::new(bamboo_broker::BrokerServer::new(core, "t"));
2877        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2878        let addr = listener.local_addr().unwrap();
2879        tokio::spawn(async move {
2880            let _ = server.serve(listener).await;
2881        });
2882        (format!("ws://{addr}"), dir)
2883    }
2884
2885    async fn join_pool(endpoint: &str, id: &str, pool: &str) -> bamboo_broker::BrokerClient {
2886        let mut c = bamboo_broker::BrokerClient::connect(
2887            endpoint,
2888            bamboo_subagent::AgentRef {
2889                session_id: id.into(),
2890                role: Some(pool.into()),
2891            },
2892            "t",
2893        )
2894        .await
2895        .unwrap();
2896        c.subscribe().await.unwrap();
2897        c
2898    }
2899
2900    fn sched_runner_on_bus(endpoint: &str, child_role: &str, pool: &str) -> ActorChildRunner {
2901        let mut sched = HashMap::new();
2902        sched.insert(child_role.to_string(), sched_placement(pool, "unused"));
2903        bogus_sched_runner(HashMap::new(), sched).with_bus(Some(bamboo_subagent::BusEndpoint {
2904            endpoint: endpoint.into(),
2905            token: "t".into(),
2906        }))
2907    }
2908
2909    #[tokio::test]
2910    async fn resolve_schedulable_picks_a_live_bus_worker() {
2911        let (endpoint, _dir) = start_bus().await;
2912        let _w = join_pool(&endpoint, "w-gpu", "gpu-pool").await;
2913        let runner = sched_runner_on_bus(&endpoint, "explorer", "gpu-pool");
2914
2915        let mailbox = runner
2916            .resolve_schedulable_worker("explorer")
2917            .await
2918            .expect("a live pool worker is found on the bus");
2919        assert_eq!(mailbox, "w-gpu");
2920    }
2921
2922    #[tokio::test]
2923    async fn resolve_schedulable_round_robins_over_pool_workers() {
2924        let (endpoint, _dir) = start_bus().await;
2925        let _a = join_pool(&endpoint, "w-a", "gpu-pool").await;
2926        let _b = join_pool(&endpoint, "w-b", "gpu-pool").await;
2927        let runner = sched_runner_on_bus(&endpoint, "explorer", "gpu-pool");
2928
2929        // Successive resolves spread across both connected workers.
2930        let mut picked = std::collections::HashSet::new();
2931        for _ in 0..6 {
2932            picked.insert(runner.resolve_schedulable_worker("explorer").await.unwrap());
2933        }
2934        assert_eq!(
2935            picked,
2936            ["w-a".to_string(), "w-b".to_string()].into_iter().collect(),
2937            "round-robin must cover every connected pool worker"
2938        );
2939    }
2940
2941    #[tokio::test]
2942    async fn resolve_schedulable_errors_on_empty_pool() {
2943        let (endpoint, _dir) = start_bus().await;
2944        // No worker subscribes to "gpu-pool".
2945        let runner = sched_runner_on_bus(&endpoint, "explorer", "gpu-pool");
2946
2947        let err = runner
2948            .resolve_schedulable_worker("explorer")
2949            .await
2950            .expect_err("an empty pool is terminal — no local fallback")
2951            .to_string();
2952        assert!(err.contains("no live worker in pool"), "got: {err}");
2953        assert!(err.contains("NOT spawning"), "got: {err}");
2954    }
2955
2956    /// FULL schedulable run over the bus: a worker SERVING `EchoExecutor` joins the
2957    /// pool by role; `execute_external_child` with a Schedulable placement resolves
2958    /// it from the bus (no local subprocess — the worker_bin is `/bin/false`),
2959    /// drives the run, gets the echo back, AND stamps the child session with the
2960    /// pool's cluster-node label — `{kind:remote, host:"mini"}`. The end-to-end
2961    /// analogue of the live `mac-mini-monitor`→mini run.
2962    #[tokio::test]
2963    async fn execute_external_child_runs_schedulable_over_bus_and_stamps_node_label() {
2964        let (endpoint, _dir) = start_bus().await;
2965
2966        // A bus worker SERVING runs (not just presence), joined to the pool by role.
2967        let ep = endpoint.clone();
2968        let worker = tokio::spawn(async move {
2969            let _ = bamboo_broker::serve_executor(
2970                &ep,
2971                bamboo_subagent::AgentRef {
2972                    session_id: "mmm-worker".into(),
2973                    role: Some("mac-mini-monitor".into()),
2974                },
2975                "t",
2976                std::sync::Arc::new(bamboo_subagent::executor::EchoExecutor),
2977            )
2978            .await;
2979        });
2980
2981        // Wait until the worker is visible on the bus so the pool is non-empty
2982        // when execute_external_child resolves it (serve_executor connects async).
2983        let mut probe = bamboo_broker::BrokerClient::connect(
2984            &endpoint,
2985            bamboo_subagent::AgentRef {
2986                session_id: "probe".into(),
2987                role: None,
2988            },
2989            "t",
2990        )
2991        .await
2992        .unwrap();
2993        let mut ready = false;
2994        for _ in 0..100 {
2995            if probe
2996                .list_connected("mac-mini-monitor")
2997                .await
2998                .unwrap()
2999                .iter()
3000                .any(|id| id == "mmm-worker")
3001            {
3002                ready = true;
3003                break;
3004            }
3005            tokio::time::sleep(Duration::from_millis(30)).await;
3006        }
3007        assert!(ready, "worker never joined the pool");
3008
3009        // Runner: child role → schedulable pool "mac-mini-monitor" carrying the
3010        // cluster node's label "mini"; bogus worker_bin so any local spawn fails.
3011        let mut sched = HashMap::new();
3012        sched.insert(
3013            "mac-mini-monitor".to_string(),
3014            ResolvedSchedulablePlacement {
3015                pool: "mac-mini-monitor".into(),
3016                host_label: Some("mini".into()),
3017            },
3018        );
3019        let runner = bogus_sched_runner(HashMap::new(), sched).with_bus(Some(
3020            bamboo_subagent::BusEndpoint {
3021                endpoint: endpoint.clone(),
3022                token: "t".into(),
3023            },
3024        ));
3025
3026        let mut session = session_of_role("mac-mini-monitor", "hello scheduled");
3027        let job = job_for("child-1");
3028        let (event_tx, _rx) = mpsc::channel::<AgentEvent>(64);
3029        let cancel = CancellationToken::new();
3030
3031        tokio::time::timeout(
3032            Duration::from_secs(10),
3033            runner.execute_external_child(&mut session, &job, event_tx, cancel),
3034        )
3035        .await
3036        .expect("run did not hang")
3037        .expect("schedulable run succeeded over the bus (no local spawn)");
3038
3039        // Echo reply flowed back — proves it routed to the bus worker, not local.
3040        let last = session
3041            .messages
3042            .iter()
3043            .rev()
3044            .find(|m| matches!(m.role, Role::Assistant))
3045            .expect("an assistant reply was written back");
3046        assert!(last.content.contains("echo:"), "got {:?}", last.content);
3047
3048        // ...and the child is stamped with the pool's cluster-node label.
3049        let placement = session
3050            .metadata
3051            .get("placement")
3052            .expect("scheduled child session stamped with a placement");
3053        assert!(placement.contains(r#""kind":"remote""#), "{placement}");
3054        assert!(placement.contains(r#""host":"mini""#), "{placement}");
3055
3056        worker.abort();
3057    }
3058}