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