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        };
204
205        // Release any prior worker FIRST so its reverse tunnel frees the broker
206        // port before the new deploy requests the same forward.
207        if let Some(prev) = self
208            .registry
209            .lock()
210            .await
211            .remove(&crate::registry_keys::node_key(node_id))
212        {
213            prev.handle.shutdown().await;
214        }
215
216        let handle = match build.deployer.deploy(&deployment).await {
217            Ok(h) => h,
218            Err(e) => {
219                let failed = NodeState {
220                    status: NodeStatus::Failed,
221                    last_error: Some(e.to_string()),
222                    ..Default::default()
223                };
224                let _ = self.persist_state(node_id, Some(failed)).await;
225                tracing::warn!(
226                    audit = "cluster_fabric.deploy",
227                    node = node_id,
228                    placement = placement_env(&node),
229                    outcome = "failed",
230                    error = %e,
231                );
232                return Err(FabricError::Internal(format!(
233                    "deploy node '{node_id}' failed: {e}"
234                )));
235            }
236        };
237        let pid = handle.pid();
238        tracing::info!(
239            audit = "cluster_fabric.deploy",
240            node = node_id,
241            placement = placement_env(&node),
242            worker_id = %worker_id,
243            echo,
244            outcome = "deployed",
245        );
246
247        self.registry.lock().await.insert(
248            crate::registry_keys::node_key(node_id),
249            Deployed {
250                env: placement_env(&node).to_string(),
251                handle,
252            },
253        );
254
255        // TOFU: pin the observed host-key fingerprint if not already set.
256        if let Some(cell) = build.observed_fp {
257            if let Some(fp) = cell.lock().await.clone() {
258                self.pin_fingerprint_if_absent(node_id, &fp).await;
259            }
260        }
261
262        // Verify-on-deploy: exec'ing the worker used to report "running" even when
263        // the worker never dialed home (phantom success — e.g. a missing/incompatible
264        // binary silently failed to exec). Surface a broken deploy→tunnel→broker→worker
265        // chain HERE, not as a hung ask on the first real task.
266        //   • echo executor → round-trip a `ping` (proves the executor loop runs).
267        //   • real executor → presence probe on the bus. A live LLM worker must NOT
268        //     be handed a bogus task, so we only confirm it registered its mailbox —
269        //     enough to prove the chain is live. The role matches what the resident
270        //     registers under (the spec's `default_role`, else `general-purpose`).
271        let verify = if echo {
272            verify_echo_worker(&broker, &worker_id).await
273        } else {
274            let role = node
275                .deploy
276                .default_role
277                .clone()
278                .unwrap_or_else(|| "general-purpose".to_string());
279            verify_worker_connected(&broker, &worker_id, &role, Duration::from_secs(30)).await
280        };
281        if let Err(e) = verify {
282            // Tear the half-dead worker down and report the real failure.
283            if let Some(d) = self
284                .registry
285                .lock()
286                .await
287                .remove(&crate::registry_keys::node_key(node_id))
288            {
289                d.handle.shutdown().await;
290            }
291            let msg = format!(
292                "worker deployed but never came up on the bus (verify failed): {e} — \
293                 check that `bamboo` runs on the remote (arch/deps) and see the node log"
294            );
295            let failed = NodeState {
296                status: NodeStatus::Failed,
297                worker_id: Some(worker_id.clone()),
298                log_path: Some(log_path.clone()),
299                last_error: Some(msg.clone()),
300                ..Default::default()
301            };
302            let _ = self.persist_state(node_id, Some(failed)).await;
303            tracing::warn!(
304                audit = "cluster_fabric.deploy",
305                node = node_id,
306                worker_id = %worker_id,
307                echo,
308                outcome = "verify_failed",
309                error = %e,
310            );
311            return Err(FabricError::Internal(format!(
312                "deploy node '{node_id}': {msg}"
313            )));
314        }
315        tracing::info!(
316            node = node_id,
317            worker_id = %worker_id,
318            echo,
319            "deploy verify ok — worker is reachable on the bus"
320        );
321
322        let state = NodeState {
323            status: NodeStatus::Running,
324            worker_id: Some(worker_id),
325            remote_pid: pid,
326            log_path: Some(log_path),
327            deployed_at: Some(chrono::Utc::now().to_rfc3339()),
328            ..Default::default()
329        };
330        self.persist_state(node_id, Some(state.clone())).await?;
331        Ok(state)
332    }
333
334    /// Stop a node's worker (if running) and persist the stopped state.
335    pub async fn stop(&self, node_id: &str) -> FabricResult<NodeState> {
336        {
337            let cfg = self.config.read().await;
338            self.node_snapshot(&cfg, node_id)?;
339        }
340        if let Some(d) = self
341            .registry
342            .lock()
343            .await
344            .remove(&crate::registry_keys::node_key(node_id))
345        {
346            d.handle.shutdown().await;
347        }
348        tracing::info!(
349            audit = "cluster_fabric.stop",
350            node = node_id,
351            outcome = "stopped"
352        );
353        let state = NodeState {
354            status: NodeStatus::Stopped,
355            ..Default::default()
356        };
357        self.persist_state(node_id, Some(state.clone())).await?;
358        Ok(state)
359    }
360
361    /// Connectivity preflight: connect + auth + `uname`, WITHOUT deploying.
362    pub async fn test(&self, node_id: &str) -> FabricResult<String> {
363        let node = {
364            let cfg = self.config.read().await;
365            self.node_snapshot(&cfg, node_id)?
366        };
367        let build = build_deployer(&node, &self.bamboo_bin).map_err(FabricError::BadRequest)?;
368        let result = build.deployer.preflight().await;
369        tracing::info!(
370            audit = "cluster_fabric.test",
371            node = node_id,
372            placement = placement_env(&node),
373            outcome = if result.is_ok() { "ok" } else { "failed" },
374        );
375        result.map_err(|e| FabricError::Internal(format!("preflight failed: {e}")))
376    }
377
378    /// Tail the last `lines` lines of a node worker's log.
379    pub async fn read_logs(&self, node_id: &str, lines: usize) -> FabricResult<String> {
380        let node = {
381            let cfg = self.config.read().await;
382            self.node_snapshot(&cfg, node_id)?
383        };
384        let log_path = node
385            .state
386            .as_ref()
387            .and_then(|s| s.log_path.clone())
388            .unwrap_or_else(|| log_path_for(&node));
389        let build = build_deployer(&node, &self.bamboo_bin).map_err(FabricError::BadRequest)?;
390        build
391            .deployer
392            .tail_log(&log_path, lines)
393            .await
394            .map_err(|e| FabricError::Internal(format!("read logs failed: {e}")))
395    }
396
397    /// Single-probe timeout. Fast when the worker is present (returns on first
398    /// sighting); this only bounds how long a genuinely-gone worker is chased.
399    const HEALTH_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
400
401    /// Health-check `node_id` with the production probe timeout.
402    pub async fn health_check(&self, node_id: &str) -> FabricResult<NodeState> {
403        self.health_check_within(node_id, Self::HEALTH_PROBE_TIMEOUT)
404            .await
405    }
406
407    /// Probe a Running/Unreachable node's worker on the bus and reconcile its live
408    /// state: a worker present on the bus → `Running` + fresh `last_health`; a
409    /// vanished one → `Unreachable`. Nodes not meant to be up
410    /// (NotDeployed/Deploying/Stopped/Failed) are left untouched. A status FLIP is
411    /// persisted to disk (durable + audited); a steady-state heartbeat only
412    /// refreshes `last_health` in memory, so a healthy cluster doesn't rewrite
413    /// config.json every tick. Presence is checked (never a task ping), so a live
414    /// LLM worker is never disturbed — same rationale as the deploy verify.
415    async fn health_check_within(
416        &self,
417        node_id: &str,
418        probe_timeout: Duration,
419    ) -> FabricResult<NodeState> {
420        let (node, broker) = {
421            let cfg = self.config.read().await;
422            (
423                self.node_snapshot(&cfg, node_id)?,
424                cfg.subagents.broker.clone(),
425            )
426        };
427        let current = node.state.clone().unwrap_or_default();
428        if !matches!(
429            current.status,
430            NodeStatus::Running | NodeStatus::Unreachable
431        ) {
432            return Ok(current); // only nodes that should be up are monitored
433        }
434        let worker_id = current
435            .worker_id
436            .clone()
437            .unwrap_or_else(|| worker_id_for(&node));
438        let role = node
439            .deploy
440            .default_role
441            .clone()
442            .unwrap_or_else(|| "general-purpose".to_string());
443        let Some(broker) = broker.filter(|b| !b.endpoint.trim().is_empty()) else {
444            return Ok(current); // no broker configured → nothing to probe against
445        };
446
447        // Fast when present (returns on first sighting); costs the timeout only
448        // when the worker is genuinely gone. The short window absorbs a blip — a
449        // false Unreachable self-corrects on the next tick.
450        let alive = verify_worker_connected(&broker, &worker_id, &role, probe_timeout)
451            .await
452            .is_ok();
453
454        let new_status = if alive {
455            NodeStatus::Running
456        } else {
457            NodeStatus::Unreachable
458        };
459        let new_error = if alive {
460            None
461        } else {
462            Some(format!(
463                "worker '{worker_id}' not present on the bus under role '{role}'"
464            ))
465        };
466
467        // Commit under the io + write lock, RE-CHECKING the live status. The probe
468        // above ran UNLOCKED for up to `probe_timeout`, so a concurrent stop()/
469        // deploy() may have moved this node out of the monitored set meanwhile —
470        // blindly writing back the stale decision would e.g. resurrect a
471        // user-Stopped node as Unreachable and hand it to auto-recover. If the live
472        // status is no longer Running/Unreachable, the concurrent write wins.
473        let _io = self.config_io_lock.lock().await;
474        let (next, from, snapshot) = {
475            let mut cfg = self.config.write().await;
476            let Some(node) = cfg.cluster_fabric.node_mut(node_id) else {
477                return Ok(current);
478            };
479            let live = node.state.clone().unwrap_or_default();
480            if !matches!(live.status, NodeStatus::Running | NodeStatus::Unreachable) {
481                return Ok(live); // moved out of the monitored set mid-probe → leave it
482            }
483            let from = live.status;
484            let next = NodeState {
485                status: new_status,
486                last_health: Some(chrono::Utc::now().to_rfc3339()),
487                last_error: new_error,
488                ..live
489            };
490            node.state = Some(next.clone());
491            // A status FLIP is durable + audited; a steady-state heartbeat stays in
492            // memory only (no config.json churn) — so snapshot to disk only on a flip.
493            (next, from, (from != new_status).then(|| cfg.clone()))
494        };
495        if let Some(snapshot) = snapshot {
496            let data_dir = self.data_dir.clone();
497            tokio::task::spawn_blocking(move || snapshot.save_to_dir(data_dir))
498                .await
499                .map_err(|e| FabricError::Internal(format!("persist task: {e}")))?
500                .map_err(|e| FabricError::Internal(format!("save config: {e}")))?;
501            tracing::info!(
502                audit = "cluster_fabric.health",
503                node = node_id,
504                worker_id = %worker_id,
505                from = ?from,
506                to = ?next.status,
507                "node health changed",
508            );
509        }
510        Ok(next)
511    }
512
513    /// Node ids whose persisted status is Running or Unreachable — the set the
514    /// health monitor sweeps.
515    async fn monitored_node_ids(&self) -> Vec<String> {
516        let cfg = self.config.read().await;
517        cfg.cluster_fabric
518            .nodes
519            .iter()
520            .filter(|n| {
521                n.state.as_ref().is_some_and(|s| {
522                    matches!(s.status, NodeStatus::Running | NodeStatus::Unreachable)
523                })
524            })
525            .map(|n| n.id.clone())
526            .collect()
527    }
528
529    /// Decide whether to auto-recover `node_id` given its just-probed `state`,
530    /// advancing the debounce/backoff bookkeeping. `Some(attempt)` ⇒ the caller
531    /// should redeploy now; `None` ⇒ hold (not opted in, still debouncing, inside
532    /// backoff, or exhausted). On exhausting [`RECOVERY_MAX_ATTEMPTS`] it marks the
533    /// node `Failed` once and stops. A node that is no longer Unreachable clears its
534    /// recovery progress (a recovered node starts fresh next outage). A user-Stopped
535    /// node never reaches here — `health_check` only probes Running/Unreachable.
536    async fn recovery_decision(&self, node_id: &str, state: &NodeState) -> Option<u32> {
537        if state.status != NodeStatus::Unreachable {
538            self.recovery.lock().await.remove(node_id);
539            return None;
540        }
541        let auto = {
542            let cfg = self.config.read().await;
543            cfg.cluster_fabric
544                .node(node_id)
545                .map(|n| n.deploy.auto_recover)
546                .unwrap_or(false)
547        };
548        if !auto {
549            return None;
550        }
551
552        let mut map = self.recovery.lock().await;
553        let rs = map.entry(node_id.to_string()).or_default();
554        rs.consecutive_unreachable = rs.consecutive_unreachable.saturating_add(1);
555        if rs.consecutive_unreachable < RECOVERY_DEBOUNCE {
556            return None; // ride out a blip before touching a live node
557        }
558        if rs.in_flight {
559            return None; // a redeploy is still running — don't overlap it
560        }
561        if rs.attempts >= RECOVERY_MAX_ATTEMPTS {
562            let first_give_up = !rs.gave_up;
563            rs.gave_up = true;
564            drop(map);
565            if first_give_up {
566                self.mark_failed(
567                    node_id,
568                    &format!("auto-recover gave up after {RECOVERY_MAX_ATTEMPTS} attempts"),
569                )
570                .await;
571            }
572            return None;
573        }
574        if let Some(t) = rs.next_eligible {
575            if tokio::time::Instant::now() < t {
576                return None; // inside backoff
577            }
578        }
579        rs.attempts += 1;
580        rs.in_flight = true;
581        let attempt = rs.attempts;
582        rs.next_eligible = Some(tokio::time::Instant::now() + Self::recovery_backoff(attempt));
583        Some(attempt)
584    }
585
586    /// Clear the in-flight guard after a recovery redeploy settles, so a later tick
587    /// can retry (on failure); on success the next `health_check` resets the entry.
588    async fn clear_recovery_in_flight(&self, node_id: &str) {
589        if let Some(rs) = self.recovery.lock().await.get_mut(node_id) {
590            rs.in_flight = false;
591        }
592    }
593
594    /// Exponential backoff between recovery attempts: ~10s, 20s, 40s… capped 300s.
595    fn recovery_backoff(attempt: u32) -> Duration {
596        let shift = attempt.saturating_sub(1).min(5);
597        Duration::from_secs((10u64 << shift).min(300))
598    }
599
600    /// Persist a node as `Failed` with `reason`, preserving its other engine fields.
601    async fn mark_failed(&self, node_id: &str, reason: &str) {
602        let current = {
603            let cfg = self.config.read().await;
604            cfg.cluster_fabric
605                .node(node_id)
606                .and_then(|n| n.state.clone())
607                .unwrap_or_default()
608        };
609        let failed = NodeState {
610            status: NodeStatus::Failed,
611            last_error: Some(reason.to_string()),
612            ..current
613        };
614        if let Err(e) = self.persist_state(node_id, Some(failed)).await {
615            tracing::warn!(node = node_id, error = %e, "failed to persist Failed state");
616        }
617        tracing::warn!(
618            audit = "cluster_fabric.recover",
619            node = node_id,
620            reason,
621            "auto-recover exhausted → Failed",
622        );
623    }
624
625    /// Spawn the background health monitor: every `cluster_fabric.health_interval`
626    /// it [`health_check`](Self::health_check)s each Running/Unreachable node.
627    /// `None` (no task) when disabled (`health_interval_secs = 0`); abort the
628    /// handle to stop it. The cadence is read once at spawn (change → restart).
629    pub async fn spawn_health_monitor(self: Arc<Self>) -> Option<tokio::task::JoinHandle<()>> {
630        let interval = {
631            let cfg = self.config.read().await;
632            cfg.cluster_fabric.health_interval()?
633        };
634        tracing::info!(
635            interval_secs = interval.as_secs(),
636            "cluster health monitor started"
637        );
638        Some(tokio::spawn(async move {
639            let mut tick = tokio::time::interval(interval);
640            tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
641            tick.tick().await; // consume the immediate first tick
642            loop {
643                tick.tick().await;
644                for id in self.monitored_node_ids().await {
645                    match self.health_check(&id).await {
646                        Ok(state) => {
647                            if let Some(attempt) = self.recovery_decision(&id, &state).await {
648                                // Redeploy OFF the sweep's critical path so a slow
649                                // deploy doesn't stall other nodes' health checks;
650                                // the backoff gate was already advanced, so the next
651                                // tick won't re-trigger until it elapses.
652                                let this = self.clone();
653                                let node = id.clone();
654                                tokio::spawn(async move {
655                                    tracing::warn!(
656                                        audit = "cluster_fabric.recover",
657                                        node = %node,
658                                        attempt,
659                                        "auto-recovering unreachable node",
660                                    );
661                                    match this.deploy(&node, false).await {
662                                        Ok(_) => tracing::info!(
663                                            node = %node,
664                                            attempt,
665                                            "auto-recover redeploy succeeded"
666                                        ),
667                                        Err(e) => tracing::warn!(
668                                            node = %node,
669                                            attempt,
670                                            error = %e,
671                                            "auto-recover redeploy failed"
672                                        ),
673                                    }
674                                    this.clear_recovery_in_flight(&node).await;
675                                });
676                            }
677                        }
678                        Err(e) => tracing::warn!(node = %id, error = %e, "health check failed"),
679                    }
680                }
681            }
682        }))
683    }
684
685    /// Persist `state` onto a node (engine-owned field): io-lock + atomic save,
686    /// mirroring `AppState::update_config` minus the provider/MCP side effects.
687    async fn persist_state(&self, node_id: &str, state: Option<NodeState>) -> FabricResult<()> {
688        let _io = self.config_io_lock.lock().await;
689        let snapshot = {
690            let mut cfg = self.config.write().await;
691            let node = cfg
692                .cluster_fabric
693                .node_mut(node_id)
694                .ok_or_else(|| FabricError::NotFound(format!("Node '{node_id}'")))?;
695            node.state = state;
696            cfg.clone()
697        };
698        let data_dir = self.data_dir.clone();
699        tokio::task::spawn_blocking(move || snapshot.save_to_dir(data_dir))
700            .await
701            .map_err(|e| FabricError::Internal(format!("persist task: {e}")))?
702            .map_err(|e| FabricError::Internal(format!("save config: {e}")))?;
703        Ok(())
704    }
705
706    /// Pin `fp` onto a node's SSH target if it has no fingerprint yet (TOFU).
707    async fn pin_fingerprint_if_absent(&self, node_id: &str, fp: &str) {
708        let _io = self.config_io_lock.lock().await;
709        let snapshot = {
710            let mut cfg = self.config.write().await;
711            if let Some(node) = cfg.cluster_fabric.node_mut(node_id) {
712                if let NodePlacement::Ssh(target) = &mut node.placement {
713                    if target.host_key_fingerprint.is_some() {
714                        return;
715                    }
716                    target.host_key_fingerprint = Some(fp.to_string());
717                }
718            }
719            cfg.clone()
720        };
721        let data_dir = self.data_dir.clone();
722        let _ = tokio::task::spawn_blocking(move || snapshot.save_to_dir(data_dir)).await;
723    }
724}
725
726/// Shared handle to the russh TOFU-observed fingerprint cell (read after deploy).
727pub type FingerprintCell = Arc<Mutex<Option<String>>>;
728
729/// The chosen deployer + (russh only) the observed-fingerprint cell for pinning.
730pub struct DeployerBuild {
731    pub deployer: Box<dyn Deployer>,
732    pub observed_fp: Option<FingerprintCell>,
733}
734
735/// The broker mailbox id for a node's worker (the `ask_agent` target).
736pub fn worker_id_for(node: &Node) -> String {
737    let short: String = node.id.chars().filter(|c| *c != '-').take(8).collect();
738    format!("node-{short}")
739}
740
741/// Resolve a deployed worker's FULL `ProvisionSpec` parent-side (model + creds +
742/// MCP-proxy + bus + identity) — the orchestrator counterpart to the self-resolve
743/// `broker-agent` does from local config. Returned as JSON to ship to the worker
744/// (stdin for local, file-upload for russh). `None` when there are no credentials
745/// to ship and it is not an echo deploy — the worker then self-resolves (legacy
746/// fallback), so we never deploy a real worker with no model/creds.
747fn build_resident_spec(
748    node: &Node,
749    broker_endpoint: &str,
750    broker_token: &str,
751    config: &Config,
752    echo: bool,
753    worker_id: &str,
754) -> Option<String> {
755    use bamboo_subagent::provision::{
756        BusEndpoint, ChildIdentity, ExecutorSpec, McpProxyConfig, ModelRefSpec, ProvisionSpec,
757    };
758
759    let credentials = bamboo_engine::external_agents::runtime::extract_provider_credentials(config);
760    if credentials.is_empty() && !echo {
761        return None;
762    }
763
764    let role = node
765        .deploy
766        .default_role
767        .clone()
768        .unwrap_or_else(|| "general-purpose".to_string());
769    let mut spec = ProvisionSpec::new(
770        ChildIdentity {
771            child_id: worker_id.to_string(),
772            parent_id: None,
773            project_key: None,
774            role,
775            depth: 0,
776        },
777        if echo {
778            ExecutorSpec::Echo
779        } else {
780            ExecutorSpec::BambooRuntime
781        },
782        std::env::temp_dir()
783            .join("bamboo-fabric-agents")
784            .join(worker_id)
785            .to_string_lossy()
786            .into_owned(),
787    );
788    spec.bus = Some(BusEndpoint {
789        endpoint: broker_endpoint.to_string(),
790        token: broker_token.to_string(),
791    });
792    // Model: the node's pinned `provider:model`, else the configured sub-agent /
793    // chat default (resolved HERE, on the orchestrator, not on the remote).
794    spec.model = node
795        .deploy
796        .model
797        .as_deref()
798        .and_then(parse_provider_model)
799        .or_else(|| {
800            config.defaults.as_ref().and_then(|d| {
801                // sub_agent default, else chat. Guard emptiness so we never ship an
802                // invalid `{provider:"", model:""}` spec — a modelless non-echo worker
803                // then fails the presence verify at deploy instead of at first task.
804                let r = d.sub_agent.as_ref().unwrap_or(&d.chat);
805                (!r.provider.trim().is_empty() && !r.model.trim().is_empty()).then(|| {
806                    ModelRefSpec {
807                        provider: r.provider.clone(),
808                        model: r.model.clone(),
809                    }
810                })
811            })
812        });
813    spec.workspace = node.deploy.workspace.clone();
814    spec.secrets.provider_credentials = credentials;
815    // Deployed workers proxy ALL MCP to the orchestrator (single MCP host).
816    spec.capabilities.mcp_proxy = Some(McpProxyConfig {
817        orchestrator: ORCHESTRATOR_ID.to_string(),
818        endpoint: broker_endpoint.to_string(),
819        token: broker_token.to_string(),
820    });
821    // `to_json` enforces the mcp XOR mcp_proxy guard before it goes on the wire.
822    spec.to_json().ok()
823}
824
825/// Parse a `provider:model` reference; `None` for empty or provider-less input
826/// (the config-default fallback handles those).
827fn parse_provider_model(s: &str) -> Option<bamboo_subagent::provision::ModelRefSpec> {
828    let s = s.trim();
829    s.split_once(':').and_then(|(p, m)| {
830        (!p.is_empty() && !m.is_empty()).then(|| bamboo_subagent::provision::ModelRefSpec {
831            provider: p.to_string(),
832            model: m.to_string(),
833        })
834    })
835}
836
837/// Short label for which environment a node deploys into.
838pub fn placement_env(node: &Node) -> &'static str {
839    match &node.placement {
840        NodePlacement::Local => "local",
841        NodePlacement::Ssh(_) => "ssh",
842    }
843}
844
845/// Where the worker writes its log: a LOCAL path under the bamboo data dir for
846/// `Local` nodes, a REMOTE path under `remote_dir` for SSH nodes. Read back by
847/// `Deployer::tail_log`.
848pub fn log_path_for(node: &Node) -> String {
849    let worker = worker_id_for(node);
850    match &node.placement {
851        NodePlacement::Local => bamboo_config::paths::resolve_bamboo_dir()
852            .join("fabric-logs")
853            .join(format!("{worker}.log"))
854            .to_string_lossy()
855            .into_owned(),
856        NodePlacement::Ssh(_) => {
857            let dir = node
858                .deploy
859                .remote_dir
860                .clone()
861                .unwrap_or_else(|| ".bamboo-deploy".to_string());
862            format!("{dir}/{worker}.log")
863        }
864    }
865}
866
867/// Remote path to install an uploaded binary at: `<remote_dir>/bamboo[-<sha8>]`.
868/// A relative `remote_dir` resolves to the remote home over scp/ssh/sftp.
869pub fn remote_artifact_path(node: &Node) -> String {
870    let dir = node
871        .deploy
872        .remote_dir
873        .clone()
874        .unwrap_or_else(|| ".bamboo-deploy".to_string());
875    let name = node
876        .deploy
877        .artifact_sha256
878        .as_deref()
879        .filter(|h| h.len() >= 8)
880        .map(|h| format!("bamboo-{}", &h[..8]))
881        .unwrap_or_else(|| "bamboo".to_string());
882    format!("{dir}/{name}")
883}
884
885/// True if a remote `uname -s -m` string (e.g. `"Darwin arm64"`) matches the
886/// orchestrator's own OS + arch, so this process's `bamboo` binary can run there.
887fn remote_matches_orchestrator(uname: &str) -> bool {
888    let os = match std::env::consts::OS {
889        "macos" => "Darwin",
890        "linux" => "Linux",
891        other => other,
892    };
893    let arch = match std::env::consts::ARCH {
894        "aarch64" => "arm64",
895        "x86_64" => "x86_64",
896        other => other,
897    };
898    uname.contains(os) && uname.contains(arch)
899}
900
901/// Round-trip a ping to a freshly-deployed **echo** worker over the bus, proving
902/// the deploy → reverse-tunnel → broker → worker chain is actually live. Returns
903/// `Ok` once the worker echoes back, or a timeout/transport error otherwise.
904async fn verify_echo_worker(broker: &BrokerClientConfig, worker_id: &str) -> Result<(), String> {
905    let me = AgentRef {
906        session_id: format!("{ORCHESTRATOR_ID}-deploy-verify"),
907        role: Some("orchestrator".to_string()),
908    };
909    ask_agent(
910        &broker.endpoint,
911        me,
912        &broker.token,
913        worker_id,
914        "ping",
915        AskMode::Query,
916        Duration::from_secs(30),
917    )
918    .await
919    .map(|_| ())
920    .map_err(|e| e.to_string())
921}
922
923/// Presence probe for a freshly-deployed **non-echo** worker. Unlike the echo
924/// verify it sends NO task (a live LLM worker must not be handed a bogus ping):
925/// it polls the bus's live-actor registry until the worker's mailbox appears
926/// under `role`, proving the deploy → tunnel → broker → worker chain came up.
927/// A worker that never dialed home (wrong arch, missing deps, failed exec) fails
928/// the deploy HERE instead of silently reporting "Running" and hanging the first
929/// task. `role` must match what the resident registers under (the spec's
930/// `default_role`, else the `general-purpose` default).
931async fn verify_worker_connected(
932    broker: &BrokerClientConfig,
933    worker_id: &str,
934    role: &str,
935    timeout: Duration,
936) -> Result<(), String> {
937    let me = AgentRef {
938        session_id: format!("{ORCHESTRATOR_ID}-deploy-presence"),
939        role: Some("orchestrator".to_string()),
940    };
941    let mut client = BrokerClient::connect(&broker.endpoint, me, &broker.token)
942        .await
943        .map_err(|e| e.to_string())?;
944    let deadline = Instant::now() + timeout;
945    loop {
946        match client.list_connected(role).await {
947            Ok(ids) if ids.iter().any(|id| id == worker_id) => return Ok(()),
948            Ok(_) => {}
949            Err(e) => return Err(e.to_string()),
950        }
951        if Instant::now() >= deadline {
952            return Err(format!(
953                "worker '{worker_id}' never registered on the bus under role \
954                 '{role}' within {}s",
955                timeout.as_secs()
956            ));
957        }
958        tokio::time::sleep(Duration::from_millis(500)).await;
959    }
960}
961
962/// Build the deployer for a node. `bamboo_bin` is the local `bamboo` path used
963/// for `placement = Local`. Returns a human-readable error for misconfigured
964/// nodes (missing secret, etc.).
965pub fn build_deployer(node: &Node, bamboo_bin: &Path) -> Result<DeployerBuild, String> {
966    match &node.placement {
967        NodePlacement::Local => Ok(DeployerBuild {
968            deployer: Box::new(LocalProcessDeployer::new(bamboo_bin.to_path_buf())),
969            observed_fp: None,
970        }),
971        NodePlacement::Ssh(target) => match &target.auth {
972            // "Use my ssh config" → system `ssh` (agent/config keys) + upload.
973            SshAuth::SystemSshConfig => Ok(DeployerBuild {
974                deployer: Box::new(build_system_ssh(node, target)),
975                observed_fp: None,
976            }),
977            // Stored password / inline key → russh.
978            SshAuth::Password { .. } | SshAuth::PrivateKey { .. } => {
979                let russh = build_russh(node, target)?;
980                let observed_fp = Some(russh.observed_cell());
981                Ok(DeployerBuild {
982                    deployer: Box::new(russh),
983                    observed_fp,
984                })
985            }
986        },
987    }
988}
989
990fn build_system_ssh(node: &Node, target: &SshTarget) -> SshDeployer {
991    let host = format!("{}@{}", target.username, target.host);
992    let upload = node.deploy.artifact_path.as_ref().map(|local| UploadSpec {
993        local_path: local.clone(),
994        remote_path: remote_artifact_path(node),
995    });
996    SshDeployer::new(host)
997        .with_port(Some(target.port))
998        .with_upload(upload)
999}
1000
1001fn build_russh(node: &Node, target: &SshTarget) -> Result<RusshDeployer, String> {
1002    let auth = match &target.auth {
1003        SshAuth::Password { password, .. } => {
1004            if password.trim().is_empty() {
1005                return Err("node has no stored SSH password".to_string());
1006            }
1007            RusshAuth::Password(password.clone())
1008        }
1009        SshAuth::PrivateKey {
1010            private_key,
1011            private_key_path,
1012            passphrase,
1013            ..
1014        } => {
1015            let pem = if !private_key.trim().is_empty() {
1016                private_key.clone()
1017            } else if let Some(path) = private_key_path {
1018                std::fs::read_to_string(path)
1019                    .map_err(|e| format!("read private key '{path}': {e}"))?
1020            } else {
1021                return Err("node has neither an inline private key nor a key path".to_string());
1022            };
1023            RusshAuth::PrivateKey {
1024                pem,
1025                passphrase: Some(passphrase.clone()).filter(|p| !p.trim().is_empty()),
1026            }
1027        }
1028        SshAuth::SystemSshConfig => {
1029            return Err("build_russh called for SystemSshConfig".to_string());
1030        }
1031    };
1032
1033    let upload = node.deploy.artifact_path.as_ref().map(|local| UploadSpec {
1034        local_path: local.clone(),
1035        remote_path: remote_artifact_path(node),
1036    });
1037
1038    Ok(RusshDeployer::new(
1039        target.host.clone(),
1040        target.port,
1041        target.username.clone(),
1042        auth,
1043    )
1044    .with_fingerprint(target.host_key_fingerprint.clone())
1045    .with_upload(upload))
1046}
1047
1048#[cfg(test)]
1049mod resident_spec_tests {
1050    use super::parse_provider_model;
1051
1052    #[test]
1053    fn parse_provider_model_splits_and_guards() {
1054        let r = parse_provider_model("anthropic:claude-opus-4-8").unwrap();
1055        assert_eq!(r.provider, "anthropic");
1056        assert_eq!(r.model, "claude-opus-4-8");
1057        // Bare model (no provider) and empty parts fall back to config defaults.
1058        assert!(parse_provider_model("just-a-model").is_none());
1059        assert!(parse_provider_model(":m").is_none());
1060        assert!(parse_provider_model("p:").is_none());
1061        assert!(parse_provider_model("  ").is_none());
1062    }
1063}
1064
1065#[cfg(test)]
1066mod presence_verify_tests {
1067    //! The non-echo deploy verify is a task-free presence probe: it must confirm a
1068    //! worker registered on the bus under its role, fail fast when it never came
1069    //! up, and stay role-scoped (so a worker under a different role doesn't count).
1070    use super::verify_worker_connected;
1071    use bamboo_config::BrokerClientConfig;
1072    use std::sync::Arc;
1073    use std::time::Duration;
1074
1075    async fn start_broker() -> (String, tempfile::TempDir) {
1076        let dir = tempfile::tempdir().unwrap();
1077        let core = Arc::new(bamboo_broker::BrokerCore::new(dir.path()));
1078        let server = Arc::new(bamboo_broker::BrokerServer::new(core, "t"));
1079        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1080        let addr = listener.local_addr().unwrap();
1081        tokio::spawn(async move {
1082            let _ = server.serve(listener).await;
1083        });
1084        (format!("ws://{addr}"), dir)
1085    }
1086
1087    /// Connect + subscribe a worker so the broker's live-actor registry lists it
1088    /// under `role` (mailbox id == worker id, matching a resident deploy).
1089    async fn join(endpoint: &str, id: &str, role: &str) -> bamboo_broker::BrokerClient {
1090        let mut c = bamboo_broker::BrokerClient::connect(
1091            endpoint,
1092            bamboo_subagent::AgentRef {
1093                session_id: id.into(),
1094                role: Some(role.into()),
1095            },
1096            "t",
1097        )
1098        .await
1099        .unwrap();
1100        c.subscribe().await.unwrap();
1101        c
1102    }
1103
1104    fn cfg(endpoint: &str) -> BrokerClientConfig {
1105        BrokerClientConfig {
1106            endpoint: endpoint.to_string(),
1107            token: "t".into(),
1108            token_encrypted: None,
1109        }
1110    }
1111
1112    #[tokio::test]
1113    async fn ok_when_worker_registered_under_role() {
1114        let (endpoint, _dir) = start_broker().await;
1115        let _worker = join(&endpoint, "w-mon", "monitor").await;
1116        let out =
1117            verify_worker_connected(&cfg(&endpoint), "w-mon", "monitor", Duration::from_secs(3))
1118                .await;
1119        assert!(out.is_ok(), "present worker should verify: {out:?}");
1120    }
1121
1122    #[tokio::test]
1123    async fn times_out_when_worker_absent() {
1124        let (endpoint, _dir) = start_broker().await;
1125        // Nobody joined "monitor" — the probe must fail fast, never hang.
1126        let out = verify_worker_connected(
1127            &cfg(&endpoint),
1128            "w-mon",
1129            "monitor",
1130            Duration::from_millis(400),
1131        )
1132        .await;
1133        assert!(out.is_err(), "absent worker should fail verify");
1134        assert!(out.unwrap_err().contains("never registered"));
1135    }
1136
1137    #[tokio::test]
1138    async fn role_scoped_ignores_worker_under_other_role() {
1139        let (endpoint, _dir) = start_broker().await;
1140        // Right id, wrong role bucket → must not satisfy a "monitor" probe.
1141        let _other = join(&endpoint, "w-mon", "builder").await;
1142        let out = verify_worker_connected(
1143            &cfg(&endpoint),
1144            "w-mon",
1145            "monitor",
1146            Duration::from_millis(400),
1147        )
1148        .await;
1149        assert!(
1150            out.is_err(),
1151            "a worker under a different role must not count"
1152        );
1153    }
1154}
1155
1156#[cfg(test)]
1157mod health_check_tests {
1158    //! The health probe drives node status live: worker present → Running +
1159    //! last_health; worker gone → Unreachable; a non-deployed node is untouched.
1160    use super::*;
1161    use bamboo_config::cluster_fabric::{
1162        DeployProfile, Node, NodePlacement, NodeState, NodeStatus, TrustLevel,
1163    };
1164    use bamboo_config::{BrokerClientConfig, Config};
1165    use std::collections::HashMap;
1166    use std::sync::Arc;
1167    use tokio::sync::{Mutex, RwLock};
1168
1169    async fn start_broker() -> (String, tempfile::TempDir) {
1170        let dir = tempfile::tempdir().unwrap();
1171        let core = Arc::new(bamboo_broker::BrokerCore::new(dir.path()));
1172        let server = Arc::new(bamboo_broker::BrokerServer::new(core, "t"));
1173        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1174        let addr = listener.local_addr().unwrap();
1175        tokio::spawn(async move {
1176            let _ = server.serve(listener).await;
1177        });
1178        (format!("ws://{addr}"), dir)
1179    }
1180
1181    async fn join(endpoint: &str, id: &str, role: &str) -> bamboo_broker::BrokerClient {
1182        let mut c = bamboo_broker::BrokerClient::connect(
1183            endpoint,
1184            bamboo_subagent::AgentRef {
1185                session_id: id.into(),
1186                role: Some(role.into()),
1187            },
1188            "t",
1189        )
1190        .await
1191        .unwrap();
1192        c.subscribe().await.unwrap();
1193        c
1194    }
1195
1196    fn running_node(id: &str, worker_id: &str, role: &str) -> Node {
1197        Node {
1198            id: id.into(),
1199            label: id.into(),
1200            placement: NodePlacement::Local,
1201            trust_level: TrustLevel::Trusted,
1202            deploy: DeployProfile {
1203                default_role: Some(role.into()),
1204                ..Default::default()
1205            },
1206            state: Some(NodeState {
1207                status: NodeStatus::Running,
1208                worker_id: Some(worker_id.into()),
1209                ..Default::default()
1210            }),
1211            enabled: true,
1212        }
1213    }
1214
1215    fn deployer_with(nodes: Vec<Node>, endpoint: &str) -> Arc<FabricDeployer> {
1216        let mut cfg = Config::default();
1217        cfg.cluster_fabric.nodes = nodes;
1218        cfg.subagents.broker = Some(BrokerClientConfig {
1219            endpoint: endpoint.into(),
1220            token: "t".into(),
1221            token_encrypted: None,
1222        });
1223        Arc::new(FabricDeployer::new(
1224            Arc::new(RwLock::new(cfg)),
1225            Arc::new(Mutex::new(())),
1226            std::env::temp_dir(),
1227            Arc::new(Mutex::new(HashMap::new())),
1228            "/usr/bin/true",
1229        ))
1230    }
1231
1232    #[tokio::test]
1233    async fn keeps_running_when_worker_present() {
1234        let (endpoint, _dir) = start_broker().await;
1235        let _w = join(&endpoint, "node-a", "mon").await;
1236        let d = deployer_with(vec![running_node("a", "node-a", "mon")], &endpoint);
1237        let st = d
1238            .health_check_within("a", Duration::from_secs(3))
1239            .await
1240            .unwrap();
1241        assert_eq!(st.status, NodeStatus::Running);
1242        assert!(st.last_health.is_some(), "heartbeat stamped");
1243        assert!(st.last_error.is_none());
1244    }
1245
1246    #[tokio::test]
1247    async fn flips_to_unreachable_when_worker_gone() {
1248        let (endpoint, _dir) = start_broker().await;
1249        // Nobody joined the bus → the node's worker is absent.
1250        let d = deployer_with(vec![running_node("a", "node-a", "mon")], &endpoint);
1251        let st = d
1252            .health_check_within("a", Duration::from_millis(400))
1253            .await
1254            .unwrap();
1255        assert_eq!(st.status, NodeStatus::Unreachable);
1256        assert!(st.last_error.is_some());
1257    }
1258
1259    #[tokio::test]
1260    async fn leaves_non_deployed_node_untouched() {
1261        let (endpoint, _dir) = start_broker().await;
1262        let mut node = running_node("a", "node-a", "mon");
1263        node.state = Some(NodeState {
1264            status: NodeStatus::Stopped,
1265            ..Default::default()
1266        });
1267        let d = deployer_with(vec![node], &endpoint);
1268        let st = d
1269            .health_check_within("a", Duration::from_millis(400))
1270            .await
1271            .unwrap();
1272        assert_eq!(
1273            st.status,
1274            NodeStatus::Stopped,
1275            "a stopped node is not probed"
1276        );
1277        assert!(st.last_health.is_none(), "no probe → no heartbeat");
1278    }
1279
1280    #[tokio::test]
1281    async fn does_not_clobber_a_concurrent_status_change() {
1282        // The probe runs UNLOCKED; a stop() landing during it must win (otherwise a
1283        // user-Stopped node gets resurrected as Unreachable → wrongly auto-recovered).
1284        let (endpoint, _dir) = start_broker().await;
1285        // No worker joined → the probe runs its full timeout before deciding.
1286        let d = deployer_with(vec![running_node("a", "node-a", "mon")], &endpoint);
1287        let probe = {
1288            let d = d.clone();
1289            tokio::spawn(async move {
1290                d.health_check_within("a", Duration::from_millis(1500))
1291                    .await
1292            })
1293        };
1294        // Mid-probe, flip the node to Stopped as stop() would.
1295        tokio::time::sleep(Duration::from_millis(200)).await;
1296        {
1297            let mut cfg = d.config.write().await;
1298            cfg.cluster_fabric.node_mut("a").unwrap().state = Some(NodeState {
1299                status: NodeStatus::Stopped,
1300                ..Default::default()
1301            });
1302        }
1303        let observed = probe.await.unwrap().unwrap();
1304        assert_eq!(
1305            observed.status,
1306            NodeStatus::Stopped,
1307            "health_check yields to the stop"
1308        );
1309        let cfg = d.config.read().await;
1310        let st = cfg
1311            .cluster_fabric
1312            .node("a")
1313            .unwrap()
1314            .state
1315            .as_ref()
1316            .unwrap();
1317        assert_eq!(
1318            st.status,
1319            NodeStatus::Stopped,
1320            "Stopped not overwritten to Unreachable"
1321        );
1322    }
1323}
1324
1325#[cfg(test)]
1326mod recovery_tests {
1327    //! Auto-recovery POLICY (no bus/deploy needed): opt-in gate, 2-miss debounce,
1328    //! exponential backoff between attempts, and a cap that marks the node Failed.
1329    use super::*;
1330    use bamboo_config::cluster_fabric::{
1331        DeployProfile, Node, NodePlacement, NodeState, NodeStatus, TrustLevel,
1332    };
1333    use bamboo_config::Config;
1334    use tokio::sync::{Mutex, RwLock};
1335
1336    fn recoverable_node(id: &str) -> Node {
1337        Node {
1338            id: id.into(),
1339            label: id.into(),
1340            placement: NodePlacement::Local,
1341            trust_level: TrustLevel::Trusted,
1342            deploy: DeployProfile {
1343                default_role: Some("mon".into()),
1344                auto_recover: true,
1345                ..Default::default()
1346            },
1347            state: Some(NodeState {
1348                status: NodeStatus::Unreachable,
1349                ..Default::default()
1350            }),
1351            enabled: true,
1352        }
1353    }
1354
1355    fn deployer(nodes: Vec<Node>) -> (Arc<FabricDeployer>, tempfile::TempDir) {
1356        let dir = tempfile::tempdir().unwrap();
1357        let mut cfg = Config::default();
1358        cfg.cluster_fabric.nodes = nodes;
1359        let d = Arc::new(FabricDeployer::new(
1360            Arc::new(RwLock::new(cfg)),
1361            Arc::new(Mutex::new(())),
1362            dir.path().to_path_buf(),
1363            Arc::new(Mutex::new(HashMap::new())),
1364            "/usr/bin/true",
1365        ));
1366        (d, dir)
1367    }
1368
1369    fn unreachable() -> NodeState {
1370        NodeState {
1371            status: NodeStatus::Unreachable,
1372            ..Default::default()
1373        }
1374    }
1375
1376    #[tokio::test(start_paused = true)]
1377    async fn debounces_backs_off_then_caps_to_failed() {
1378        let (d, _dir) = deployer(vec![recoverable_node("a")]);
1379        let un = unreachable();
1380
1381        // 1st miss → debounce (no action); 2nd → first redeploy (attempt in flight).
1382        assert_eq!(d.recovery_decision("a", &un).await, None);
1383        assert_eq!(d.recovery_decision("a", &un).await, Some(1));
1384        // In-flight guard: no overlapping attempt, even once backoff elapses.
1385        assert_eq!(d.recovery_decision("a", &un).await, None);
1386        tokio::time::advance(Duration::from_secs(11)).await;
1387        assert_eq!(d.recovery_decision("a", &un).await, None, "still in-flight");
1388        // Redeploy settled (failed) → guard clears; past backoff(1) → attempt 2.
1389        d.clear_recovery_in_flight("a").await;
1390        assert_eq!(d.recovery_decision("a", &un).await, Some(2));
1391        d.clear_recovery_in_flight("a").await;
1392        tokio::time::advance(Duration::from_secs(21)).await;
1393        assert_eq!(d.recovery_decision("a", &un).await, Some(3));
1394        // Past backoff(3) → cap reached → None, and the node is marked Failed.
1395        d.clear_recovery_in_flight("a").await;
1396        tokio::time::advance(Duration::from_secs(41)).await;
1397        assert_eq!(d.recovery_decision("a", &un).await, None);
1398        let cfg = d.config.read().await;
1399        let st = cfg
1400            .cluster_fabric
1401            .node("a")
1402            .unwrap()
1403            .state
1404            .as_ref()
1405            .unwrap();
1406        assert_eq!(st.status, NodeStatus::Failed);
1407        assert!(st
1408            .last_error
1409            .as_deref()
1410            .unwrap_or_default()
1411            .contains("gave up"));
1412    }
1413
1414    #[tokio::test]
1415    async fn no_recovery_when_flag_off() {
1416        let mut node = recoverable_node("a");
1417        node.deploy.auto_recover = false;
1418        let (d, _dir) = deployer(vec![node]);
1419        let un = unreachable();
1420        assert_eq!(d.recovery_decision("a", &un).await, None);
1421        assert_eq!(
1422            d.recovery_decision("a", &un).await,
1423            None,
1424            "opt-out never redeploys"
1425        );
1426    }
1427
1428    #[tokio::test]
1429    async fn recovered_node_clears_progress() {
1430        let (d, _dir) = deployer(vec![recoverable_node("a")]);
1431        let un = unreachable();
1432        let ok = NodeState {
1433            status: NodeStatus::Running,
1434            ..Default::default()
1435        };
1436        assert_eq!(d.recovery_decision("a", &un).await, None); // miss 1
1437        assert_eq!(d.recovery_decision("a", &ok).await, None); // recovered → reset
1438                                                               // Debounce restarts from zero, so it takes another 2 misses.
1439        assert_eq!(d.recovery_decision("a", &un).await, None);
1440        assert_eq!(d.recovery_decision("a", &un).await, Some(1));
1441    }
1442}