1use 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#[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
50pub struct FabricDeployer {
53 config: Arc<RwLock<Config>>,
54 config_io_lock: Arc<Mutex<()>>,
57 data_dir: PathBuf,
58 registry: DeployedRegistry,
61 bamboo_bin: PathBuf,
62 recovery: Arc<Mutex<HashMap<String, RecoveryState>>>,
65}
66
67#[derive(Default)]
69struct RecoveryState {
70 consecutive_unreachable: u32,
72 attempts: u32,
74 next_eligible: Option<tokio::time::Instant>,
76 gave_up: bool,
78 in_flight: bool,
81}
82
83const RECOVERY_DEBOUNCE: u32 = 2;
85const 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 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 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 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 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 tls_ca_cert: None,
208 };
209
210 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 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 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 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 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 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 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 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 const HEALTH_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
410
411 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 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); }
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); };
456
457 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 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); }
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 (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 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 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; }
568 if rs.in_flight {
569 return None; }
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; }
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 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 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 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 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; 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 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 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 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
736pub type FingerprintCell = Arc<Mutex<Option<String>>>;
738
739pub struct DeployerBuild {
741 pub deployer: Box<dyn Deployer>,
742 pub observed_fp: Option<FingerprintCell>,
743}
744
745pub 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
751fn 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#[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 spec.model = pinned_model.and_then(parse_provider_model).or_else(|| {
849 config.defaults.as_ref().and_then(|d| {
850 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 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 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 spec.to_json().ok()
898}
899
900fn 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
912pub fn placement_env(node: &Node) -> &'static str {
914 match &node.placement {
915 NodePlacement::Local => "local",
916 NodePlacement::Ssh(_) => "ssh",
917 }
918}
919
920pub 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
942pub 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
960fn 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
976async 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
998async 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
1037pub 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 SshAuth::SystemSshConfig => Ok(DeployerBuild {
1049 deployer: Box::new(build_system_ssh(node, target)),
1050 observed_fp: None,
1051 }),
1052 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 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 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 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 #[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 #[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 #[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 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 #[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 assert!(!spec_json.contains("sk-oai-secret"));
1282
1283 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 #[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 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 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 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 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 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 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 let (endpoint, _dir) = start_broker().await;
1556 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 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 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 assert_eq!(d.recovery_decision("a", &un).await, None);
1654 assert_eq!(d.recovery_decision("a", &un).await, Some(1));
1655 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 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 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); assert_eq!(d.recovery_decision("a", &ok).await, None); assert_eq!(d.recovery_decision("a", &un).await, None);
1711 assert_eq!(d.recovery_decision("a", &un).await, Some(1));
1712 }
1713}