Skip to main content

bamboo_server_tools/
fabric_deploy.rs

1//! Remote Cluster Fabric deploy engine.
2//!
3//! [`FabricDeployer`] is the SINGLE orchestration path — `deploy` / `stop` /
4//! `test` / `read_logs` — shared by the operator HTTP handlers and the agent
5//! [`crate::cluster_tool::ClusterTool`]. Both hold the same `Arc<FabricDeployer>`
6//! so they share ONE worker registry (stop from either side sees the same
7//! workers) and one persistence path. Living here (the one crate that sees both
8//! `bamboo-config`'s `Node` and `bamboo-broker`'s deployers) keeps placement/
9//! auth handling in one place.
10
11use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13use std::sync::Arc;
14use std::time::{Duration, Instant};
15
16use tokio::sync::{Mutex, RwLock};
17
18use bamboo_broker::{
19    ask_agent, AgentDeployment, BrokerClient, Deployer, LocalProcessDeployer, RusshAuth,
20    RusshDeployer, SshDeployer, UploadSpec, ORCHESTRATOR_ID,
21};
22use bamboo_config::cluster_fabric::{
23    Node, NodePlacement, NodeState, NodeStatus, SshAuth, SshTarget,
24};
25use bamboo_config::{BrokerClientConfig, Config};
26use bamboo_subagent::{AgentRef, AskMode};
27
28use crate::deploy_agent::{Deployed, DeployedRegistry};
29
30/// Typed error so callers (HTTP / agent tool) can map to the right status/kind.
31#[derive(Debug)]
32pub enum FabricError {
33    NotFound(String),
34    BadRequest(String),
35    Internal(String),
36}
37
38impl std::fmt::Display for FabricError {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        match self {
41            FabricError::NotFound(m) | FabricError::BadRequest(m) | FabricError::Internal(m) => {
42                write!(f, "{m}")
43            }
44        }
45    }
46}
47
48type FabricResult<T> = Result<T, FabricError>;
49
50/// The shared fabric deploy engine: turns persisted nodes into running
51/// `broker-agent` workers, holding their handles + persisting `NodeState`.
52pub struct FabricDeployer {
53    config: Arc<RwLock<Config>>,
54    /// Serializes the mutate+persist of a fabric config write (same guarantee as
55    /// `AppState::update_config`'s io-lock — #126).
56    config_io_lock: Arc<Mutex<()>>,
57    data_dir: PathBuf,
58    /// Worker handles, keyed by node id — SHARED with `deploy_agent` so both
59    /// surfaces see/manage the same workers.
60    registry: DeployedRegistry,
61    bamboo_bin: PathBuf,
62    /// Per-node auto-recovery bookkeeping (debounce + backoff + attempt cap).
63    /// Ephemeral: cleared on recovery and re-derived after a restart.
64    recovery: Arc<Mutex<HashMap<String, RecoveryState>>>,
65}
66
67/// Auto-recovery state for one node (see [`FabricDeployer::recovery_decision`]).
68#[derive(Default)]
69struct RecoveryState {
70    /// Consecutive Unreachable observations (the debounce counter).
71    consecutive_unreachable: u32,
72    /// Redeploy attempts made this outage.
73    attempts: u32,
74    /// Earliest time the next attempt may fire (exponential backoff gate).
75    next_eligible: Option<tokio::time::Instant>,
76    /// Set once the attempt cap is hit + the node marked Failed (don't repeat).
77    gave_up: bool,
78    /// A redeploy is currently running — don't launch an overlapping one (a slow
79    /// SSH deploy can outlast the sweep interval).
80    in_flight: bool,
81}
82
83/// Consecutive Unreachable probes before the first redeploy (ride out a blip).
84const RECOVERY_DEBOUNCE: u32 = 2;
85/// Redeploy attempts before giving up and marking the node Failed.
86const RECOVERY_MAX_ATTEMPTS: u32 = 3;
87
88impl FabricDeployer {
89    pub fn new(
90        config: Arc<RwLock<Config>>,
91        config_io_lock: Arc<Mutex<()>>,
92        data_dir: impl Into<PathBuf>,
93        registry: DeployedRegistry,
94        bamboo_bin: impl Into<PathBuf>,
95    ) -> Self {
96        Self {
97            config,
98            config_io_lock,
99            data_dir: data_dir.into(),
100            registry,
101            bamboo_bin: bamboo_bin.into(),
102            recovery: Arc::new(Mutex::new(HashMap::new())),
103        }
104    }
105
106    /// The shared worker registry (so `deploy_agent` can reuse it).
107    pub fn registry(&self) -> DeployedRegistry {
108        self.registry.clone()
109    }
110
111    fn node_snapshot(&self, cfg: &Config, node_id: &str) -> FabricResult<Node> {
112        cfg.cluster_fabric
113            .node(node_id)
114            .cloned()
115            .ok_or_else(|| FabricError::NotFound(format!("Node '{node_id}'")))
116    }
117
118    /// Deploy a worker onto a node and persist its running state.
119    ///
120    /// `echo=true` runs the dependency-free echo executor (no LLM) — a
121    /// connectivity smoke test.
122    pub async fn deploy(&self, node_id: &str, echo: bool) -> FabricResult<NodeState> {
123        let (mut node, broker) = {
124            let cfg = self.config.read().await;
125            (
126                self.node_snapshot(&cfg, node_id)?,
127                cfg.subagents.broker.clone(),
128            )
129        };
130        if !node.enabled {
131            return Err(FabricError::BadRequest(format!(
132                "Node '{node_id}' is disabled"
133            )));
134        }
135        let broker = broker
136            .filter(|b| !b.endpoint.trim().is_empty())
137            .ok_or_else(|| {
138                FabricError::BadRequest(
139                    "No broker configured (subagents.broker) — a worker has nowhere to dial home"
140                        .to_string(),
141                )
142            })?;
143
144        // Zero-config default: if the operator didn't pin an artifact, ship our OWN
145        // `bamboo` binary so a fresh remote node needs no manual install — but only
146        // when the remote arch matches (a cross-arch binary can't run there). We
147        // preflight `uname` for the arch; a mismatch is a clear error, not a wasted
148        // 100MB+ upload that silently fails to exec. (Local placement runs the
149        // binary directly, so it never needs an upload.)
150        if node.deploy.artifact_path.is_none() && matches!(node.placement, NodePlacement::Ssh(_)) {
151            let build = build_deployer(&node, &self.bamboo_bin).map_err(FabricError::BadRequest)?;
152            let uname = build.deployer.preflight().await.map_err(|e| {
153                FabricError::Internal(format!("preflight for '{node_id}' failed: {e}"))
154            })?;
155            if remote_matches_orchestrator(&uname) {
156                node.deploy.artifact_path = Some(self.bamboo_bin.to_string_lossy().into_owned());
157                tracing::info!(
158                    node = node_id,
159                    %uname,
160                    "no artifact_path set — auto-uploading orchestrator binary (arch match)"
161                );
162            } else {
163                return Err(FabricError::BadRequest(format!(
164                    "node '{node_id}': remote is '{uname}' but the orchestrator binary is \
165                     {}/{} — set deploy.artifact_path to a bamboo binary built for the remote arch",
166                    std::env::consts::OS,
167                    std::env::consts::ARCH,
168                )));
169            }
170        }
171
172        let worker_id = worker_id_for(&node);
173        let build = build_deployer(&node, &self.bamboo_bin).map_err(FabricError::BadRequest)?;
174        let log_path = log_path_for(&node);
175
176        // Resolve the worker's full ProvisionSpec PARENT-side (model + creds + MCP
177        // + bus) so a remote node needs no bamboo config of its own. Deployers that
178        // deliver it (local stdin / russh file-upload) ship it; others fall back to
179        // the legacy argv+env self-resolve (spec_json is then ignored, harmless).
180        let spec_json = {
181            let cfg = self.config.read().await;
182            build_resident_spec(
183                &node,
184                &broker.endpoint,
185                &broker.token,
186                &cfg,
187                echo,
188                &worker_id,
189            )
190        };
191
192        let deployment = AgentDeployment {
193            id: worker_id.clone(),
194            role: node.deploy.default_role.clone(),
195            broker_endpoint: broker.endpoint.clone(),
196            token: broker.token.clone(),
197            model: node.deploy.model.clone(),
198            workspace: node.deploy.workspace.clone(),
199            echo,
200            mcp_proxy: Some(ORCHESTRATOR_ID.to_string()),
201            log_path: Some(log_path.clone()),
202            spec_json,
203            // Fabric config doesn't yet expose a per-node CA-cert path (#48
204            // wires the capability into `AgentDeployment`/the CLI; a fast-follow
205            // can add `node.deploy.tls_ca_cert` if fabric nodes need self-signed
206            // `wss://` brokers without an OS-trust-store install).
207            tls_ca_cert: None,
208        };
209
210        // Release any prior worker FIRST so its reverse tunnel frees the broker
211        // port before the new deploy requests the same forward. Remove under the
212        // lock, shut down outside it: shutdown is graceful now (SIGTERM + drain
213        // grace, #49) and must not hold the shared registry for its duration.
214        let prev = self
215            .registry
216            .lock()
217            .await
218            .remove(&crate::registry_keys::node_key(node_id));
219        if let Some(prev) = prev {
220            prev.handle.shutdown().await;
221        }
222
223        let handle = match build.deployer.deploy(&deployment).await {
224            Ok(h) => h,
225            Err(e) => {
226                let failed = NodeState {
227                    status: NodeStatus::Failed,
228                    last_error: Some(e.to_string()),
229                    ..Default::default()
230                };
231                let _ = self.persist_state(node_id, Some(failed)).await;
232                tracing::warn!(
233                    audit = "cluster_fabric.deploy",
234                    node = node_id,
235                    placement = placement_env(&node),
236                    outcome = "failed",
237                    error = %e,
238                );
239                return Err(FabricError::Internal(format!(
240                    "deploy node '{node_id}' failed: {e}"
241                )));
242            }
243        };
244        let pid = handle.pid();
245        tracing::info!(
246            audit = "cluster_fabric.deploy",
247            node = node_id,
248            placement = placement_env(&node),
249            worker_id = %worker_id,
250            echo,
251            outcome = "deployed",
252        );
253
254        self.registry.lock().await.insert(
255            crate::registry_keys::node_key(node_id),
256            Deployed {
257                env: placement_env(&node).to_string(),
258                handle,
259            },
260        );
261
262        // TOFU: pin the observed host-key fingerprint if not already set.
263        if let Some(cell) = build.observed_fp {
264            if let Some(fp) = cell.lock().await.clone() {
265                self.pin_fingerprint_if_absent(node_id, &fp).await;
266            }
267        }
268
269        // Verify-on-deploy: exec'ing the worker used to report "running" even when
270        // the worker never dialed home (phantom success — e.g. a missing/incompatible
271        // binary silently failed to exec). Surface a broken deploy→tunnel→broker→worker
272        // chain HERE, not as a hung ask on the first real task.
273        //   • echo executor → round-trip a `ping` (proves the executor loop runs).
274        //   • real executor → presence probe on the bus. A live LLM worker must NOT
275        //     be handed a bogus task, so we only confirm it registered its mailbox —
276        //     enough to prove the chain is live. The role matches what the resident
277        //     registers under (the spec's `default_role`, else `general-purpose`).
278        let verify = if echo {
279            verify_echo_worker(&broker, &worker_id).await
280        } else {
281            let role = node
282                .deploy
283                .default_role
284                .clone()
285                .unwrap_or_else(|| "general-purpose".to_string());
286            verify_worker_connected(&broker, &worker_id, &role, Duration::from_secs(30)).await
287        };
288        if let Err(e) = verify {
289            // Tear the half-dead worker down and report the real failure.
290            // (Remove under the lock, shut down outside it — see deploy above.)
291            let dead = self
292                .registry
293                .lock()
294                .await
295                .remove(&crate::registry_keys::node_key(node_id));
296            if let Some(d) = dead {
297                d.handle.shutdown().await;
298            }
299            let msg = format!(
300                "worker deployed but never came up on the bus (verify failed): {e} — \
301                 check that `bamboo` runs on the remote (arch/deps) and see the node log"
302            );
303            let failed = NodeState {
304                status: NodeStatus::Failed,
305                worker_id: Some(worker_id.clone()),
306                log_path: Some(log_path.clone()),
307                last_error: Some(msg.clone()),
308                ..Default::default()
309            };
310            let _ = self.persist_state(node_id, Some(failed)).await;
311            tracing::warn!(
312                audit = "cluster_fabric.deploy",
313                node = node_id,
314                worker_id = %worker_id,
315                echo,
316                outcome = "verify_failed",
317                error = %e,
318            );
319            return Err(FabricError::Internal(format!(
320                "deploy node '{node_id}': {msg}"
321            )));
322        }
323        tracing::info!(
324            node = node_id,
325            worker_id = %worker_id,
326            echo,
327            "deploy verify ok — worker is reachable on the bus"
328        );
329
330        let state = NodeState {
331            status: NodeStatus::Running,
332            worker_id: Some(worker_id),
333            remote_pid: pid,
334            log_path: Some(log_path),
335            deployed_at: Some(chrono::Utc::now().to_rfc3339()),
336            ..Default::default()
337        };
338        self.persist_state(node_id, Some(state.clone())).await?;
339        Ok(state)
340    }
341
342    /// Stop a node's worker (if running) and persist the stopped state.
343    pub async fn stop(&self, node_id: &str) -> FabricResult<NodeState> {
344        {
345            let cfg = self.config.read().await;
346            self.node_snapshot(&cfg, node_id)?;
347        }
348        // Remove under the lock, shut down outside it: shutdown is graceful now
349        // (SIGTERM + drain grace, #49) and must not hold the shared registry.
350        let removed = self
351            .registry
352            .lock()
353            .await
354            .remove(&crate::registry_keys::node_key(node_id));
355        if let Some(d) = removed {
356            d.handle.shutdown().await;
357        }
358        tracing::info!(
359            audit = "cluster_fabric.stop",
360            node = node_id,
361            outcome = "stopped"
362        );
363        let state = NodeState {
364            status: NodeStatus::Stopped,
365            ..Default::default()
366        };
367        self.persist_state(node_id, Some(state.clone())).await?;
368        Ok(state)
369    }
370
371    /// Connectivity preflight: connect + auth + `uname`, WITHOUT deploying.
372    pub async fn test(&self, node_id: &str) -> FabricResult<String> {
373        let node = {
374            let cfg = self.config.read().await;
375            self.node_snapshot(&cfg, node_id)?
376        };
377        let build = build_deployer(&node, &self.bamboo_bin).map_err(FabricError::BadRequest)?;
378        let result = build.deployer.preflight().await;
379        tracing::info!(
380            audit = "cluster_fabric.test",
381            node = node_id,
382            placement = placement_env(&node),
383            outcome = if result.is_ok() { "ok" } else { "failed" },
384        );
385        result.map_err(|e| FabricError::Internal(format!("preflight failed: {e}")))
386    }
387
388    /// Tail the last `lines` lines of a node worker's log.
389    pub async fn read_logs(&self, node_id: &str, lines: usize) -> FabricResult<String> {
390        let node = {
391            let cfg = self.config.read().await;
392            self.node_snapshot(&cfg, node_id)?
393        };
394        let log_path = node
395            .state
396            .as_ref()
397            .and_then(|s| s.log_path.clone())
398            .unwrap_or_else(|| log_path_for(&node));
399        let build = build_deployer(&node, &self.bamboo_bin).map_err(FabricError::BadRequest)?;
400        build
401            .deployer
402            .tail_log(&log_path, lines)
403            .await
404            .map_err(|e| FabricError::Internal(format!("read logs failed: {e}")))
405    }
406
407    /// Single-probe timeout. Fast when the worker is present (returns on first
408    /// sighting); this only bounds how long a genuinely-gone worker is chased.
409    const HEALTH_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
410
411    /// Health-check `node_id` with the production probe timeout.
412    pub async fn health_check(&self, node_id: &str) -> FabricResult<NodeState> {
413        self.health_check_within(node_id, Self::HEALTH_PROBE_TIMEOUT)
414            .await
415    }
416
417    /// Probe a Running/Unreachable node's worker on the bus and reconcile its live
418    /// state: a worker present on the bus → `Running` + fresh `last_health`; a
419    /// vanished one → `Unreachable`. Nodes not meant to be up
420    /// (NotDeployed/Deploying/Stopped/Failed) are left untouched. A status FLIP is
421    /// persisted to disk (durable + audited); a steady-state heartbeat only
422    /// refreshes `last_health` in memory, so a healthy cluster doesn't rewrite
423    /// config.json every tick. Presence is checked (never a task ping), so a live
424    /// LLM worker is never disturbed — same rationale as the deploy verify.
425    async fn health_check_within(
426        &self,
427        node_id: &str,
428        probe_timeout: Duration,
429    ) -> FabricResult<NodeState> {
430        let (node, broker) = {
431            let cfg = self.config.read().await;
432            (
433                self.node_snapshot(&cfg, node_id)?,
434                cfg.subagents.broker.clone(),
435            )
436        };
437        let current = node.state.clone().unwrap_or_default();
438        if !matches!(
439            current.status,
440            NodeStatus::Running | NodeStatus::Unreachable
441        ) {
442            return Ok(current); // only nodes that should be up are monitored
443        }
444        let worker_id = current
445            .worker_id
446            .clone()
447            .unwrap_or_else(|| worker_id_for(&node));
448        let role = node
449            .deploy
450            .default_role
451            .clone()
452            .unwrap_or_else(|| "general-purpose".to_string());
453        let Some(broker) = broker.filter(|b| !b.endpoint.trim().is_empty()) else {
454            return Ok(current); // no broker configured → nothing to probe against
455        };
456
457        // Fast when present (returns on first sighting); costs the timeout only
458        // when the worker is genuinely gone. The short window absorbs a blip — a
459        // false Unreachable self-corrects on the next tick.
460        let alive = verify_worker_connected(&broker, &worker_id, &role, probe_timeout)
461            .await
462            .is_ok();
463
464        let new_status = if alive {
465            NodeStatus::Running
466        } else {
467            NodeStatus::Unreachable
468        };
469        let new_error = if alive {
470            None
471        } else {
472            Some(format!(
473                "worker '{worker_id}' not present on the bus under role '{role}'"
474            ))
475        };
476
477        // Commit under the io + write lock, RE-CHECKING the live status. The probe
478        // above ran UNLOCKED for up to `probe_timeout`, so a concurrent stop()/
479        // deploy() may have moved this node out of the monitored set meanwhile —
480        // blindly writing back the stale decision would e.g. resurrect a
481        // user-Stopped node as Unreachable and hand it to auto-recover. If the live
482        // status is no longer Running/Unreachable, the concurrent write wins.
483        let _io = self.config_io_lock.lock().await;
484        let (next, from, snapshot) = {
485            let mut cfg = self.config.write().await;
486            let Some(node) = cfg.cluster_fabric.node_mut(node_id) else {
487                return Ok(current);
488            };
489            let live = node.state.clone().unwrap_or_default();
490            if !matches!(live.status, NodeStatus::Running | NodeStatus::Unreachable) {
491                return Ok(live); // moved out of the monitored set mid-probe → leave it
492            }
493            let from = live.status;
494            let next = NodeState {
495                status: new_status,
496                last_health: Some(chrono::Utc::now().to_rfc3339()),
497                last_error: new_error,
498                ..live
499            };
500            node.state = Some(next.clone());
501            // A status FLIP is durable + audited; a steady-state heartbeat stays in
502            // memory only (no config.json churn) — so snapshot to disk only on a flip.
503            (next, from, (from != new_status).then(|| cfg.clone()))
504        };
505        if let Some(snapshot) = snapshot {
506            let data_dir = self.data_dir.clone();
507            tokio::task::spawn_blocking(move || snapshot.save_to_dir(data_dir))
508                .await
509                .map_err(|e| FabricError::Internal(format!("persist task: {e}")))?
510                .map_err(|e| FabricError::Internal(format!("save config: {e}")))?;
511            tracing::info!(
512                audit = "cluster_fabric.health",
513                node = node_id,
514                worker_id = %worker_id,
515                from = ?from,
516                to = ?next.status,
517                "node health changed",
518            );
519        }
520        Ok(next)
521    }
522
523    /// Node ids whose persisted status is Running or Unreachable — the set the
524    /// health monitor sweeps.
525    async fn monitored_node_ids(&self) -> Vec<String> {
526        let cfg = self.config.read().await;
527        cfg.cluster_fabric
528            .nodes
529            .iter()
530            .filter(|n| {
531                n.state.as_ref().is_some_and(|s| {
532                    matches!(s.status, NodeStatus::Running | NodeStatus::Unreachable)
533                })
534            })
535            .map(|n| n.id.clone())
536            .collect()
537    }
538
539    /// Decide whether to auto-recover `node_id` given its just-probed `state`,
540    /// advancing the debounce/backoff bookkeeping. `Some(attempt)` ⇒ the caller
541    /// should redeploy now; `None` ⇒ hold (not opted in, still debouncing, inside
542    /// backoff, or exhausted). On exhausting [`RECOVERY_MAX_ATTEMPTS`] it marks the
543    /// node `Failed` once and stops. A node that is no longer Unreachable clears its
544    /// recovery progress (a recovered node starts fresh next outage). A user-Stopped
545    /// node never reaches here — `health_check` only probes Running/Unreachable.
546    async fn recovery_decision(&self, node_id: &str, state: &NodeState) -> Option<u32> {
547        if state.status != NodeStatus::Unreachable {
548            self.recovery.lock().await.remove(node_id);
549            return None;
550        }
551        let auto = {
552            let cfg = self.config.read().await;
553            cfg.cluster_fabric
554                .node(node_id)
555                .map(|n| n.deploy.auto_recover)
556                .unwrap_or(false)
557        };
558        if !auto {
559            return None;
560        }
561
562        let mut map = self.recovery.lock().await;
563        let rs = map.entry(node_id.to_string()).or_default();
564        rs.consecutive_unreachable = rs.consecutive_unreachable.saturating_add(1);
565        if rs.consecutive_unreachable < RECOVERY_DEBOUNCE {
566            return None; // ride out a blip before touching a live node
567        }
568        if rs.in_flight {
569            return None; // a redeploy is still running — don't overlap it
570        }
571        if rs.attempts >= RECOVERY_MAX_ATTEMPTS {
572            let first_give_up = !rs.gave_up;
573            rs.gave_up = true;
574            drop(map);
575            if first_give_up {
576                self.mark_failed(
577                    node_id,
578                    &format!("auto-recover gave up after {RECOVERY_MAX_ATTEMPTS} attempts"),
579                )
580                .await;
581            }
582            return None;
583        }
584        if let Some(t) = rs.next_eligible {
585            if tokio::time::Instant::now() < t {
586                return None; // inside backoff
587            }
588        }
589        rs.attempts += 1;
590        rs.in_flight = true;
591        let attempt = rs.attempts;
592        rs.next_eligible = Some(tokio::time::Instant::now() + Self::recovery_backoff(attempt));
593        Some(attempt)
594    }
595
596    /// Clear the in-flight guard after a recovery redeploy settles, so a later tick
597    /// can retry (on failure); on success the next `health_check` resets the entry.
598    async fn clear_recovery_in_flight(&self, node_id: &str) {
599        if let Some(rs) = self.recovery.lock().await.get_mut(node_id) {
600            rs.in_flight = false;
601        }
602    }
603
604    /// Exponential backoff between recovery attempts: ~10s, 20s, 40s… capped 300s.
605    fn recovery_backoff(attempt: u32) -> Duration {
606        let shift = attempt.saturating_sub(1).min(5);
607        Duration::from_secs((10u64 << shift).min(300))
608    }
609
610    /// Persist a node as `Failed` with `reason`, preserving its other engine fields.
611    async fn mark_failed(&self, node_id: &str, reason: &str) {
612        let current = {
613            let cfg = self.config.read().await;
614            cfg.cluster_fabric
615                .node(node_id)
616                .and_then(|n| n.state.clone())
617                .unwrap_or_default()
618        };
619        let failed = NodeState {
620            status: NodeStatus::Failed,
621            last_error: Some(reason.to_string()),
622            ..current
623        };
624        if let Err(e) = self.persist_state(node_id, Some(failed)).await {
625            tracing::warn!(node = node_id, error = %e, "failed to persist Failed state");
626        }
627        tracing::warn!(
628            audit = "cluster_fabric.recover",
629            node = node_id,
630            reason,
631            "auto-recover exhausted → Failed",
632        );
633    }
634
635    /// Spawn the background health monitor: every `cluster_fabric.health_interval`
636    /// it [`health_check`](Self::health_check)s each Running/Unreachable node.
637    /// `None` (no task) when disabled (`health_interval_secs = 0`); abort the
638    /// handle to stop it. The cadence is read once at spawn (change → restart).
639    pub async fn spawn_health_monitor(self: Arc<Self>) -> Option<tokio::task::JoinHandle<()>> {
640        let interval = {
641            let cfg = self.config.read().await;
642            cfg.cluster_fabric.health_interval()?
643        };
644        tracing::info!(
645            interval_secs = interval.as_secs(),
646            "cluster health monitor started"
647        );
648        Some(tokio::spawn(async move {
649            let mut tick = tokio::time::interval(interval);
650            tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
651            tick.tick().await; // consume the immediate first tick
652            loop {
653                tick.tick().await;
654                for id in self.monitored_node_ids().await {
655                    match self.health_check(&id).await {
656                        Ok(state) => {
657                            if let Some(attempt) = self.recovery_decision(&id, &state).await {
658                                // Redeploy OFF the sweep's critical path so a slow
659                                // deploy doesn't stall other nodes' health checks;
660                                // the backoff gate was already advanced, so the next
661                                // tick won't re-trigger until it elapses.
662                                let this = self.clone();
663                                let node = id.clone();
664                                tokio::spawn(async move {
665                                    tracing::warn!(
666                                        audit = "cluster_fabric.recover",
667                                        node = %node,
668                                        attempt,
669                                        "auto-recovering unreachable node",
670                                    );
671                                    match this.deploy(&node, false).await {
672                                        Ok(_) => tracing::info!(
673                                            node = %node,
674                                            attempt,
675                                            "auto-recover redeploy succeeded"
676                                        ),
677                                        Err(e) => tracing::warn!(
678                                            node = %node,
679                                            attempt,
680                                            error = %e,
681                                            "auto-recover redeploy failed"
682                                        ),
683                                    }
684                                    this.clear_recovery_in_flight(&node).await;
685                                });
686                            }
687                        }
688                        Err(e) => tracing::warn!(node = %id, error = %e, "health check failed"),
689                    }
690                }
691            }
692        }))
693    }
694
695    /// Persist `state` onto a node (engine-owned field): io-lock + atomic save,
696    /// mirroring `AppState::update_config` minus the provider/MCP side effects.
697    async fn persist_state(&self, node_id: &str, state: Option<NodeState>) -> FabricResult<()> {
698        let _io = self.config_io_lock.lock().await;
699        let snapshot = {
700            let mut cfg = self.config.write().await;
701            let node = cfg
702                .cluster_fabric
703                .node_mut(node_id)
704                .ok_or_else(|| FabricError::NotFound(format!("Node '{node_id}'")))?;
705            node.state = state;
706            cfg.clone()
707        };
708        let data_dir = self.data_dir.clone();
709        tokio::task::spawn_blocking(move || snapshot.save_to_dir(data_dir))
710            .await
711            .map_err(|e| FabricError::Internal(format!("persist task: {e}")))?
712            .map_err(|e| FabricError::Internal(format!("save config: {e}")))?;
713        Ok(())
714    }
715
716    /// Pin `fp` onto a node's SSH target if it has no fingerprint yet (TOFU).
717    async fn pin_fingerprint_if_absent(&self, node_id: &str, fp: &str) {
718        let _io = self.config_io_lock.lock().await;
719        let snapshot = {
720            let mut cfg = self.config.write().await;
721            if let Some(node) = cfg.cluster_fabric.node_mut(node_id) {
722                if let NodePlacement::Ssh(target) = &mut node.placement {
723                    if target.host_key_fingerprint.is_some() {
724                        return;
725                    }
726                    target.host_key_fingerprint = Some(fp.to_string());
727                }
728            }
729            cfg.clone()
730        };
731        let data_dir = self.data_dir.clone();
732        let _ = tokio::task::spawn_blocking(move || snapshot.save_to_dir(data_dir)).await;
733    }
734}
735
736/// Shared handle to the russh TOFU-observed fingerprint cell (read after deploy).
737pub type FingerprintCell = Arc<Mutex<Option<String>>>;
738
739/// The chosen deployer + (russh only) the observed-fingerprint cell for pinning.
740pub struct DeployerBuild {
741    pub deployer: Box<dyn Deployer>,
742    pub observed_fp: Option<FingerprintCell>,
743}
744
745/// The broker mailbox id for a node's worker (the `ask_agent` target).
746pub fn worker_id_for(node: &Node) -> String {
747    let short: String = node.id.chars().filter(|c| *c != '-').take(8).collect();
748    format!("node-{short}")
749}
750
751/// Resolve a deployed worker's FULL `ProvisionSpec` parent-side (model + creds +
752/// MCP-proxy + bus + identity) — the orchestrator counterpart to the self-resolve
753/// `broker-agent` does from local config. Returned as JSON to ship to the worker
754/// (stdin for local, file-upload for russh). `None` when there are no credentials
755/// to ship and it is not an echo deploy — the worker then self-resolves (legacy
756/// fallback), so we never deploy a real worker with no model/creds.
757fn build_resident_spec(
758    node: &Node,
759    broker_endpoint: &str,
760    broker_token: &str,
761    config: &Config,
762    echo: bool,
763    worker_id: &str,
764) -> Option<String> {
765    build_ondemand_provision_spec(
766        worker_id,
767        node.deploy.default_role.as_deref(),
768        node.deploy.model.as_deref(),
769        node.deploy.workspace.as_deref(),
770        std::env::temp_dir()
771            .join("bamboo-fabric-agents")
772            .join(worker_id),
773        broker_endpoint,
774        broker_token,
775        config,
776        echo,
777    )
778}
779
780/// Build a parent-resolved `ProvisionSpec` (model + creds + MCP-proxy + bus +
781/// identity), serialized as JSON, for an on-demand worker deploy. Shared by
782/// [`build_resident_spec`] (cluster-fabric nodes) and `deploy_agent`'s
783/// `env=docker` path (#46: Docker used to bind-mount the orchestrator's ENTIRE
784/// `~/.bamboo` — including `config.json` and the master
785/// `.bamboo_encryption_key` — into the worker container; this spec ships only
786/// the credentials the assigned model actually needs, over a one-shot stdin
787/// pipe, with no encryption key and no home mount at all). `None` when there
788/// are no credentials to ship and this is not an echo deploy — the caller then
789/// falls back to legacy self-resolve rather than deploying a real worker with
790/// no model/creds.
791///
792/// Credential scoping mirrors `ActorChildRunner::build_spec`
793/// (`external_agents/actor_adapter.rs`): `extract_provider_credentials`
794/// returns every configured provider's key, so it is filtered down to the
795/// single credential matching `spec.model.provider` *after* the model is
796/// resolved — never the raw unfiltered list. This applies to both callers
797/// (cluster-fabric node deploys and the AI-triggered docker path), for the
798/// same least-privilege reason `ActorChildRunner` already scopes its own
799/// workers: a review bot flagged a prior version of this helper for shipping
800/// every configured provider's key regardless of which model was pinned.
801#[allow(clippy::too_many_arguments)]
802pub(crate) fn build_ondemand_provision_spec(
803    worker_id: &str,
804    role: Option<&str>,
805    pinned_model: Option<&str>,
806    workspace: Option<&str>,
807    storage_dir: PathBuf,
808    broker_endpoint: &str,
809    broker_token: &str,
810    config: &Config,
811    echo: bool,
812) -> Option<String> {
813    use bamboo_subagent::provision::{
814        BusEndpoint, ChildIdentity, ExecutorSpec, McpProxyConfig, ModelRefSpec, ProvisionSpec,
815    };
816
817    let all_credentials =
818        bamboo_engine::external_agents::runtime::extract_provider_credentials(config);
819    if all_credentials.is_empty() && !echo {
820        return None;
821    }
822
823    let role = role
824        .map(str::to_string)
825        .unwrap_or_else(|| "general-purpose".to_string());
826    let mut spec = ProvisionSpec::new(
827        ChildIdentity {
828            child_id: worker_id.to_string(),
829            parent_id: None,
830            project_key: None,
831            role,
832            depth: 0,
833        },
834        if echo {
835            ExecutorSpec::Echo
836        } else {
837            ExecutorSpec::BambooRuntime
838        },
839        storage_dir.to_string_lossy().into_owned(),
840    );
841    spec.bus = Some(BusEndpoint {
842        endpoint: broker_endpoint.to_string(),
843        token: broker_token.to_string(),
844    });
845    // Model: the caller's pinned `provider:model`, else the configured
846    // sub-agent / chat default (resolved HERE, on the orchestrator, never on
847    // the worker).
848    spec.model = pinned_model.and_then(parse_provider_model).or_else(|| {
849        config.defaults.as_ref().and_then(|d| {
850            // sub_agent default, else chat. Guard emptiness so we never ship an
851            // invalid `{provider:"", model:""}` spec — a modelless non-echo worker
852            // then fails the presence verify at deploy instead of at first task.
853            let r = d.sub_agent.as_ref().unwrap_or(&d.chat);
854            (!r.provider.trim().is_empty() && !r.model.trim().is_empty()).then(|| ModelRefSpec {
855                provider: r.provider.clone(),
856                model: r.model.clone(),
857            })
858        })
859    });
860    spec.workspace = workspace.map(str::to_string);
861    // Least-privilege secrets: only the credential for the resolved model's
862    // provider ships — never the full `all_credentials` set (#46 follow-up).
863    match spec
864        .model
865        .as_ref()
866        .map(|m| m.provider.as_str())
867        .filter(|p| !p.trim().is_empty())
868    {
869        Some(provider) => match all_credentials.into_iter().find(|c| c.provider == provider) {
870            Some(cred) => spec.secrets.provider_credentials = vec![cred],
871            None => {
872                tracing::warn!(
873                    "ondemand spec for worker {}: no credential found for provider '{}'; \
874                         shipping none",
875                    worker_id,
876                    provider
877                );
878            }
879        },
880        None => {
881            if !echo {
882                tracing::warn!(
883                    "ondemand spec for worker {}: no model provider resolved to scope \
884                     credentials to; shipping none",
885                    worker_id
886                );
887            }
888        }
889    }
890    // Deployed workers proxy ALL MCP to the orchestrator (single MCP host).
891    spec.capabilities.mcp_proxy = Some(McpProxyConfig {
892        orchestrator: ORCHESTRATOR_ID.to_string(),
893        endpoint: broker_endpoint.to_string(),
894        token: broker_token.to_string(),
895    });
896    // `to_json` enforces the mcp XOR mcp_proxy guard before it goes on the wire.
897    spec.to_json().ok()
898}
899
900/// Parse a `provider:model` reference; `None` for empty or provider-less input
901/// (the config-default fallback handles those).
902fn parse_provider_model(s: &str) -> Option<bamboo_subagent::provision::ModelRefSpec> {
903    let s = s.trim();
904    s.split_once(':').and_then(|(p, m)| {
905        (!p.is_empty() && !m.is_empty()).then(|| bamboo_subagent::provision::ModelRefSpec {
906            provider: p.to_string(),
907            model: m.to_string(),
908        })
909    })
910}
911
912/// Short label for which environment a node deploys into.
913pub fn placement_env(node: &Node) -> &'static str {
914    match &node.placement {
915        NodePlacement::Local => "local",
916        NodePlacement::Ssh(_) => "ssh",
917    }
918}
919
920/// Where the worker writes its log: a LOCAL path under the bamboo data dir for
921/// `Local` nodes, a REMOTE path under `remote_dir` for SSH nodes. Read back by
922/// `Deployer::tail_log`.
923pub fn log_path_for(node: &Node) -> String {
924    let worker = worker_id_for(node);
925    match &node.placement {
926        NodePlacement::Local => bamboo_config::paths::resolve_bamboo_dir()
927            .join("fabric-logs")
928            .join(format!("{worker}.log"))
929            .to_string_lossy()
930            .into_owned(),
931        NodePlacement::Ssh(_) => {
932            let dir = node
933                .deploy
934                .remote_dir
935                .clone()
936                .unwrap_or_else(|| ".bamboo-deploy".to_string());
937            format!("{dir}/{worker}.log")
938        }
939    }
940}
941
942/// Remote path to install an uploaded binary at: `<remote_dir>/bamboo[-<sha8>]`.
943/// A relative `remote_dir` resolves to the remote home over scp/ssh/sftp.
944pub fn remote_artifact_path(node: &Node) -> String {
945    let dir = node
946        .deploy
947        .remote_dir
948        .clone()
949        .unwrap_or_else(|| ".bamboo-deploy".to_string());
950    let name = node
951        .deploy
952        .artifact_sha256
953        .as_deref()
954        .filter(|h| h.len() >= 8)
955        .map(|h| format!("bamboo-{}", &h[..8]))
956        .unwrap_or_else(|| "bamboo".to_string());
957    format!("{dir}/{name}")
958}
959
960/// True if a remote `uname -s -m` string (e.g. `"Darwin arm64"`) matches the
961/// orchestrator's own OS + arch, so this process's `bamboo` binary can run there.
962fn remote_matches_orchestrator(uname: &str) -> bool {
963    let os = match std::env::consts::OS {
964        "macos" => "Darwin",
965        "linux" => "Linux",
966        other => other,
967    };
968    let arch = match std::env::consts::ARCH {
969        "aarch64" => "arm64",
970        "x86_64" => "x86_64",
971        other => other,
972    };
973    uname.contains(os) && uname.contains(arch)
974}
975
976/// Round-trip a ping to a freshly-deployed **echo** worker over the bus, proving
977/// the deploy → reverse-tunnel → broker → worker chain is actually live. Returns
978/// `Ok` once the worker echoes back, or a timeout/transport error otherwise.
979async fn verify_echo_worker(broker: &BrokerClientConfig, worker_id: &str) -> Result<(), String> {
980    let me = AgentRef {
981        session_id: format!("{ORCHESTRATOR_ID}-deploy-verify"),
982        role: Some("orchestrator".to_string()),
983    };
984    ask_agent(
985        &broker.endpoint,
986        me,
987        &broker.token,
988        worker_id,
989        "ping",
990        AskMode::Query,
991        Duration::from_secs(30),
992    )
993    .await
994    .map(|_| ())
995    .map_err(|e| e.to_string())
996}
997
998/// Presence probe for a freshly-deployed **non-echo** worker. Unlike the echo
999/// verify it sends NO task (a live LLM worker must not be handed a bogus ping):
1000/// it polls the bus's live-actor registry until the worker's mailbox appears
1001/// under `role`, proving the deploy → tunnel → broker → worker chain came up.
1002/// A worker that never dialed home (wrong arch, missing deps, failed exec) fails
1003/// the deploy HERE instead of silently reporting "Running" and hanging the first
1004/// task. `role` must match what the resident registers under (the spec's
1005/// `default_role`, else the `general-purpose` default).
1006async fn verify_worker_connected(
1007    broker: &BrokerClientConfig,
1008    worker_id: &str,
1009    role: &str,
1010    timeout: Duration,
1011) -> Result<(), String> {
1012    let me = AgentRef {
1013        session_id: format!("{ORCHESTRATOR_ID}-deploy-presence"),
1014        role: Some("orchestrator".to_string()),
1015    };
1016    let mut client = BrokerClient::connect(&broker.endpoint, me, &broker.token)
1017        .await
1018        .map_err(|e| e.to_string())?;
1019    let deadline = Instant::now() + timeout;
1020    loop {
1021        match client.list_connected(role).await {
1022            Ok(ids) if ids.iter().any(|id| id == worker_id) => return Ok(()),
1023            Ok(_) => {}
1024            Err(e) => return Err(e.to_string()),
1025        }
1026        if Instant::now() >= deadline {
1027            return Err(format!(
1028                "worker '{worker_id}' never registered on the bus under role \
1029                 '{role}' within {}s",
1030                timeout.as_secs()
1031            ));
1032        }
1033        tokio::time::sleep(Duration::from_millis(500)).await;
1034    }
1035}
1036
1037/// Build the deployer for a node. `bamboo_bin` is the local `bamboo` path used
1038/// for `placement = Local`. Returns a human-readable error for misconfigured
1039/// nodes (missing secret, etc.).
1040pub fn build_deployer(node: &Node, bamboo_bin: &Path) -> Result<DeployerBuild, String> {
1041    match &node.placement {
1042        NodePlacement::Local => Ok(DeployerBuild {
1043            deployer: Box::new(LocalProcessDeployer::new(bamboo_bin.to_path_buf())),
1044            observed_fp: None,
1045        }),
1046        NodePlacement::Ssh(target) => match &target.auth {
1047            // "Use my ssh config" → system `ssh` (agent/config keys) + upload.
1048            SshAuth::SystemSshConfig => Ok(DeployerBuild {
1049                deployer: Box::new(build_system_ssh(node, target)),
1050                observed_fp: None,
1051            }),
1052            // Stored password / inline key → russh.
1053            SshAuth::Password { .. } | SshAuth::PrivateKey { .. } => {
1054                let russh = build_russh(node, target)?;
1055                let observed_fp = Some(russh.observed_cell());
1056                Ok(DeployerBuild {
1057                    deployer: Box::new(russh),
1058                    observed_fp,
1059                })
1060            }
1061        },
1062    }
1063}
1064
1065fn build_system_ssh(node: &Node, target: &SshTarget) -> SshDeployer {
1066    let host = format!("{}@{}", target.username, target.host);
1067    let upload = node.deploy.artifact_path.as_ref().map(|local| UploadSpec {
1068        local_path: local.clone(),
1069        remote_path: remote_artifact_path(node),
1070    });
1071    SshDeployer::new(host)
1072        .with_port(Some(target.port))
1073        .with_upload(upload)
1074}
1075
1076fn build_russh(node: &Node, target: &SshTarget) -> Result<RusshDeployer, String> {
1077    let auth = match &target.auth {
1078        SshAuth::Password { password, .. } => {
1079            if password.trim().is_empty() {
1080                return Err("node has no stored SSH password".to_string());
1081            }
1082            RusshAuth::Password(password.clone())
1083        }
1084        SshAuth::PrivateKey {
1085            private_key,
1086            private_key_path,
1087            passphrase,
1088            ..
1089        } => {
1090            let pem = if !private_key.trim().is_empty() {
1091                private_key.clone()
1092            } else if let Some(path) = private_key_path {
1093                std::fs::read_to_string(path)
1094                    .map_err(|e| format!("read private key '{path}': {e}"))?
1095            } else {
1096                return Err("node has neither an inline private key nor a key path".to_string());
1097            };
1098            RusshAuth::PrivateKey {
1099                pem,
1100                passphrase: Some(passphrase.clone()).filter(|p| !p.trim().is_empty()),
1101            }
1102        }
1103        SshAuth::SystemSshConfig => {
1104            return Err("build_russh called for SystemSshConfig".to_string());
1105        }
1106    };
1107
1108    let upload = node.deploy.artifact_path.as_ref().map(|local| UploadSpec {
1109        local_path: local.clone(),
1110        remote_path: remote_artifact_path(node),
1111    });
1112
1113    Ok(RusshDeployer::new(
1114        target.host.clone(),
1115        target.port,
1116        target.username.clone(),
1117        auth,
1118    )
1119    .with_fingerprint(target.host_key_fingerprint.clone())
1120    .with_upload(upload))
1121}
1122
1123#[cfg(test)]
1124mod resident_spec_tests {
1125    use super::{build_ondemand_provision_spec, parse_provider_model};
1126    use bamboo_config::Config;
1127
1128    #[test]
1129    fn parse_provider_model_splits_and_guards() {
1130        let r = parse_provider_model("anthropic:claude-opus-4-8").unwrap();
1131        assert_eq!(r.provider, "anthropic");
1132        assert_eq!(r.model, "claude-opus-4-8");
1133        // Bare model (no provider) and empty parts fall back to config defaults.
1134        assert!(parse_provider_model("just-a-model").is_none());
1135        assert!(parse_provider_model(":m").is_none());
1136        assert!(parse_provider_model("p:").is_none());
1137        assert!(parse_provider_model("  ").is_none());
1138    }
1139
1140    /// A single `anthropic` provider instance carrying `key` — the
1141    /// actually-live credential path `extract_provider_credentials` reads
1142    /// (unlike the legacy `providers.anthropic` slot, whose `api_key` is
1143    /// `#[serde(skip_serializing)]` and so never round-trips through the
1144    /// generic serde projection that function uses for legacy slots).
1145    fn config_with_anthropic_key(key: &str) -> Config {
1146        let mut cfg = Config::default();
1147        let instance: bamboo_config::ProviderInstanceConfig =
1148            serde_json::from_value(serde_json::json!({
1149                "provider_type": "anthropic",
1150                "api_key": key,
1151            }))
1152            .expect("minimal ProviderInstanceConfig JSON");
1153        cfg.provider_instances
1154            .insert("anthropic".to_string(), instance);
1155        cfg
1156    }
1157
1158    /// Two provider instances configured (`anthropic` + `openai`) — the
1159    /// multi-provider setup the review on #494 flagged: with more than one
1160    /// provider configured, the ondemand spec must still carry only the ONE
1161    /// credential backing the pinned model, not every configured provider.
1162    fn config_with_two_providers(anthropic_key: &str, openai_key: &str) -> Config {
1163        let mut cfg = config_with_anthropic_key(anthropic_key);
1164        let openai: bamboo_config::ProviderInstanceConfig =
1165            serde_json::from_value(serde_json::json!({
1166                "provider_type": "openai",
1167                "api_key": openai_key,
1168            }))
1169            .expect("minimal ProviderInstanceConfig JSON");
1170        cfg.provider_instances.insert("openai".to_string(), openai);
1171        cfg
1172    }
1173
1174    /// #46 — a real (non-echo) on-demand deploy with no configured credentials
1175    /// must fall back to `None` (caller then declines to hand a worker nothing
1176    /// to authenticate with) rather than shipping an empty/invalid spec.
1177    #[test]
1178    fn ondemand_spec_is_none_without_credentials_and_not_echo() {
1179        let cfg = Config::default();
1180        let spec = build_ondemand_provision_spec(
1181            "w1",
1182            Some("researcher"),
1183            Some("anthropic:claude-opus-4-8"),
1184            None,
1185            std::env::temp_dir().join("bamboo-test-agents").join("w1"),
1186            "ws://broker:9600",
1187            "tok",
1188            &cfg,
1189            false,
1190        );
1191        assert!(spec.is_none());
1192    }
1193
1194    /// echo deploys never need credentials — always produce a spec so the
1195    /// connectivity smoke test can proceed.
1196    #[test]
1197    fn ondemand_spec_is_some_for_echo_even_without_credentials() {
1198        let cfg = Config::default();
1199        let spec = build_ondemand_provision_spec(
1200            "w1",
1201            None,
1202            None,
1203            None,
1204            std::env::temp_dir().join("bamboo-test-agents").join("w1"),
1205            "ws://broker:9600",
1206            "tok",
1207            &cfg,
1208            true,
1209        );
1210        assert!(spec.is_some());
1211    }
1212
1213    /// The core #46 regression guard: the serialized spec carries ONLY the
1214    /// configured provider credential (here `anthropic`) — never the
1215    /// `.bamboo_encryption_key` string, a raw `config.json` blob, or any
1216    /// unrelated provider ("openai" is unset here and must not appear).
1217    #[test]
1218    fn ondemand_spec_carries_only_configured_provider_credential() {
1219        let cfg = config_with_anthropic_key("sk-ant-super-secret");
1220        let spec_json = build_ondemand_provision_spec(
1221            "w1",
1222            Some("researcher"),
1223            Some("anthropic:claude-opus-4-8"),
1224            Some("/workspace"),
1225            std::env::temp_dir().join("bamboo-test-agents").join("w1"),
1226            "ws://broker:9600",
1227            "tok",
1228            &cfg,
1229            false,
1230        )
1231        .expect("credentials configured — spec must be built");
1232
1233        assert!(spec_json.contains("sk-ant-super-secret"));
1234        assert!(spec_json.contains("anthropic"));
1235        // No encryption key, no on-disk config dump, no other provider.
1236        assert!(!spec_json.contains("bamboo_encryption_key"));
1237        assert!(!spec_json.contains("openai"));
1238        assert!(!spec_json.contains("gemini"));
1239
1240        let parsed: bamboo_subagent::provision::ProvisionSpec =
1241            serde_json::from_str(&spec_json).expect("valid ProvisionSpec JSON");
1242        assert_eq!(parsed.secrets.provider_credentials.len(), 1);
1243        assert_eq!(parsed.secrets.provider_credentials[0].provider, "anthropic");
1244        assert_eq!(
1245            parsed.secrets.provider_credentials[0].api_key,
1246            "sk-ant-super-secret"
1247        );
1248        assert_eq!(parsed.workspace.as_deref(), Some("/workspace"));
1249    }
1250
1251    /// #494 review finding: with TWO providers configured, a deploy pinned to
1252    /// `anthropic:...` must ship ONLY the anthropic credential — the prior
1253    /// version unconditionally assigned the entire `extract_provider_credentials`
1254    /// output (every configured provider) regardless of which model was
1255    /// pinned, so this would previously have leaked the openai key too.
1256    #[test]
1257    fn ondemand_spec_scopes_credentials_to_pinned_model_provider_only() {
1258        let cfg = config_with_two_providers("sk-ant-secret", "sk-oai-secret");
1259        let spec_json = build_ondemand_provision_spec(
1260            "w1",
1261            Some("researcher"),
1262            Some("anthropic:claude-opus-4-8"),
1263            None,
1264            std::env::temp_dir().join("bamboo-test-agents").join("w1"),
1265            "ws://broker:9600",
1266            "tok",
1267            &cfg,
1268            false,
1269        )
1270        .expect("credentials configured — spec must be built");
1271
1272        let parsed: bamboo_subagent::provision::ProvisionSpec =
1273            serde_json::from_str(&spec_json).expect("valid ProvisionSpec JSON");
1274        assert_eq!(parsed.secrets.provider_credentials.len(), 1);
1275        assert_eq!(parsed.secrets.provider_credentials[0].provider, "anthropic");
1276        assert_eq!(
1277            parsed.secrets.provider_credentials[0].api_key,
1278            "sk-ant-secret"
1279        );
1280        // The unrelated, but CONFIGURED, openai key must not ship.
1281        assert!(!spec_json.contains("sk-oai-secret"));
1282
1283        // Pin the other provider instead — only ITS credential should ship.
1284        let spec_json_openai = build_ondemand_provision_spec(
1285            "w2",
1286            Some("researcher"),
1287            Some("openai:gpt-5"),
1288            None,
1289            std::env::temp_dir().join("bamboo-test-agents").join("w2"),
1290            "ws://broker:9600",
1291            "tok",
1292            &cfg,
1293            false,
1294        )
1295        .expect("credentials configured — spec must be built");
1296        let parsed_openai: bamboo_subagent::provision::ProvisionSpec =
1297            serde_json::from_str(&spec_json_openai).expect("valid ProvisionSpec JSON");
1298        assert_eq!(parsed_openai.secrets.provider_credentials.len(), 1);
1299        assert_eq!(
1300            parsed_openai.secrets.provider_credentials[0].provider,
1301            "openai"
1302        );
1303        assert!(!spec_json_openai.contains("sk-ant-secret"));
1304    }
1305
1306    /// A pinned model whose provider has no matching configured credential
1307    /// (e.g. typo'd provider id, or a provider that was since removed) must
1308    /// still build a spec — with an EMPTY credential list, not a fallback to
1309    /// every other configured provider's key. Mirrors the idiom already used
1310    /// by `ActorChildRunner::build_spec` (warn + ship nothing) rather than
1311    /// failing the whole deploy.
1312    #[test]
1313    fn ondemand_spec_has_no_credentials_when_pinned_provider_has_no_match() {
1314        let cfg = config_with_two_providers("sk-ant-secret", "sk-oai-secret");
1315        let spec_json = build_ondemand_provision_spec(
1316            "w1",
1317            Some("researcher"),
1318            Some("gemini:gemini-3-pro"),
1319            None,
1320            std::env::temp_dir().join("bamboo-test-agents").join("w1"),
1321            "ws://broker:9600",
1322            "tok",
1323            &cfg,
1324            false,
1325        )
1326        .expect("at least one provider configured overall — spec must be built");
1327
1328        let parsed: bamboo_subagent::provision::ProvisionSpec =
1329            serde_json::from_str(&spec_json).expect("valid ProvisionSpec JSON");
1330        assert!(parsed.secrets.provider_credentials.is_empty());
1331        assert!(!spec_json.contains("sk-ant-secret"));
1332        assert!(!spec_json.contains("sk-oai-secret"));
1333    }
1334}
1335
1336#[cfg(test)]
1337mod presence_verify_tests {
1338    //! The non-echo deploy verify is a task-free presence probe: it must confirm a
1339    //! worker registered on the bus under its role, fail fast when it never came
1340    //! up, and stay role-scoped (so a worker under a different role doesn't count).
1341    use super::verify_worker_connected;
1342    use bamboo_config::BrokerClientConfig;
1343    use std::sync::Arc;
1344    use std::time::Duration;
1345
1346    async fn start_broker() -> (String, tempfile::TempDir) {
1347        let dir = tempfile::tempdir().unwrap();
1348        let core = Arc::new(bamboo_broker::BrokerCore::new(dir.path()));
1349        let server = Arc::new(bamboo_broker::BrokerServer::new(core, "t"));
1350        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1351        let addr = listener.local_addr().unwrap();
1352        tokio::spawn(async move {
1353            let _ = server.serve(listener).await;
1354        });
1355        (format!("ws://{addr}"), dir)
1356    }
1357
1358    /// Connect + subscribe a worker so the broker's live-actor registry lists it
1359    /// under `role` (mailbox id == worker id, matching a resident deploy).
1360    async fn join(endpoint: &str, id: &str, role: &str) -> bamboo_broker::BrokerClient {
1361        let mut c = bamboo_broker::BrokerClient::connect(
1362            endpoint,
1363            bamboo_subagent::AgentRef {
1364                session_id: id.into(),
1365                role: Some(role.into()),
1366            },
1367            "t",
1368        )
1369        .await
1370        .unwrap();
1371        c.subscribe().await.unwrap();
1372        c
1373    }
1374
1375    fn cfg(endpoint: &str) -> BrokerClientConfig {
1376        BrokerClientConfig {
1377            endpoint: endpoint.to_string(),
1378            token: "t".into(),
1379            token_encrypted: None,
1380        }
1381    }
1382
1383    #[tokio::test]
1384    async fn ok_when_worker_registered_under_role() {
1385        let (endpoint, _dir) = start_broker().await;
1386        let _worker = join(&endpoint, "w-mon", "monitor").await;
1387        let out =
1388            verify_worker_connected(&cfg(&endpoint), "w-mon", "monitor", Duration::from_secs(3))
1389                .await;
1390        assert!(out.is_ok(), "present worker should verify: {out:?}");
1391    }
1392
1393    #[tokio::test]
1394    async fn times_out_when_worker_absent() {
1395        let (endpoint, _dir) = start_broker().await;
1396        // Nobody joined "monitor" — the probe must fail fast, never hang.
1397        let out = verify_worker_connected(
1398            &cfg(&endpoint),
1399            "w-mon",
1400            "monitor",
1401            Duration::from_millis(400),
1402        )
1403        .await;
1404        assert!(out.is_err(), "absent worker should fail verify");
1405        assert!(out.unwrap_err().contains("never registered"));
1406    }
1407
1408    #[tokio::test]
1409    async fn role_scoped_ignores_worker_under_other_role() {
1410        let (endpoint, _dir) = start_broker().await;
1411        // Right id, wrong role bucket → must not satisfy a "monitor" probe.
1412        let _other = join(&endpoint, "w-mon", "builder").await;
1413        let out = verify_worker_connected(
1414            &cfg(&endpoint),
1415            "w-mon",
1416            "monitor",
1417            Duration::from_millis(400),
1418        )
1419        .await;
1420        assert!(
1421            out.is_err(),
1422            "a worker under a different role must not count"
1423        );
1424    }
1425}
1426
1427#[cfg(test)]
1428mod health_check_tests {
1429    //! The health probe drives node status live: worker present → Running +
1430    //! last_health; worker gone → Unreachable; a non-deployed node is untouched.
1431    use super::*;
1432    use bamboo_config::cluster_fabric::{
1433        DeployProfile, Node, NodePlacement, NodeState, NodeStatus, TrustLevel,
1434    };
1435    use bamboo_config::{BrokerClientConfig, Config};
1436    use std::collections::HashMap;
1437    use std::sync::Arc;
1438    use tokio::sync::{Mutex, RwLock};
1439
1440    async fn start_broker() -> (String, tempfile::TempDir) {
1441        let dir = tempfile::tempdir().unwrap();
1442        let core = Arc::new(bamboo_broker::BrokerCore::new(dir.path()));
1443        let server = Arc::new(bamboo_broker::BrokerServer::new(core, "t"));
1444        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1445        let addr = listener.local_addr().unwrap();
1446        tokio::spawn(async move {
1447            let _ = server.serve(listener).await;
1448        });
1449        (format!("ws://{addr}"), dir)
1450    }
1451
1452    async fn join(endpoint: &str, id: &str, role: &str) -> bamboo_broker::BrokerClient {
1453        let mut c = bamboo_broker::BrokerClient::connect(
1454            endpoint,
1455            bamboo_subagent::AgentRef {
1456                session_id: id.into(),
1457                role: Some(role.into()),
1458            },
1459            "t",
1460        )
1461        .await
1462        .unwrap();
1463        c.subscribe().await.unwrap();
1464        c
1465    }
1466
1467    fn running_node(id: &str, worker_id: &str, role: &str) -> Node {
1468        Node {
1469            id: id.into(),
1470            label: id.into(),
1471            placement: NodePlacement::Local,
1472            trust_level: TrustLevel::Trusted,
1473            deploy: DeployProfile {
1474                default_role: Some(role.into()),
1475                ..Default::default()
1476            },
1477            state: Some(NodeState {
1478                status: NodeStatus::Running,
1479                worker_id: Some(worker_id.into()),
1480                ..Default::default()
1481            }),
1482            enabled: true,
1483        }
1484    }
1485
1486    fn deployer_with(nodes: Vec<Node>, endpoint: &str) -> Arc<FabricDeployer> {
1487        let mut cfg = Config::default();
1488        cfg.cluster_fabric.nodes = nodes;
1489        cfg.subagents.broker = Some(BrokerClientConfig {
1490            endpoint: endpoint.into(),
1491            token: "t".into(),
1492            token_encrypted: None,
1493        });
1494        Arc::new(FabricDeployer::new(
1495            Arc::new(RwLock::new(cfg)),
1496            Arc::new(Mutex::new(())),
1497            std::env::temp_dir(),
1498            Arc::new(Mutex::new(HashMap::new())),
1499            "/usr/bin/true",
1500        ))
1501    }
1502
1503    #[tokio::test]
1504    async fn keeps_running_when_worker_present() {
1505        let (endpoint, _dir) = start_broker().await;
1506        let _w = join(&endpoint, "node-a", "mon").await;
1507        let d = deployer_with(vec![running_node("a", "node-a", "mon")], &endpoint);
1508        let st = d
1509            .health_check_within("a", Duration::from_secs(3))
1510            .await
1511            .unwrap();
1512        assert_eq!(st.status, NodeStatus::Running);
1513        assert!(st.last_health.is_some(), "heartbeat stamped");
1514        assert!(st.last_error.is_none());
1515    }
1516
1517    #[tokio::test]
1518    async fn flips_to_unreachable_when_worker_gone() {
1519        let (endpoint, _dir) = start_broker().await;
1520        // Nobody joined the bus → the node's worker is absent.
1521        let d = deployer_with(vec![running_node("a", "node-a", "mon")], &endpoint);
1522        let st = d
1523            .health_check_within("a", Duration::from_millis(400))
1524            .await
1525            .unwrap();
1526        assert_eq!(st.status, NodeStatus::Unreachable);
1527        assert!(st.last_error.is_some());
1528    }
1529
1530    #[tokio::test]
1531    async fn leaves_non_deployed_node_untouched() {
1532        let (endpoint, _dir) = start_broker().await;
1533        let mut node = running_node("a", "node-a", "mon");
1534        node.state = Some(NodeState {
1535            status: NodeStatus::Stopped,
1536            ..Default::default()
1537        });
1538        let d = deployer_with(vec![node], &endpoint);
1539        let st = d
1540            .health_check_within("a", Duration::from_millis(400))
1541            .await
1542            .unwrap();
1543        assert_eq!(
1544            st.status,
1545            NodeStatus::Stopped,
1546            "a stopped node is not probed"
1547        );
1548        assert!(st.last_health.is_none(), "no probe → no heartbeat");
1549    }
1550
1551    #[tokio::test]
1552    async fn does_not_clobber_a_concurrent_status_change() {
1553        // The probe runs UNLOCKED; a stop() landing during it must win (otherwise a
1554        // user-Stopped node gets resurrected as Unreachable → wrongly auto-recovered).
1555        let (endpoint, _dir) = start_broker().await;
1556        // No worker joined → the probe runs its full timeout before deciding.
1557        let d = deployer_with(vec![running_node("a", "node-a", "mon")], &endpoint);
1558        let probe = {
1559            let d = d.clone();
1560            tokio::spawn(async move {
1561                d.health_check_within("a", Duration::from_millis(1500))
1562                    .await
1563            })
1564        };
1565        // Mid-probe, flip the node to Stopped as stop() would.
1566        tokio::time::sleep(Duration::from_millis(200)).await;
1567        {
1568            let mut cfg = d.config.write().await;
1569            cfg.cluster_fabric.node_mut("a").unwrap().state = Some(NodeState {
1570                status: NodeStatus::Stopped,
1571                ..Default::default()
1572            });
1573        }
1574        let observed = probe.await.unwrap().unwrap();
1575        assert_eq!(
1576            observed.status,
1577            NodeStatus::Stopped,
1578            "health_check yields to the stop"
1579        );
1580        let cfg = d.config.read().await;
1581        let st = cfg
1582            .cluster_fabric
1583            .node("a")
1584            .unwrap()
1585            .state
1586            .as_ref()
1587            .unwrap();
1588        assert_eq!(
1589            st.status,
1590            NodeStatus::Stopped,
1591            "Stopped not overwritten to Unreachable"
1592        );
1593    }
1594}
1595
1596#[cfg(test)]
1597mod recovery_tests {
1598    //! Auto-recovery POLICY (no bus/deploy needed): opt-in gate, 2-miss debounce,
1599    //! exponential backoff between attempts, and a cap that marks the node Failed.
1600    use super::*;
1601    use bamboo_config::cluster_fabric::{
1602        DeployProfile, Node, NodePlacement, NodeState, NodeStatus, TrustLevel,
1603    };
1604    use bamboo_config::Config;
1605    use tokio::sync::{Mutex, RwLock};
1606
1607    fn recoverable_node(id: &str) -> Node {
1608        Node {
1609            id: id.into(),
1610            label: id.into(),
1611            placement: NodePlacement::Local,
1612            trust_level: TrustLevel::Trusted,
1613            deploy: DeployProfile {
1614                default_role: Some("mon".into()),
1615                auto_recover: true,
1616                ..Default::default()
1617            },
1618            state: Some(NodeState {
1619                status: NodeStatus::Unreachable,
1620                ..Default::default()
1621            }),
1622            enabled: true,
1623        }
1624    }
1625
1626    fn deployer(nodes: Vec<Node>) -> (Arc<FabricDeployer>, tempfile::TempDir) {
1627        let dir = tempfile::tempdir().unwrap();
1628        let mut cfg = Config::default();
1629        cfg.cluster_fabric.nodes = nodes;
1630        let d = Arc::new(FabricDeployer::new(
1631            Arc::new(RwLock::new(cfg)),
1632            Arc::new(Mutex::new(())),
1633            dir.path().to_path_buf(),
1634            Arc::new(Mutex::new(HashMap::new())),
1635            "/usr/bin/true",
1636        ));
1637        (d, dir)
1638    }
1639
1640    fn unreachable() -> NodeState {
1641        NodeState {
1642            status: NodeStatus::Unreachable,
1643            ..Default::default()
1644        }
1645    }
1646
1647    #[tokio::test(start_paused = true)]
1648    async fn debounces_backs_off_then_caps_to_failed() {
1649        let (d, _dir) = deployer(vec![recoverable_node("a")]);
1650        let un = unreachable();
1651
1652        // 1st miss → debounce (no action); 2nd → first redeploy (attempt in flight).
1653        assert_eq!(d.recovery_decision("a", &un).await, None);
1654        assert_eq!(d.recovery_decision("a", &un).await, Some(1));
1655        // In-flight guard: no overlapping attempt, even once backoff elapses.
1656        assert_eq!(d.recovery_decision("a", &un).await, None);
1657        tokio::time::advance(Duration::from_secs(11)).await;
1658        assert_eq!(d.recovery_decision("a", &un).await, None, "still in-flight");
1659        // Redeploy settled (failed) → guard clears; past backoff(1) → attempt 2.
1660        d.clear_recovery_in_flight("a").await;
1661        assert_eq!(d.recovery_decision("a", &un).await, Some(2));
1662        d.clear_recovery_in_flight("a").await;
1663        tokio::time::advance(Duration::from_secs(21)).await;
1664        assert_eq!(d.recovery_decision("a", &un).await, Some(3));
1665        // Past backoff(3) → cap reached → None, and the node is marked Failed.
1666        d.clear_recovery_in_flight("a").await;
1667        tokio::time::advance(Duration::from_secs(41)).await;
1668        assert_eq!(d.recovery_decision("a", &un).await, None);
1669        let cfg = d.config.read().await;
1670        let st = cfg
1671            .cluster_fabric
1672            .node("a")
1673            .unwrap()
1674            .state
1675            .as_ref()
1676            .unwrap();
1677        assert_eq!(st.status, NodeStatus::Failed);
1678        assert!(st
1679            .last_error
1680            .as_deref()
1681            .unwrap_or_default()
1682            .contains("gave up"));
1683    }
1684
1685    #[tokio::test]
1686    async fn no_recovery_when_flag_off() {
1687        let mut node = recoverable_node("a");
1688        node.deploy.auto_recover = false;
1689        let (d, _dir) = deployer(vec![node]);
1690        let un = unreachable();
1691        assert_eq!(d.recovery_decision("a", &un).await, None);
1692        assert_eq!(
1693            d.recovery_decision("a", &un).await,
1694            None,
1695            "opt-out never redeploys"
1696        );
1697    }
1698
1699    #[tokio::test]
1700    async fn recovered_node_clears_progress() {
1701        let (d, _dir) = deployer(vec![recoverable_node("a")]);
1702        let un = unreachable();
1703        let ok = NodeState {
1704            status: NodeStatus::Running,
1705            ..Default::default()
1706        };
1707        assert_eq!(d.recovery_decision("a", &un).await, None); // miss 1
1708        assert_eq!(d.recovery_decision("a", &ok).await, None); // recovered → reset
1709                                                               // Debounce restarts from zero, so it takes another 2 misses.
1710        assert_eq!(d.recovery_decision("a", &un).await, None);
1711        assert_eq!(d.recovery_decision("a", &un).await, Some(1));
1712    }
1713}