Skip to main content

bamboo_engine/external_agents/
actor_adapter.rs

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