1use std::collections::HashMap;
9use std::marker::PhantomData;
10use std::path::PathBuf;
11use std::sync::Arc;
12
13#[cfg(any(feature = "native", feature = "os-patterns"))]
14use dashmap::DashMap;
15use serde::{Deserialize, Serialize};
16use tracing::{debug, info, warn};
17
18use crate::process::CancellationToken;
19
20use clawft_platform::Platform;
21
22use crate::capability::AgentCapabilities;
23use crate::error::{KernelError, KernelResult};
24use crate::ipc::KernelIpc;
25use crate::process::{Pid, ProcessEntry, ProcessState, ProcessTable, ResourceUsage};
26
27#[non_exhaustive]
34#[cfg(feature = "os-patterns")]
35#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
36pub enum RestartStrategy {
37 #[default]
39 OneForOne,
40 OneForAll,
42 RestForOne,
44 Permanent,
46 Transient,
48}
49
50#[cfg(feature = "os-patterns")]
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct RestartBudget {
57 pub max_restarts: u32,
59 pub within_secs: u64,
61}
62
63#[cfg(feature = "os-patterns")]
64impl Default for RestartBudget {
65 fn default() -> Self {
66 Self {
67 max_restarts: 5,
68 within_secs: 60,
69 }
70 }
71}
72
73#[cfg(feature = "os-patterns")]
75pub struct RestartTracker {
76 pub restart_count: u32,
78 pub window_start: std::time::Instant,
80 pub last_restart: Option<std::time::Instant>,
82 pub backoff_ms: u64,
84}
85
86#[cfg(feature = "os-patterns")]
87impl RestartTracker {
88 pub fn new() -> Self {
90 Self {
91 restart_count: 0,
92 window_start: std::time::Instant::now(),
93 last_restart: None,
94 backoff_ms: 0,
95 }
96 }
97
98 pub fn next_backoff_ms(&self) -> u64 {
108 let base: u64 = 100;
109 let exponent = self.restart_count.saturating_sub(1);
110 let delay = base.saturating_mul(1u64 << exponent.min(20));
111 delay.min(30_000)
112 }
113
114 pub fn next_backoff_ms_with_model(
130 &self,
131 model: Option<&crate::eml_kernel::RestartStrategyModel>,
132 failure_type: u32,
133 uptime_secs: f64,
134 system_load: f64,
135 ) -> u64 {
136 match model {
137 Some(m) => {
138 let (delay, _should_retry) =
139 m.predict(self.restart_count, failure_type, uptime_secs, system_load);
140 delay
141 }
142 None => self.next_backoff_ms(),
143 }
144 }
145
146 pub fn check_window(&mut self, budget: &RestartBudget) {
148 let now = std::time::Instant::now();
149 if now.duration_since(self.window_start).as_secs() > budget.within_secs {
150 self.restart_count = 0;
151 self.window_start = now;
152 }
153 }
154
155 pub fn record_restart(&mut self, budget: &RestartBudget) -> bool {
158 self.check_window(budget);
159 self.restart_count += 1;
160 self.backoff_ms = self.next_backoff_ms();
161 self.last_restart = Some(std::time::Instant::now());
162 self.restart_count <= budget.max_restarts
163 }
164
165 pub fn record_restart_with_model(
174 &mut self,
175 budget: &RestartBudget,
176 model: Option<&crate::eml_kernel::RestartStrategyModel>,
177 failure_type: u32,
178 uptime_secs: f64,
179 system_load: f64,
180 ) -> bool {
181 self.check_window(budget);
182 self.restart_count += 1;
183 self.backoff_ms =
184 self.next_backoff_ms_with_model(model, failure_type, uptime_secs, system_load);
185 self.last_restart = Some(std::time::Instant::now());
186 self.restart_count <= budget.max_restarts
187 }
188
189 pub fn is_exhausted(&self, budget: &RestartBudget) -> bool {
191 let now = std::time::Instant::now();
193 if now.duration_since(self.window_start).as_secs() > budget.within_secs {
194 return false;
196 }
197 self.restart_count >= budget.max_restarts
198 }
199
200 pub fn remaining(&self, budget: &RestartBudget) -> u32 {
206 let now = std::time::Instant::now();
207 if now.duration_since(self.window_start).as_secs() > budget.within_secs {
208 return budget.max_restarts;
209 }
210 budget.max_restarts.saturating_sub(self.restart_count)
211 }
212
213 pub fn should_restart(strategy: &RestartStrategy, exit_code: i32) -> bool {
216 match strategy {
217 RestartStrategy::Permanent => false,
218 RestartStrategy::Transient => exit_code != 0,
219 _ => true,
221 }
222 }
223}
224
225#[cfg(feature = "os-patterns")]
226impl Default for RestartTracker {
227 fn default() -> Self {
228 Self::new()
229 }
230}
231
232#[non_exhaustive]
236#[cfg(feature = "os-patterns")]
237#[derive(Debug, Clone, PartialEq, Eq)]
238pub enum ResourceCheckResult {
239 Ok,
241 Warning {
243 resource: String,
244 current: u64,
245 limit: u64,
246 },
247 Exceeded {
249 resource: String,
250 current: u64,
251 limit: u64,
252 },
253}
254
255#[cfg(feature = "os-patterns")]
257pub fn check_resource_usage(
258 usage: &ResourceUsage,
259 limits: &crate::capability::ResourceLimits,
260) -> Vec<ResourceCheckResult> {
261 let mut results = Vec::new();
262
263 let checks: &[(&str, u64, u64)] = &[
264 ("memory", usage.memory_bytes, limits.max_memory_bytes),
265 ("cpu_time", usage.cpu_time_ms, limits.max_cpu_time_ms),
266 ("messages", usage.messages_sent, limits.max_messages),
267 ("tool_calls", usage.tool_calls, limits.max_tool_calls),
268 ];
269
270 for &(name, current, limit) in checks {
271 if limit == 0 {
272 continue; }
274 let ratio = current as f64 / limit as f64;
275 if ratio >= 1.0 {
276 results.push(ResourceCheckResult::Exceeded {
277 resource: name.to_owned(),
278 current,
279 limit,
280 });
281 } else if ratio >= 0.8 {
282 results.push(ResourceCheckResult::Warning {
283 resource: name.to_owned(),
284 current,
285 limit,
286 });
287 }
288 }
289
290 results
291}
292
293#[non_exhaustive]
300#[derive(Debug, Clone, Serialize, Deserialize)]
301pub enum SpawnBackend {
302 Native,
304 Wasm {
306 module: PathBuf,
308 },
309 Container {
311 image: String,
313 },
314 Tee {
316 enclave: EnclaveConfig,
318 },
319 Remote {
321 node_id: String,
323 },
324}
325
326#[derive(Debug, Clone, Serialize, Deserialize)]
331pub struct EnclaveConfig {
332 pub enclave_type: String,
334}
335
336#[derive(Debug, Clone, Serialize, Deserialize)]
338pub struct SpawnRequest {
339 pub agent_id: String,
341
342 #[serde(default)]
345 pub capabilities: Option<AgentCapabilities>,
346
347 #[serde(default)]
349 pub parent_pid: Option<Pid>,
350
351 #[serde(default)]
353 pub env: HashMap<String, String>,
354
355 #[serde(default)]
360 pub backend: Option<SpawnBackend>,
361}
362
363#[derive(Debug, Clone, Serialize, Deserialize)]
365pub struct SpawnResult {
366 pub pid: Pid,
368
369 pub agent_id: String,
371}
372
373pub struct AgentSupervisor<P: Platform> {
391 process_table: Arc<ProcessTable>,
392 kernel_ipc: Arc<KernelIpc>,
393 default_capabilities: AgentCapabilities,
394 #[cfg(feature = "native")]
395 running_agents: Arc<DashMap<Pid, tokio::task::JoinHandle<()>>>,
396 #[cfg(feature = "native")]
397 a2a_router: Option<Arc<crate::a2a::A2ARouter>>,
398 #[cfg(feature = "native")]
399 cron_service: Option<Arc<crate::cron::CronService>>,
400 #[cfg(feature = "exochain")]
401 tree_manager: Option<Arc<crate::tree_manager::TreeManager>>,
402 #[cfg(feature = "exochain")]
403 chain_manager: Option<Arc<crate::chain::ChainManager>>,
404 #[cfg(feature = "os-patterns")]
406 monitor_registry: Arc<crate::monitor::MonitorRegistry>,
407 #[cfg(feature = "os-patterns")]
409 restart_strategy: RestartStrategy,
410 #[cfg(feature = "os-patterns")]
412 restart_budget: RestartBudget,
413 #[cfg(feature = "os-patterns")]
415 restart_trackers: Arc<DashMap<Pid, RestartTracker>>,
416 _platform: PhantomData<P>,
417}
418
419impl<P: Platform> AgentSupervisor<P> {
420 pub fn new(
429 process_table: Arc<ProcessTable>,
430 kernel_ipc: Arc<KernelIpc>,
431 default_capabilities: AgentCapabilities,
432 ) -> Self {
433 Self {
434 process_table,
435 kernel_ipc,
436 default_capabilities,
437 #[cfg(feature = "native")]
438 running_agents: Arc::new(DashMap::new()),
439 #[cfg(feature = "native")]
440 a2a_router: None,
441 #[cfg(feature = "native")]
442 cron_service: None,
443 #[cfg(feature = "exochain")]
444 tree_manager: None,
445 #[cfg(feature = "exochain")]
446 chain_manager: None,
447 #[cfg(feature = "os-patterns")]
448 monitor_registry: Arc::new(crate::monitor::MonitorRegistry::new()),
449 #[cfg(feature = "os-patterns")]
450 restart_strategy: RestartStrategy::default(),
451 #[cfg(feature = "os-patterns")]
452 restart_budget: RestartBudget::default(),
453 #[cfg(feature = "os-patterns")]
454 restart_trackers: Arc::new(DashMap::new()),
455 _platform: PhantomData,
456 }
457 }
458
459 #[cfg(feature = "native")]
464 pub fn with_a2a_router(
465 mut self,
466 a2a_router: Arc<crate::a2a::A2ARouter>,
467 cron_service: Arc<crate::cron::CronService>,
468 ) -> Self {
469 self.a2a_router = Some(a2a_router);
470 self.cron_service = Some(cron_service);
471 self
472 }
473
474 #[cfg(feature = "native")]
476 pub fn a2a_router(&self) -> Option<&Arc<crate::a2a::A2ARouter>> {
477 self.a2a_router.as_ref()
478 }
479
480 #[cfg(feature = "native")]
482 pub fn cron_service(&self) -> Option<&Arc<crate::cron::CronService>> {
483 self.cron_service.as_ref()
484 }
485
486 #[cfg(feature = "exochain")]
491 pub fn with_exochain(
492 mut self,
493 tree_manager: Option<Arc<crate::tree_manager::TreeManager>>,
494 chain_manager: Option<Arc<crate::chain::ChainManager>>,
495 ) -> Self {
496 self.tree_manager = tree_manager;
497 self.chain_manager = chain_manager;
498 self
499 }
500
501 #[cfg(feature = "os-patterns")]
503 pub fn with_restart_config(mut self, strategy: RestartStrategy, budget: RestartBudget) -> Self {
504 self.restart_strategy = strategy;
505 self.restart_budget = budget;
506 self
507 }
508
509 #[cfg(feature = "os-patterns")]
511 pub fn monitor_registry(&self) -> &Arc<crate::monitor::MonitorRegistry> {
512 &self.monitor_registry
513 }
514
515 #[cfg(feature = "os-patterns")]
517 pub fn restart_strategy(&self) -> &RestartStrategy {
518 &self.restart_strategy
519 }
520
521 #[cfg(feature = "os-patterns")]
523 pub fn restart_budget(&self) -> &RestartBudget {
524 &self.restart_budget
525 }
526
527 #[cfg(feature = "os-patterns")]
536 pub fn handle_exit(&self, pid: Pid, exit_code: i32) -> Vec<(Pid, SpawnResult)> {
537 use crate::monitor::ExitReason;
538
539 let reason = if exit_code == 0 {
540 ExitReason::Normal
541 } else {
542 ExitReason::Crash(format!("exit code {exit_code}"))
543 };
544
545 let (_link_signals, _down_signals) = self.monitor_registry.process_exited(pid, &reason);
547
548 let mut restarts = Vec::new();
549
550 if !RestartTracker::should_restart(&self.restart_strategy, exit_code) {
552 debug!(pid, exit_code, strategy = ?self.restart_strategy, "not restarting per strategy");
553 return restarts;
554 }
555
556 let mut tracker = self.restart_trackers.entry(pid).or_default();
558
559 let within_budget = tracker.record_restart(&self.restart_budget);
560 let backoff_ms = tracker.backoff_ms;
561 drop(tracker);
562
563 if !within_budget {
564 warn!(pid, "restart budget exhausted for pid, escalating");
565 return restarts;
566 }
567
568 info!(
569 pid,
570 backoff_ms,
571 strategy = ?self.restart_strategy,
572 "scheduling restart after backoff"
573 );
574
575 #[cfg(feature = "exochain")]
577 if let Some(ref cm) = self.chain_manager {
578 cm.append(
579 "supervisor",
580 "agent.self_heal_restart",
581 Some(serde_json::json!({
582 "pid": pid,
583 "exit_code": exit_code,
584 "strategy": format!("{:?}", self.restart_strategy),
585 "backoff_ms": backoff_ms,
586 })),
587 );
588 }
589
590 match self.restart_strategy {
592 RestartStrategy::OneForOne | RestartStrategy::Transient => {
593 if let Ok(result) = self.restart(pid) {
595 restarts.push((pid, result));
596 }
597 }
598 RestartStrategy::OneForAll => {
599 let siblings: Vec<ProcessEntry> = self
601 .process_table
602 .list()
603 .into_iter()
604 .filter(|e| e.pid != 0 && e.pid != pid && e.state == ProcessState::Running)
605 .collect();
606
607 for sibling in &siblings {
608 if let Ok(result) = self.restart(sibling.pid) {
609 restarts.push((sibling.pid, result));
610 }
611 }
612 if let Ok(result) = self.restart(pid) {
613 restarts.push((pid, result));
614 }
615 }
616 RestartStrategy::RestForOne => {
617 let mut all: Vec<ProcessEntry> = self
619 .process_table
620 .list()
621 .into_iter()
622 .filter(|e| e.pid != 0 && e.state == ProcessState::Running)
623 .collect();
624 all.sort_by_key(|e| e.pid);
625
626 let after: Vec<Pid> = all.iter().filter(|e| e.pid > pid).map(|e| e.pid).collect();
627
628 for sibling_pid in &after {
629 if let Ok(result) = self.restart(*sibling_pid) {
630 restarts.push((*sibling_pid, result));
631 }
632 }
633 if let Ok(result) = self.restart(pid) {
634 restarts.push((pid, result));
635 }
636 }
637 RestartStrategy::Permanent => {
638 }
640 }
641
642 restarts
643 }
644
645 pub fn spawn(&self, request: SpawnRequest) -> KernelResult<SpawnResult> {
657 match &request.backend {
659 None | Some(SpawnBackend::Native) => { }
660 Some(SpawnBackend::Wasm { .. }) => {
661 return Err(KernelError::BackendNotAvailable {
662 backend: "wasm".into(),
663 reason: "WASM sandbox requires K3 (Wasmtime integration)".into(),
664 });
665 }
666 Some(SpawnBackend::Container { .. }) => {
667 return Err(KernelError::BackendNotAvailable {
668 backend: "container".into(),
669 reason: "agents do not run in containers yet; cog workloads run in \
670 containers through workload_runtime::WorkloadHost (ADR-099 s5)"
671 .into(),
672 });
673 }
674 Some(SpawnBackend::Tee { .. }) => {
675 return Err(KernelError::BackendNotAvailable {
676 backend: "tee".into(),
677 reason: "TEE runtime requires K6+ and hardware support".into(),
678 });
679 }
680 Some(SpawnBackend::Remote { .. }) => {
681 return Err(KernelError::BackendNotAvailable {
682 backend: "remote".into(),
683 reason: "remote agent delegation requires K6; remote workloads run \
684 through workload_runtime adapters (remote.api, ADR-099 s5)"
685 .into(),
686 });
687 }
688 }
689
690 let caps = request
691 .capabilities
692 .unwrap_or_else(|| self.default_capabilities.clone());
693
694 info!(
695 agent_id = %request.agent_id,
696 parent_pid = ?request.parent_pid,
697 "spawning supervised agent"
698 );
699
700 let entry = ProcessEntry {
701 pid: 0, agent_id: request.agent_id.clone(),
703 state: ProcessState::Starting,
704 capabilities: caps,
705 resource_usage: ResourceUsage::default(),
706 cancel_token: CancellationToken::new(),
707 parent_pid: request.parent_pid,
708 };
709
710 let pid = self.process_table.insert(entry)?;
711
712 debug!(pid, agent_id = %request.agent_id, "agent spawned");
713
714 Ok(SpawnResult {
715 pid,
716 agent_id: request.agent_id,
717 })
718 }
719
720 #[cfg(feature = "native")]
737 pub fn spawn_and_run<F, Fut>(&self, request: SpawnRequest, work: F) -> KernelResult<SpawnResult>
738 where
739 F: FnOnce(Pid, CancellationToken) -> Fut,
740 Fut: std::future::Future<Output = i32> + Send + 'static,
741 {
742 #[cfg(feature = "exochain")]
744 let parent_pid = request.parent_pid;
745
746 let result = self.spawn(request)?;
748 let pid = result.pid;
749
750 let entry = self
751 .process_table
752 .get(pid)
753 .ok_or(KernelError::ProcessNotFound { pid })?;
754 let cancel_token = entry.cancel_token.clone();
755
756 #[cfg(feature = "exochain")]
758 if let Some(ref tm) = self.tree_manager
759 && let Err(e) = tm.register_agent(&result.agent_id, pid, &entry.capabilities)
760 {
761 warn!(error = %e, pid, "failed to register agent in resource tree");
762 }
763
764 let _ = self.process_table.update_state(pid, ProcessState::Running);
766
767 #[cfg(feature = "exochain")]
769 if let Some(ref cm) = self.chain_manager {
770 cm.append(
771 "supervisor",
772 "agent.spawn",
773 Some(serde_json::json!({
774 "agent_id": result.agent_id,
775 "pid": pid,
776 "parent_pid": parent_pid,
777 })),
778 );
779 }
780
781 let process_table = Arc::clone(&self.process_table);
783 let running_agents = Arc::clone(&self.running_agents);
784 let agent_id = result.agent_id.clone();
785 #[cfg(feature = "exochain")]
786 let tree_manager = self.tree_manager.clone();
787 #[cfg(feature = "exochain")]
788 let chain_manager = self.chain_manager.clone();
789
790 let future = work(pid, cancel_token);
791 let handle = tokio::spawn(async move {
792 let exit_code = future.await;
793
794 let _ = process_table.update_state(pid, ProcessState::Exited(exit_code));
796
797 #[cfg(feature = "exochain")]
799 if let Some(ref tm) = tree_manager {
800 let agent_path = format!("/kernel/agents/{agent_id}");
801 let rid = exo_resource_tree::ResourceId::new(&agent_path);
802
803 let success = exit_code == 0;
806 let observation = exo_resource_tree::NodeScoring {
807 trust: if success { 0.8 } else { 0.2 },
808 performance: if success { 0.7 } else { 0.3 },
809 difficulty: 0.5,
810 reward: if success { 0.6 } else { 0.1 },
811 reliability: if success { 0.9 } else { 0.1 },
812 velocity: 0.5,
813 };
814 if let Err(e) = tm.blend_scoring(&rid, &observation, 0.3) {
816 debug!(error = %e, pid, "scoring blend skipped (node may be unregistered)");
817 }
818 }
819
820 #[cfg(feature = "exochain")]
822 if let Some(ref tm) = tree_manager
823 && let Err(e) = tm.unregister_agent(&agent_id, pid, exit_code)
824 {
825 tracing::warn!(error = %e, pid, "failed to unregister agent from tree");
826 }
827
828 #[cfg(feature = "exochain")]
830 if let Some(ref cm) = chain_manager {
831 cm.append(
832 "supervisor",
833 "agent.exit",
834 Some(serde_json::json!({
835 "agent_id": agent_id,
836 "pid": pid,
837 "exit_code": exit_code,
838 })),
839 );
840 }
841
842 running_agents.remove(&pid);
844
845 info!(pid, exit_code, agent_id = %agent_id, "agent task completed");
846 });
847
848 self.running_agents.insert(pid, handle);
849
850 info!(pid, agent_id = %result.agent_id, "agent spawned and running");
851
852 Ok(result)
853 }
854
855 pub fn stop(&self, pid: Pid, graceful: bool) -> KernelResult<()> {
869 let entry = self
870 .process_table
871 .get(pid)
872 .ok_or(KernelError::ProcessNotFound { pid })?;
873
874 if matches!(entry.state, ProcessState::Exited(_)) {
876 warn!(pid, "stop called on already-exited process");
877 return Ok(());
878 }
879
880 if graceful {
881 info!(pid, "gracefully stopping agent");
882 let _ = self.process_table.update_state(pid, ProcessState::Stopping);
886 entry.cancel_token.cancel();
887 } else {
888 info!(pid, "force stopping agent");
889 entry.cancel_token.cancel();
890 let _ = self
891 .process_table
892 .update_state(pid, ProcessState::Exited(-1));
893
894 #[cfg(feature = "native")]
896 if let Some((_, handle)) = self.running_agents.remove(&pid) {
897 handle.abort();
898 }
899
900 #[cfg(feature = "exochain")]
903 {
904 if let Some(ref tm) = self.tree_manager {
905 let _ = tm.unregister_agent(&entry.agent_id, pid, -1);
906 }
907 if let Some(ref cm) = self.chain_manager {
908 cm.append(
909 "supervisor",
910 "agent.force_stop",
911 Some(serde_json::json!({
912 "agent_id": entry.agent_id,
913 "pid": pid,
914 })),
915 );
916 }
917 }
918 }
919
920 Ok(())
921 }
922
923 pub fn restart(&self, pid: Pid) -> KernelResult<SpawnResult> {
938 let old_entry = self
939 .process_table
940 .get(pid)
941 .ok_or(KernelError::ProcessNotFound { pid })?;
942
943 info!(pid, agent_id = %old_entry.agent_id, "restarting agent");
944
945 self.stop(pid, true)?;
947
948 if !matches!(old_entry.state, ProcessState::Exited(_)) {
950 let _ = self
951 .process_table
952 .update_state(pid, ProcessState::Exited(0));
953 }
954
955 let request = SpawnRequest {
957 agent_id: old_entry.agent_id.clone(),
958 capabilities: Some(old_entry.capabilities.clone()),
959 parent_pid: Some(pid),
960 env: HashMap::new(),
961 backend: None, };
963
964 let result = self.spawn(request)?;
965
966 #[cfg(feature = "exochain")]
968 if let Some(ref cm) = self.chain_manager {
969 cm.append(
970 "supervisor",
971 "agent.restart",
972 Some(serde_json::json!({
973 "agent_id": result.agent_id,
974 "old_pid": pid,
975 "new_pid": result.pid,
976 })),
977 );
978 }
979
980 Ok(result)
981 }
982
983 pub fn inspect(&self, pid: Pid) -> KernelResult<ProcessEntry> {
993 self.process_table
994 .get(pid)
995 .ok_or(KernelError::ProcessNotFound { pid })
996 }
997
998 pub fn list_by_state(&self, state: ProcessState) -> Vec<ProcessEntry> {
1000 self.process_table
1001 .list()
1002 .into_iter()
1003 .filter(|e| e.state == state)
1004 .collect()
1005 }
1006
1007 pub fn list_agents(&self) -> Vec<ProcessEntry> {
1009 self.process_table
1010 .list()
1011 .into_iter()
1012 .filter(|e| e.pid != 0)
1013 .collect()
1014 }
1015
1016 pub fn process_table(&self) -> &Arc<ProcessTable> {
1018 &self.process_table
1019 }
1020
1021 pub fn ipc(&self) -> &Arc<KernelIpc> {
1023 &self.kernel_ipc
1024 }
1025
1026 pub fn default_capabilities(&self) -> &AgentCapabilities {
1028 &self.default_capabilities
1029 }
1030
1031 pub fn running_count(&self) -> usize {
1033 self.process_table
1034 .list()
1035 .iter()
1036 .filter(|e| e.pid != 0 && e.state == ProcessState::Running)
1037 .count()
1038 }
1039
1040 #[cfg(feature = "native")]
1042 pub fn running_task_count(&self) -> usize {
1043 self.running_agents.len()
1044 }
1045
1046 #[cfg(feature = "native")]
1048 pub fn abort_all(&self) {
1049 for entry in self.running_agents.iter() {
1050 entry.value().abort();
1051 }
1052 self.running_agents.clear();
1053 }
1054
1055 #[cfg(feature = "native")]
1066 pub async fn watchdog_sweep(&self) -> Vec<(Pid, i32)> {
1067 let mut reaped = Vec::new();
1068
1069 let finished_pids: Vec<Pid> = self
1071 .running_agents
1072 .iter()
1073 .filter(|entry| entry.value().is_finished())
1074 .map(|entry| *entry.key())
1075 .collect();
1076
1077 for pid in finished_pids {
1078 if let Some((_, handle)) = self.running_agents.remove(&pid) {
1079 let exit_code = match handle.await {
1081 Ok(()) => -2, Err(e) if e.is_panic() => -3, Err(_) => -2, };
1085
1086 if let Some(entry) = self.process_table.get(pid)
1088 && entry.state == ProcessState::Running
1089 {
1090 let _ = self
1091 .process_table
1092 .update_state(pid, ProcessState::Exited(exit_code));
1093
1094 #[cfg(feature = "exochain")]
1095 if let Some(ref cm) = self.chain_manager {
1096 cm.append(
1097 "watchdog",
1098 "agent.watchdog_reap",
1099 Some(serde_json::json!({
1100 "pid": pid,
1101 "exit_code": exit_code,
1102 "agent_id": entry.agent_id,
1103 })),
1104 );
1105 }
1106
1107 reaped.push((pid, exit_code));
1108 info!(pid, exit_code, agent_id = %entry.agent_id, "watchdog reaped stale agent");
1109 }
1110 }
1111 }
1112
1113 reaped
1114 }
1115
1116 #[cfg(feature = "native")]
1125 pub async fn shutdown_all(&self, timeout: std::time::Duration) -> Vec<(Pid, i32)> {
1126 for entry in self.process_table.list() {
1128 if entry.pid == 0 {
1129 continue; }
1131 entry.cancel_token.cancel();
1132 }
1133
1134 let handles: Vec<(Pid, tokio::task::JoinHandle<()>)> = {
1136 let pids: Vec<Pid> = self.running_agents.iter().map(|e| *e.key()).collect();
1137 let mut collected = Vec::with_capacity(pids.len());
1138 for pid in pids {
1139 if let Some((pid, handle)) = self.running_agents.remove(&pid) {
1140 collected.push((pid, handle));
1141 }
1142 }
1143 collected
1144 };
1145
1146 if handles.is_empty() {
1147 return Vec::new();
1148 }
1149
1150 let process_table = &self.process_table;
1151
1152 let mut results = Vec::with_capacity(handles.len());
1156
1157 match tokio::time::timeout(
1158 timeout,
1159 futures::future::join_all(
1160 handles
1161 .into_iter()
1162 .map(|(pid, handle)| async move { (pid, handle.await) }),
1163 ),
1164 )
1165 .await
1166 {
1167 Ok(join_results) => {
1168 for (pid, join_result) in join_results {
1170 let exit_code = match join_result {
1171 Ok(()) => process_table
1172 .get(pid)
1173 .and_then(|e| match e.state {
1174 ProcessState::Exited(code) => Some(code),
1175 _ => None,
1176 })
1177 .unwrap_or(0),
1178 Err(e) if e.is_panic() => -3,
1179 Err(_) => -1,
1180 };
1181 results.push((pid, exit_code));
1182 }
1183 }
1184 Err(_elapsed) => {
1185 info!("shutdown timeout reached, aborting remaining agents");
1186 let remaining: Vec<Pid> = self.running_agents.iter().map(|e| *e.key()).collect();
1191 for pid in remaining {
1192 if let Some((pid, handle)) = self.running_agents.remove(&pid) {
1193 handle.abort();
1194 let _ = process_table.update_state(pid, ProcessState::Exited(-1));
1195 results.push((pid, -1));
1196 }
1197 }
1198
1199 if results.is_empty() {
1202 for entry in process_table.list() {
1203 if entry.pid != 0 && !matches!(entry.state, ProcessState::Exited(_)) {
1204 let _ = process_table.update_state(entry.pid, ProcessState::Exited(-1));
1205 results.push((entry.pid, -1));
1206 }
1207 }
1208 }
1209 }
1210 }
1211
1212 results
1213 }
1214
1215 #[cfg(feature = "exochain")]
1217 pub fn tree_manager(&self) -> Option<&Arc<crate::tree_manager::TreeManager>> {
1218 self.tree_manager.as_ref()
1219 }
1220
1221 #[cfg(feature = "exochain")]
1223 pub fn chain_manager(&self) -> Option<&Arc<crate::chain::ChainManager>> {
1224 self.chain_manager.as_ref()
1225 }
1226}
1227
1228#[cfg(test)]
1229mod tests {
1230 use super::*;
1231 use clawft_core::bus::MessageBus;
1232
1233 fn make_supervisor() -> AgentSupervisor<clawft_platform::NativePlatform> {
1234 let process_table = Arc::new(ProcessTable::new(16));
1235 let bus = Arc::new(MessageBus::new());
1236 let ipc = Arc::new(KernelIpc::new(bus));
1237 AgentSupervisor::new(process_table, ipc, AgentCapabilities::default())
1238 }
1239
1240 fn simple_request(agent_id: &str) -> SpawnRequest {
1241 SpawnRequest {
1242 agent_id: agent_id.to_owned(),
1243 capabilities: None,
1244 parent_pid: None,
1245 env: HashMap::new(),
1246 backend: None,
1247 }
1248 }
1249
1250 #[test]
1251 fn spawn_creates_process_entry() {
1252 let sup = make_supervisor();
1253 let result = sup.spawn(simple_request("agent-1")).unwrap();
1254
1255 assert!(result.pid > 0);
1256 assert_eq!(result.agent_id, "agent-1");
1257
1258 let entry = sup.inspect(result.pid).unwrap();
1259 assert_eq!(entry.agent_id, "agent-1");
1260 assert_eq!(entry.state, ProcessState::Starting);
1261 }
1262
1263 #[test]
1264 fn spawn_uses_default_capabilities() {
1265 let sup = make_supervisor();
1266 let result = sup.spawn(simple_request("agent-1")).unwrap();
1267
1268 let entry = sup.inspect(result.pid).unwrap();
1269 assert!(entry.capabilities.can_spawn);
1270 assert!(entry.capabilities.can_ipc);
1271 assert!(entry.capabilities.can_exec_tools);
1272 }
1273
1274 #[test]
1275 fn spawn_uses_custom_capabilities() {
1276 let sup = make_supervisor();
1277 let caps = AgentCapabilities {
1278 can_spawn: false,
1279 can_ipc: false,
1280 can_exec_tools: true,
1281 can_network: true,
1282 ..Default::default()
1283 };
1284
1285 let request = SpawnRequest {
1286 agent_id: "restricted".to_owned(),
1287 capabilities: Some(caps.clone()),
1288 parent_pid: None,
1289 env: HashMap::new(),
1290 backend: None,
1291 };
1292
1293 let result = sup.spawn(request).unwrap();
1294 let entry = sup.inspect(result.pid).unwrap();
1295 assert!(!entry.capabilities.can_spawn);
1296 assert!(!entry.capabilities.can_ipc);
1297 assert!(entry.capabilities.can_network);
1298 }
1299
1300 #[test]
1301 fn spawn_with_parent_pid() {
1302 let sup = make_supervisor();
1303 let parent = sup.spawn(simple_request("parent")).unwrap();
1304
1305 let request = SpawnRequest {
1306 agent_id: "child".to_owned(),
1307 capabilities: None,
1308 parent_pid: Some(parent.pid),
1309 env: HashMap::new(),
1310 backend: None,
1311 };
1312
1313 let result = sup.spawn(request).unwrap();
1314 let entry = sup.inspect(result.pid).unwrap();
1315 assert_eq!(entry.parent_pid, Some(parent.pid));
1316 }
1317
1318 #[test]
1319 fn spawn_fails_when_table_full() {
1320 let process_table = Arc::new(ProcessTable::new(2));
1321 let bus = Arc::new(MessageBus::new());
1322 let ipc = Arc::new(KernelIpc::new(bus));
1323 let sup: AgentSupervisor<clawft_platform::NativePlatform> =
1324 AgentSupervisor::new(process_table, ipc, AgentCapabilities::default());
1325
1326 sup.spawn(simple_request("a1")).unwrap();
1327 sup.spawn(simple_request("a2")).unwrap();
1328 let result = sup.spawn(simple_request("a3"));
1329 assert!(result.is_err());
1330 }
1331
1332 #[test]
1333 fn stop_graceful() {
1334 let sup = make_supervisor();
1335 let result = sup.spawn(simple_request("agent-1")).unwrap();
1336
1337 sup.process_table()
1339 .update_state(result.pid, ProcessState::Running)
1340 .unwrap();
1341
1342 sup.stop(result.pid, true).unwrap();
1343
1344 let entry = sup.inspect(result.pid).unwrap();
1345 assert_eq!(entry.state, ProcessState::Stopping);
1346 assert!(entry.cancel_token.is_cancelled());
1347 }
1348
1349 #[test]
1350 fn stop_force() {
1351 let sup = make_supervisor();
1352 let result = sup.spawn(simple_request("agent-1")).unwrap();
1353
1354 sup.process_table()
1356 .update_state(result.pid, ProcessState::Running)
1357 .unwrap();
1358
1359 sup.stop(result.pid, false).unwrap();
1360
1361 let entry = sup.inspect(result.pid).unwrap();
1362 assert!(entry.cancel_token.is_cancelled());
1363 }
1364
1365 #[test]
1366 fn stop_already_exited_is_idempotent() {
1367 let sup = make_supervisor();
1368 let result = sup.spawn(simple_request("agent-1")).unwrap();
1369
1370 sup.process_table()
1372 .update_state(result.pid, ProcessState::Exited(0))
1373 .unwrap();
1374
1375 sup.stop(result.pid, true).unwrap();
1377 }
1378
1379 #[test]
1380 fn stop_nonexistent_pid_fails() {
1381 let sup = make_supervisor();
1382 let result = sup.stop(999, true);
1383 assert!(result.is_err());
1384 }
1385
1386 #[test]
1387 fn restart_creates_new_process() {
1388 let sup = make_supervisor();
1389 let original = sup.spawn(simple_request("agent-1")).unwrap();
1390
1391 sup.process_table()
1393 .update_state(original.pid, ProcessState::Running)
1394 .unwrap();
1395
1396 let restarted = sup.restart(original.pid).unwrap();
1397
1398 assert_ne!(restarted.pid, original.pid);
1400 assert_eq!(restarted.agent_id, "agent-1");
1401
1402 let new_entry = sup.inspect(restarted.pid).unwrap();
1404 assert_eq!(new_entry.parent_pid, Some(original.pid));
1405 }
1406
1407 #[test]
1408 fn restart_preserves_capabilities() {
1409 let sup = make_supervisor();
1410 let caps = AgentCapabilities {
1411 can_spawn: false,
1412 can_network: true,
1413 ..Default::default()
1414 };
1415
1416 let request = SpawnRequest {
1417 agent_id: "restricted".to_owned(),
1418 capabilities: Some(caps),
1419 parent_pid: None,
1420 env: HashMap::new(),
1421 backend: None,
1422 };
1423
1424 let original = sup.spawn(request).unwrap();
1425 sup.process_table()
1426 .update_state(original.pid, ProcessState::Running)
1427 .unwrap();
1428
1429 let restarted = sup.restart(original.pid).unwrap();
1430 let entry = sup.inspect(restarted.pid).unwrap();
1431 assert!(!entry.capabilities.can_spawn);
1432 assert!(entry.capabilities.can_network);
1433 }
1434
1435 #[test]
1436 fn list_by_state() {
1437 let sup = make_supervisor();
1438 let r1 = sup.spawn(simple_request("a1")).unwrap();
1439 let r2 = sup.spawn(simple_request("a2")).unwrap();
1440 sup.spawn(simple_request("a3")).unwrap();
1441
1442 sup.process_table()
1444 .update_state(r1.pid, ProcessState::Running)
1445 .unwrap();
1446 sup.process_table()
1447 .update_state(r2.pid, ProcessState::Running)
1448 .unwrap();
1449
1450 let running = sup.list_by_state(ProcessState::Running);
1451 assert_eq!(running.len(), 2);
1452
1453 let starting = sup.list_by_state(ProcessState::Starting);
1454 assert_eq!(starting.len(), 1);
1455 }
1456
1457 #[test]
1458 fn list_agents_excludes_kernel() {
1459 let sup = make_supervisor();
1460
1461 let kernel_entry = ProcessEntry {
1463 pid: 0,
1464 agent_id: "kernel".to_owned(),
1465 state: ProcessState::Running,
1466 capabilities: AgentCapabilities::default(),
1467 resource_usage: ResourceUsage::default(),
1468 cancel_token: CancellationToken::new(),
1469 parent_pid: None,
1470 };
1471 sup.process_table().insert_with_pid(kernel_entry).unwrap();
1472
1473 sup.spawn(simple_request("agent-1")).unwrap();
1475
1476 let agents = sup.list_agents();
1477 assert_eq!(agents.len(), 1);
1478 assert_eq!(agents[0].agent_id, "agent-1");
1479 }
1480
1481 #[test]
1482 fn running_count() {
1483 let sup = make_supervisor();
1484 let r1 = sup.spawn(simple_request("a1")).unwrap();
1485 let r2 = sup.spawn(simple_request("a2")).unwrap();
1486 sup.spawn(simple_request("a3")).unwrap();
1487
1488 assert_eq!(sup.running_count(), 0); sup.process_table()
1491 .update_state(r1.pid, ProcessState::Running)
1492 .unwrap();
1493 assert_eq!(sup.running_count(), 1);
1494
1495 sup.process_table()
1496 .update_state(r2.pid, ProcessState::Running)
1497 .unwrap();
1498 assert_eq!(sup.running_count(), 2);
1499 }
1500
1501 #[test]
1502 fn default_capabilities_accessor() {
1503 let sup = make_supervisor();
1504 let caps = sup.default_capabilities();
1505 assert!(caps.can_spawn);
1506 assert!(caps.can_ipc);
1507 assert!(caps.can_exec_tools);
1508 }
1509
1510 #[test]
1511 fn spawn_request_serde_roundtrip() {
1512 let request = SpawnRequest {
1513 agent_id: "test".to_owned(),
1514 capabilities: Some(AgentCapabilities {
1515 can_spawn: false,
1516 ..Default::default()
1517 }),
1518 parent_pid: Some(5),
1519 env: HashMap::from([("KEY".into(), "VALUE".into())]),
1520 backend: Some(SpawnBackend::Native),
1521 };
1522
1523 let json = serde_json::to_string(&request).unwrap();
1524 let restored: SpawnRequest = serde_json::from_str(&json).unwrap();
1525 assert_eq!(restored.agent_id, "test");
1526 assert_eq!(restored.parent_pid, Some(5));
1527 assert!(!restored.capabilities.unwrap().can_spawn);
1528 }
1529
1530 #[test]
1531 fn spawn_result_serde_roundtrip() {
1532 let result = SpawnResult {
1533 pid: 42,
1534 agent_id: "agent-42".to_owned(),
1535 };
1536
1537 let json = serde_json::to_string(&result).unwrap();
1538 let restored: SpawnResult = serde_json::from_str(&json).unwrap();
1539 assert_eq!(restored.pid, 42);
1540 assert_eq!(restored.agent_id, "agent-42");
1541 }
1542
1543 #[tokio::test]
1544 async fn spawn_and_run_executes_work() {
1545 let sup = make_supervisor();
1546
1547 let result = sup
1548 .spawn_and_run(simple_request("runner-1"), |_pid, _cancel| async { 0 })
1549 .unwrap();
1550
1551 assert!(result.pid > 0);
1552 assert_eq!(result.agent_id, "runner-1");
1553
1554 let entry = sup.inspect(result.pid).unwrap();
1556 assert_eq!(entry.state, ProcessState::Running);
1557
1558 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1560
1561 let entry = sup.inspect(result.pid).unwrap();
1563 assert!(matches!(entry.state, ProcessState::Exited(0)));
1564
1565 assert_eq!(sup.running_task_count(), 0);
1567 }
1568
1569 #[tokio::test]
1570 async fn spawn_and_run_respects_cancellation() {
1571 let sup = make_supervisor();
1572
1573 let result = sup
1574 .spawn_and_run(simple_request("cancellable"), |_pid, cancel| async move {
1575 cancel.cancelled().await;
1576 42
1577 })
1578 .unwrap();
1579
1580 assert_eq!(sup.running_task_count(), 1);
1581
1582 sup.stop(result.pid, true).unwrap();
1584
1585 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1587
1588 let entry = sup.inspect(result.pid).unwrap();
1589 assert!(matches!(entry.state, ProcessState::Exited(42)));
1590 assert_eq!(sup.running_task_count(), 0);
1591 }
1592
1593 #[tokio::test]
1594 async fn spawn_and_run_force_stop_aborts() {
1595 let sup = make_supervisor();
1596
1597 let result = sup
1598 .spawn_and_run(simple_request("force-me"), |_pid, cancel| async move {
1599 cancel.cancelled().await;
1600 0
1601 })
1602 .unwrap();
1603
1604 sup.stop(result.pid, false).unwrap();
1606
1607 let entry = sup.inspect(result.pid).unwrap();
1608 assert!(matches!(entry.state, ProcessState::Exited(-1)));
1609 assert_eq!(sup.running_task_count(), 0);
1610 }
1611
1612 #[tokio::test]
1613 async fn abort_all_clears_running_agents() {
1614 let sup = make_supervisor();
1615
1616 sup.spawn_and_run(simple_request("a1"), |_pid, cancel| async move {
1617 cancel.cancelled().await;
1618 0
1619 })
1620 .unwrap();
1621 sup.spawn_and_run(simple_request("a2"), |_pid, cancel| async move {
1622 cancel.cancelled().await;
1623 0
1624 })
1625 .unwrap();
1626
1627 assert_eq!(sup.running_task_count(), 2);
1628
1629 sup.abort_all();
1630
1631 assert_eq!(sup.running_task_count(), 0);
1632 }
1633
1634 #[tokio::test]
1635 async fn watchdog_sweep_reaps_finished_task() {
1636 let sup = make_supervisor();
1637
1638 let result = sup
1640 .spawn_and_run(simple_request("instant"), |_pid, _cancel| async { 0 })
1641 .unwrap();
1642
1643 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1645
1646 let reaped = sup.watchdog_sweep().await;
1649
1650 let entry = sup.inspect(result.pid).unwrap();
1653 assert!(
1654 matches!(entry.state, ProcessState::Exited(_)),
1655 "process should be Exited after sweep, got {:?}",
1656 entry.state
1657 );
1658
1659 assert_eq!(sup.running_task_count(), 0);
1661
1662 for (pid, code) in &reaped {
1664 assert_eq!(*pid, result.pid);
1665 assert!(*code == -2 || *code == -3);
1666 }
1667 }
1668
1669 #[tokio::test]
1670 async fn shutdown_all_graceful() {
1671 let sup = make_supervisor();
1672
1673 sup.spawn_and_run(simple_request("g1"), |_pid, cancel| async move {
1674 cancel.cancelled().await;
1675 0
1676 })
1677 .unwrap();
1678 sup.spawn_and_run(simple_request("g2"), |_pid, cancel| async move {
1679 cancel.cancelled().await;
1680 42
1681 })
1682 .unwrap();
1683
1684 assert_eq!(sup.running_task_count(), 2);
1685
1686 let results = sup.shutdown_all(std::time::Duration::from_secs(5)).await;
1687
1688 assert_eq!(results.len(), 2);
1689 assert_eq!(sup.running_task_count(), 0);
1690 }
1691
1692 #[tokio::test]
1693 async fn shutdown_all_timeout_aborts() {
1694 let sup = make_supervisor();
1695
1696 sup.spawn_and_run(simple_request("stubborn"), |_pid, _cancel| async move {
1698 tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
1700 0
1701 })
1702 .unwrap();
1703
1704 assert_eq!(sup.running_task_count(), 1);
1705
1706 let results = sup
1708 .shutdown_all(std::time::Duration::from_millis(100))
1709 .await;
1710
1711 assert!(!results.is_empty());
1713 assert_eq!(sup.running_task_count(), 0);
1714 }
1715
1716 #[cfg(feature = "exochain")]
1717 #[tokio::test]
1718 async fn chain_logs_agent_spawn() {
1719 let process_table = Arc::new(ProcessTable::new(16));
1720 let bus = Arc::new(MessageBus::new());
1721 let ipc = Arc::new(KernelIpc::new(bus));
1722 let cm = Arc::new(crate::chain::ChainManager::new(0, 1000));
1723
1724 let sup: AgentSupervisor<clawft_platform::NativePlatform> =
1725 AgentSupervisor::new(process_table, ipc, AgentCapabilities::default())
1726 .with_exochain(None, Some(cm.clone()));
1727
1728 let request = SpawnRequest {
1729 agent_id: "chain-agent".to_owned(),
1730 capabilities: None,
1731 parent_pid: Some(99),
1732 env: HashMap::new(),
1733 backend: None,
1734 };
1735
1736 let result = sup
1737 .spawn_and_run(request, |_pid, _cancel| async { 0 })
1738 .unwrap();
1739
1740 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1742
1743 let events = cm.tail(10);
1745 let spawn_evt = events.iter().find(|e| e.kind == "agent.spawn");
1746
1747 assert!(spawn_evt.is_some(), "expected agent.spawn event on chain");
1748
1749 let payload = spawn_evt.unwrap().payload.as_ref().unwrap();
1750 assert_eq!(payload["agent_id"], "chain-agent");
1751 assert_eq!(payload["pid"], result.pid);
1752 assert_eq!(payload["parent_pid"], 99);
1753
1754 let exit_evt = events.iter().find(|e| e.kind == "agent.exit");
1756 assert!(exit_evt.is_some(), "expected agent.exit event on chain");
1757 }
1758
1759 #[test]
1762 fn spawn_native_explicit() {
1763 let sup = make_supervisor();
1764 let request = SpawnRequest {
1765 agent_id: "native-agent".to_owned(),
1766 capabilities: None,
1767 parent_pid: None,
1768 env: HashMap::new(),
1769 backend: Some(SpawnBackend::Native),
1770 };
1771 let result = sup.spawn(request).unwrap();
1772 assert!(result.pid > 0);
1773 assert_eq!(result.agent_id, "native-agent");
1774 }
1775
1776 #[test]
1777 fn spawn_backend_none_defaults_to_native() {
1778 let sup = make_supervisor();
1779 let request = SpawnRequest {
1780 agent_id: "default-agent".to_owned(),
1781 capabilities: None,
1782 parent_pid: None,
1783 env: HashMap::new(),
1784 backend: None,
1785 };
1786 let result = sup.spawn(request).unwrap();
1787 assert!(result.pid > 0);
1788 assert_eq!(result.agent_id, "default-agent");
1789 }
1790
1791 #[test]
1792 fn spawn_wasm_returns_not_available() {
1793 let sup = make_supervisor();
1794 let request = SpawnRequest {
1795 agent_id: "wasm-agent".to_owned(),
1796 capabilities: None,
1797 parent_pid: None,
1798 env: HashMap::new(),
1799 backend: Some(SpawnBackend::Wasm {
1800 module: PathBuf::from("/tmp/agent.wasm"),
1801 }),
1802 };
1803 let result = sup.spawn(request);
1804 assert!(result.is_err());
1805 let err = result.unwrap_err();
1806 let msg = err.to_string();
1807 assert!(msg.contains("wasm"), "error should mention wasm: {msg}");
1808 assert!(
1809 msg.contains("not available"),
1810 "error should say not available: {msg}"
1811 );
1812 }
1813
1814 #[test]
1815 fn spawn_container_returns_not_available() {
1816 let sup = make_supervisor();
1817 let request = SpawnRequest {
1818 agent_id: "container-agent".to_owned(),
1819 capabilities: None,
1820 parent_pid: None,
1821 env: HashMap::new(),
1822 backend: Some(SpawnBackend::Container {
1823 image: "ghcr.io/test/agent:latest".into(),
1824 }),
1825 };
1826 let result = sup.spawn(request);
1827 assert!(result.is_err());
1828 let msg = result.unwrap_err().to_string();
1829 assert!(
1830 msg.contains("container"),
1831 "error should mention container: {msg}"
1832 );
1833 }
1834
1835 #[test]
1836 fn spawn_tee_returns_not_available() {
1837 let sup = make_supervisor();
1838 let request = SpawnRequest {
1839 agent_id: "tee-agent".to_owned(),
1840 capabilities: None,
1841 parent_pid: None,
1842 env: HashMap::new(),
1843 backend: Some(SpawnBackend::Tee {
1844 enclave: EnclaveConfig {
1845 enclave_type: "sgx".into(),
1846 },
1847 }),
1848 };
1849 let result = sup.spawn(request);
1850 assert!(result.is_err());
1851 let msg = result.unwrap_err().to_string();
1852 assert!(msg.contains("tee"), "error should mention tee: {msg}");
1853 }
1854
1855 #[test]
1856 fn spawn_remote_returns_not_available() {
1857 let sup = make_supervisor();
1858 let request = SpawnRequest {
1859 agent_id: "remote-agent".to_owned(),
1860 capabilities: None,
1861 parent_pid: None,
1862 env: HashMap::new(),
1863 backend: Some(SpawnBackend::Remote {
1864 node_id: "node-42".into(),
1865 }),
1866 };
1867 let result = sup.spawn(request);
1868 assert!(result.is_err());
1869 let msg = result.unwrap_err().to_string();
1870 assert!(msg.contains("remote"), "error should mention remote: {msg}");
1871 }
1872
1873 #[cfg(feature = "os-patterns")]
1876 mod restart_tests {
1877 use super::*;
1878 use crate::capability::ResourceLimits;
1879 use crate::supervisor::{
1880 ResourceCheckResult, RestartBudget, RestartStrategy, RestartTracker,
1881 check_resource_usage,
1882 };
1883
1884 #[test]
1885 fn restart_strategy_default_is_one_for_one() {
1886 assert_eq!(RestartStrategy::default(), RestartStrategy::OneForOne);
1887 }
1888
1889 #[test]
1890 fn restart_strategy_serde_roundtrip() {
1891 let strategies = vec![
1892 RestartStrategy::OneForOne,
1893 RestartStrategy::OneForAll,
1894 RestartStrategy::RestForOne,
1895 ];
1896 for strategy in strategies {
1897 let json = serde_json::to_string(&strategy).unwrap();
1898 let restored: RestartStrategy = serde_json::from_str(&json).unwrap();
1899 assert_eq!(restored, strategy);
1900 }
1901 }
1902
1903 #[test]
1904 fn restart_budget_default() {
1905 let budget = RestartBudget::default();
1906 assert_eq!(budget.max_restarts, 5);
1907 assert_eq!(budget.within_secs, 60);
1908 }
1909
1910 #[test]
1911 fn restart_budget_serde_roundtrip() {
1912 let budget = RestartBudget {
1913 max_restarts: 3,
1914 within_secs: 30,
1915 };
1916 let json = serde_json::to_string(&budget).unwrap();
1917 let restored: RestartBudget = serde_json::from_str(&json).unwrap();
1918 assert_eq!(restored.max_restarts, 3);
1919 assert_eq!(restored.within_secs, 30);
1920 }
1921
1922 #[test]
1923 fn tracker_new_starts_at_zero() {
1924 let tracker = RestartTracker::new();
1925 assert_eq!(tracker.restart_count, 0);
1926 assert_eq!(tracker.backoff_ms, 0);
1927 assert!(tracker.last_restart.is_none());
1928 }
1929
1930 #[test]
1931 fn tracker_backoff_exponential() {
1932 let mut tracker = RestartTracker::new();
1933 let budget = RestartBudget {
1934 max_restarts: 10,
1935 within_secs: 60,
1936 };
1937
1938 tracker.record_restart(&budget);
1940 assert_eq!(tracker.backoff_ms, 100);
1941
1942 tracker.record_restart(&budget);
1944 assert_eq!(tracker.backoff_ms, 200);
1945
1946 tracker.record_restart(&budget);
1948 assert_eq!(tracker.backoff_ms, 400);
1949
1950 tracker.record_restart(&budget);
1952 assert_eq!(tracker.backoff_ms, 800);
1953 }
1954
1955 #[test]
1956 fn tracker_backoff_caps_at_30s() {
1957 let mut tracker = RestartTracker::new();
1958 tracker.restart_count = 20;
1959 let delay = tracker.next_backoff_ms();
1960 assert!(delay <= 30_000, "backoff should cap at 30s, got {delay}");
1961 }
1962
1963 #[test]
1964 fn tracker_budget_exceeded_returns_false() {
1965 let mut tracker = RestartTracker::new();
1966 let budget = RestartBudget {
1967 max_restarts: 2,
1968 within_secs: 60,
1969 };
1970
1971 assert!(tracker.record_restart(&budget)); assert!(tracker.record_restart(&budget)); assert!(!tracker.record_restart(&budget)); }
1975
1976 #[test]
1977 fn tracker_budget_within_returns_true() {
1978 let mut tracker = RestartTracker::new();
1979 let budget = RestartBudget {
1980 max_restarts: 5,
1981 within_secs: 60,
1982 };
1983
1984 for _ in 0..5 {
1985 assert!(tracker.record_restart(&budget));
1986 }
1987 }
1988
1989 #[test]
1990 fn tracker_records_last_restart() {
1991 let mut tracker = RestartTracker::new();
1992 let budget = RestartBudget::default();
1993 assert!(tracker.last_restart.is_none());
1994
1995 tracker.record_restart(&budget);
1996 assert!(tracker.last_restart.is_some());
1997 }
1998
1999 #[test]
2000 fn tracker_with_untrained_model_matches_hardcoded() {
2001 let model = crate::eml_kernel::RestartStrategyModel::new();
2004 assert!(!model.is_trained());
2005 let mut tracker = RestartTracker::new();
2006 let budget = RestartBudget::default();
2007
2008 for _ in 0..5 {
2009 tracker.check_window(&budget);
2010 tracker.restart_count += 1;
2011 let hardcoded = tracker.next_backoff_ms();
2012 let via_model = tracker.next_backoff_ms_with_model(Some(&model), 0, 60.0, 0.5);
2013 assert_eq!(
2014 hardcoded, via_model,
2015 "untrained model must reproduce hardcoded backoff at count {}",
2016 tracker.restart_count
2017 );
2018 }
2019 }
2020
2021 #[test]
2022 fn tracker_with_trained_model_uses_prediction() {
2023 let model = crate::eml_kernel::RestartStrategyModel::new();
2027 let mut json = serde_json::to_value(&model).unwrap();
2028 if let Some(inner) = json.get_mut("inner").and_then(|v| v.as_object_mut()) {
2029 inner.insert("trained".into(), serde_json::Value::Bool(true));
2030 }
2031 let forced: crate::eml_kernel::RestartStrategyModel =
2032 serde_json::from_value(json).unwrap();
2033 assert!(forced.is_trained());
2034
2035 let mut tracker = RestartTracker::new();
2036 tracker.restart_count = 3; let hardcoded = tracker.next_backoff_ms();
2039 let via_model = tracker.next_backoff_ms_with_model(Some(&forced), 0, 60.0, 0.5);
2040 assert_eq!(hardcoded, 400);
2041 assert_ne!(hardcoded, via_model);
2044 }
2045
2046 #[test]
2047 fn tracker_record_with_model_matches_plain_when_untrained() {
2048 let model = crate::eml_kernel::RestartStrategyModel::new();
2051 let budget = RestartBudget::default();
2052
2053 let mut baseline = RestartTracker::new();
2054 let mut wired = RestartTracker::new();
2055
2056 for _ in 0..4 {
2057 baseline.record_restart(&budget);
2058 wired.record_restart_with_model(&budget, Some(&model), 0, 60.0, 0.5);
2059 assert_eq!(baseline.backoff_ms, wired.backoff_ms);
2060 assert_eq!(baseline.restart_count, wired.restart_count);
2061 }
2062 }
2063
2064 #[test]
2067 fn resource_check_within_limits() {
2068 let usage = ResourceUsage {
2069 memory_bytes: 100,
2070 cpu_time_ms: 100,
2071 tool_calls: 10,
2072 messages_sent: 10,
2073 };
2074 let limits = ResourceLimits::default(); let results = check_resource_usage(&usage, &limits);
2076 assert!(results.is_empty());
2077 }
2078
2079 #[test]
2080 fn resource_check_warning_at_80_percent() {
2081 let usage = ResourceUsage {
2082 memory_bytes: 220 * 1024 * 1024, cpu_time_ms: 100,
2084 tool_calls: 10,
2085 messages_sent: 10,
2086 };
2087 let limits = ResourceLimits::default();
2088 let results = check_resource_usage(&usage, &limits);
2089 assert_eq!(results.len(), 1);
2090 assert!(
2091 matches!(&results[0], ResourceCheckResult::Warning { resource, .. } if resource == "memory")
2092 );
2093 }
2094
2095 #[test]
2096 fn resource_check_exceeded_at_100_percent() {
2097 let usage = ResourceUsage {
2098 memory_bytes: 300 * 1024 * 1024, cpu_time_ms: 100,
2100 tool_calls: 10,
2101 messages_sent: 10,
2102 };
2103 let limits = ResourceLimits::default();
2104 let results = check_resource_usage(&usage, &limits);
2105 assert_eq!(results.len(), 1);
2106 assert!(
2107 matches!(&results[0], ResourceCheckResult::Exceeded { resource, .. } if resource == "memory")
2108 );
2109 }
2110
2111 #[test]
2112 fn resource_check_unlimited_skipped() {
2113 let usage = ResourceUsage {
2114 memory_bytes: 999_999_999,
2115 cpu_time_ms: 999_999_999,
2116 tool_calls: 999_999_999,
2117 messages_sent: 999_999_999,
2118 };
2119 let limits = ResourceLimits {
2120 max_memory_bytes: 0,
2121 max_cpu_time_ms: 0,
2122 max_tool_calls: 0,
2123 max_messages: 0,
2124 ..Default::default()
2125 };
2126 let results = check_resource_usage(&usage, &limits);
2127 assert!(results.is_empty(), "0 = unlimited should skip enforcement");
2128 }
2129
2130 #[test]
2131 fn resource_check_multiple_exceeded() {
2132 let limits = ResourceLimits {
2133 max_memory_bytes: 100,
2134 max_cpu_time_ms: 100,
2135 max_tool_calls: 10,
2136 max_messages: 10,
2137 ..Default::default()
2138 };
2139 let usage = ResourceUsage {
2140 memory_bytes: 200,
2141 cpu_time_ms: 200,
2142 tool_calls: 20,
2143 messages_sent: 20,
2144 };
2145 let results = check_resource_usage(&usage, &limits);
2146 assert_eq!(results.len(), 4);
2147 for r in &results {
2148 assert!(matches!(r, ResourceCheckResult::Exceeded { .. }));
2149 }
2150 }
2151
2152 #[test]
2153 fn resource_check_message_limit() {
2154 let limits = ResourceLimits {
2155 max_messages: 100,
2156 ..Default::default()
2157 };
2158 let usage = ResourceUsage {
2159 messages_sent: 100,
2160 ..Default::default()
2161 };
2162 let results = check_resource_usage(&usage, &limits);
2163 assert_eq!(results.len(), 1);
2164 assert!(
2165 matches!(&results[0], ResourceCheckResult::Exceeded { resource, .. } if resource == "messages")
2166 );
2167 }
2168
2169 #[test]
2170 fn resource_check_cpu_time_limit() {
2171 let limits = ResourceLimits {
2172 max_cpu_time_ms: 1000,
2173 ..Default::default()
2174 };
2175 let usage = ResourceUsage {
2176 cpu_time_ms: 1000,
2177 ..Default::default()
2178 };
2179 let results = check_resource_usage(&usage, &limits);
2180 assert_eq!(results.len(), 1);
2181 assert!(
2182 matches!(&results[0], ResourceCheckResult::Exceeded { resource, .. } if resource == "cpu_time")
2183 );
2184 }
2185
2186 #[test]
2189 fn restart_strategy_permanent_serde_roundtrip() {
2190 let strategy = RestartStrategy::Permanent;
2191 let json = serde_json::to_string(&strategy).unwrap();
2192 let restored: RestartStrategy = serde_json::from_str(&json).unwrap();
2193 assert_eq!(restored, RestartStrategy::Permanent);
2194 }
2195
2196 #[test]
2197 fn restart_strategy_transient_serde_roundtrip() {
2198 let strategy = RestartStrategy::Transient;
2199 let json = serde_json::to_string(&strategy).unwrap();
2200 let restored: RestartStrategy = serde_json::from_str(&json).unwrap();
2201 assert_eq!(restored, RestartStrategy::Transient);
2202 }
2203
2204 #[test]
2205 fn should_restart_permanent_never_restarts() {
2206 assert!(!RestartTracker::should_restart(
2207 &RestartStrategy::Permanent,
2208 0
2209 ));
2210 assert!(!RestartTracker::should_restart(
2211 &RestartStrategy::Permanent,
2212 1
2213 ));
2214 assert!(!RestartTracker::should_restart(
2215 &RestartStrategy::Permanent,
2216 -1
2217 ));
2218 }
2219
2220 #[test]
2221 fn should_restart_transient_only_on_abnormal() {
2222 assert!(!RestartTracker::should_restart(
2224 &RestartStrategy::Transient,
2225 0
2226 ));
2227 assert!(RestartTracker::should_restart(
2229 &RestartStrategy::Transient,
2230 1
2231 ));
2232 assert!(RestartTracker::should_restart(
2233 &RestartStrategy::Transient,
2234 -1
2235 ));
2236 assert!(RestartTracker::should_restart(
2237 &RestartStrategy::Transient,
2238 42
2239 ));
2240 }
2241
2242 #[test]
2243 fn should_restart_one_for_one_always() {
2244 assert!(RestartTracker::should_restart(
2245 &RestartStrategy::OneForOne,
2246 0
2247 ));
2248 assert!(RestartTracker::should_restart(
2249 &RestartStrategy::OneForOne,
2250 1
2251 ));
2252 }
2253
2254 #[test]
2255 fn should_restart_one_for_all_always() {
2256 assert!(RestartTracker::should_restart(
2257 &RestartStrategy::OneForAll,
2258 0
2259 ));
2260 assert!(RestartTracker::should_restart(
2261 &RestartStrategy::OneForAll,
2262 -1
2263 ));
2264 }
2265
2266 #[test]
2267 fn should_restart_rest_for_one_always() {
2268 assert!(RestartTracker::should_restart(
2269 &RestartStrategy::RestForOne,
2270 0
2271 ));
2272 assert!(RestartTracker::should_restart(
2273 &RestartStrategy::RestForOne,
2274 1
2275 ));
2276 }
2277
2278 #[test]
2279 fn tracker_is_exhausted_when_at_max() {
2280 let mut tracker = RestartTracker::new();
2281 let budget = RestartBudget {
2282 max_restarts: 3,
2283 within_secs: 60,
2284 };
2285
2286 assert!(!tracker.is_exhausted(&budget));
2287
2288 tracker.record_restart(&budget); tracker.record_restart(&budget); assert!(!tracker.is_exhausted(&budget));
2291
2292 tracker.record_restart(&budget); assert!(tracker.is_exhausted(&budget));
2294 }
2295
2296 #[test]
2297 fn tracker_remaining_decreases() {
2298 let mut tracker = RestartTracker::new();
2299 let budget = RestartBudget {
2300 max_restarts: 5,
2301 within_secs: 60,
2302 };
2303
2304 assert_eq!(tracker.remaining(&budget), 5);
2305 tracker.record_restart(&budget);
2306 assert_eq!(tracker.remaining(&budget), 4);
2307 tracker.record_restart(&budget);
2308 assert_eq!(tracker.remaining(&budget), 3);
2309 }
2310
2311 #[test]
2312 fn tracker_remaining_saturates_at_zero() {
2313 let mut tracker = RestartTracker::new();
2314 let budget = RestartBudget {
2315 max_restarts: 2,
2316 within_secs: 60,
2317 };
2318
2319 tracker.record_restart(&budget); tracker.record_restart(&budget); assert_eq!(tracker.remaining(&budget), 0);
2322 tracker.record_restart(&budget); assert_eq!(tracker.remaining(&budget), 0);
2324 }
2325
2326 #[test]
2327 fn tracker_backoff_sequence_matches_spec() {
2328 let mut tracker = RestartTracker::new();
2330 let budget = RestartBudget {
2331 max_restarts: 20,
2332 within_secs: 600,
2333 };
2334
2335 let expected = [100, 200, 400, 800, 1600, 3200, 6400, 12800, 25600, 30000];
2336 for &exp in &expected {
2337 tracker.record_restart(&budget);
2338 assert_eq!(
2339 tracker.backoff_ms, exp,
2340 "restart #{}: expected {}ms, got {}ms",
2341 tracker.restart_count, exp, tracker.backoff_ms
2342 );
2343 }
2344 }
2345
2346 #[test]
2347 fn handle_exit_permanent_no_restart() {
2348 let sup =
2349 make_supervisor_with_strategy(RestartStrategy::Permanent, RestartBudget::default());
2350 let result = sup.spawn(simple_request("perm-agent")).unwrap();
2351 sup.process_table()
2352 .update_state(result.pid, ProcessState::Running)
2353 .unwrap();
2354
2355 let _ = sup
2357 .process_table()
2358 .update_state(result.pid, ProcessState::Exited(1));
2359
2360 let restarts = sup.handle_exit(result.pid, 1);
2361 assert!(
2362 restarts.is_empty(),
2363 "Permanent strategy should never restart"
2364 );
2365 }
2366
2367 #[test]
2368 fn handle_exit_transient_normal_no_restart() {
2369 let sup =
2370 make_supervisor_with_strategy(RestartStrategy::Transient, RestartBudget::default());
2371 let result = sup.spawn(simple_request("trans-agent")).unwrap();
2372 sup.process_table()
2373 .update_state(result.pid, ProcessState::Running)
2374 .unwrap();
2375
2376 let _ = sup
2377 .process_table()
2378 .update_state(result.pid, ProcessState::Exited(0));
2379
2380 let restarts = sup.handle_exit(result.pid, 0);
2381 assert!(
2382 restarts.is_empty(),
2383 "Transient should not restart on normal exit (code 0)"
2384 );
2385 }
2386
2387 #[test]
2388 fn handle_exit_transient_abnormal_restarts() {
2389 let sup = make_supervisor_with_strategy(
2390 RestartStrategy::Transient,
2391 RestartBudget {
2392 max_restarts: 5,
2393 within_secs: 60,
2394 },
2395 );
2396 let result = sup.spawn(simple_request("trans-crash")).unwrap();
2397 sup.process_table()
2398 .update_state(result.pid, ProcessState::Running)
2399 .unwrap();
2400
2401 let _ = sup
2402 .process_table()
2403 .update_state(result.pid, ProcessState::Exited(1));
2404
2405 let restarts = sup.handle_exit(result.pid, 1);
2406 assert_eq!(
2407 restarts.len(),
2408 1,
2409 "Transient should restart on abnormal exit"
2410 );
2411 assert_eq!(restarts[0].0, result.pid);
2412 }
2413
2414 #[test]
2415 fn handle_exit_one_for_one_restarts_only_failed() {
2416 let sup = make_supervisor_with_strategy(
2417 RestartStrategy::OneForOne,
2418 RestartBudget {
2419 max_restarts: 10,
2420 within_secs: 60,
2421 },
2422 );
2423 let r1 = sup.spawn(simple_request("ofo-a")).unwrap();
2424 let r2 = sup.spawn(simple_request("ofo-b")).unwrap();
2425 sup.process_table()
2426 .update_state(r1.pid, ProcessState::Running)
2427 .unwrap();
2428 sup.process_table()
2429 .update_state(r2.pid, ProcessState::Running)
2430 .unwrap();
2431
2432 let _ = sup
2433 .process_table()
2434 .update_state(r1.pid, ProcessState::Exited(1));
2435
2436 let restarts = sup.handle_exit(r1.pid, 1);
2437 assert_eq!(restarts.len(), 1);
2439 assert_eq!(restarts[0].0, r1.pid);
2440
2441 let r2_entry = sup.inspect(r2.pid).unwrap();
2443 assert_eq!(r2_entry.state, ProcessState::Running);
2444 }
2445
2446 #[test]
2447 fn handle_exit_budget_exhausted_no_restart() {
2448 let sup = make_supervisor_with_strategy(
2449 RestartStrategy::OneForOne,
2450 RestartBudget {
2451 max_restarts: 1,
2452 within_secs: 60,
2453 },
2454 );
2455
2456 let r1 = sup.spawn(simple_request("budget-a")).unwrap();
2458 sup.process_table()
2459 .update_state(r1.pid, ProcessState::Running)
2460 .unwrap();
2461 let _ = sup
2462 .process_table()
2463 .update_state(r1.pid, ProcessState::Exited(1));
2464
2465 let restarts1 = sup.handle_exit(r1.pid, 1);
2466 assert_eq!(restarts1.len(), 1, "first restart should succeed");
2467
2468 let new_pid = restarts1[0].1.pid;
2471 sup.process_table()
2472 .update_state(new_pid, ProcessState::Running)
2473 .unwrap();
2474 let _ = sup
2475 .process_table()
2476 .update_state(r1.pid, ProcessState::Exited(1));
2477
2478 let restarts2 = sup.handle_exit(r1.pid, 1);
2479 assert!(
2480 restarts2.is_empty(),
2481 "budget should be exhausted after 1 restart"
2482 );
2483 }
2484
2485 #[test]
2486 fn handle_exit_links_notify_monitor_registry() {
2487 let sup = make_supervisor_with_strategy(
2488 RestartStrategy::Permanent, RestartBudget::default(),
2490 );
2491 let r1 = sup.spawn(simple_request("linked-a")).unwrap();
2492 let r2 = sup.spawn(simple_request("linked-b")).unwrap();
2493
2494 sup.monitor_registry().link(r1.pid, r2.pid);
2496
2497 let _ref_id = sup.monitor_registry().monitor(r2.pid, r1.pid);
2499
2500 sup.process_table()
2501 .update_state(r1.pid, ProcessState::Running)
2502 .unwrap();
2503 let _ = sup
2504 .process_table()
2505 .update_state(r1.pid, ProcessState::Exited(1));
2506
2507 let _restarts = sup.handle_exit(r1.pid, 1);
2509
2510 assert!(!sup.monitor_registry().is_linked(r1.pid, r2.pid));
2512 assert!(sup.monitor_registry().get_monitors(r1.pid).is_empty());
2514 }
2515
2516 #[test]
2517 fn supervisor_with_restart_config_builder() {
2518 let process_table = Arc::new(ProcessTable::new(16));
2519 let bus = Arc::new(clawft_core::bus::MessageBus::new());
2520 let ipc = Arc::new(KernelIpc::new(bus));
2521
2522 let sup: AgentSupervisor<clawft_platform::NativePlatform> =
2523 AgentSupervisor::new(process_table, ipc, AgentCapabilities::default())
2524 .with_restart_config(
2525 RestartStrategy::RestForOne,
2526 RestartBudget {
2527 max_restarts: 3,
2528 within_secs: 30,
2529 },
2530 );
2531
2532 assert_eq!(*sup.restart_strategy(), RestartStrategy::RestForOne);
2533 assert_eq!(sup.restart_budget().max_restarts, 3);
2534 assert_eq!(sup.restart_budget().within_secs, 30);
2535 }
2536
2537 fn make_supervisor_with_strategy(
2538 strategy: RestartStrategy,
2539 budget: RestartBudget,
2540 ) -> AgentSupervisor<clawft_platform::NativePlatform> {
2541 let process_table = Arc::new(ProcessTable::new(32));
2542 let bus = Arc::new(clawft_core::bus::MessageBus::new());
2543 let ipc = Arc::new(KernelIpc::new(bus));
2544 AgentSupervisor::new(process_table, ipc, AgentCapabilities::default())
2545 .with_restart_config(strategy, budget)
2546 }
2547 }
2548
2549 #[test]
2552 fn spawn_backend_native_serde_roundtrip() {
2553 let backend = SpawnBackend::Native;
2554 let json = serde_json::to_string(&backend).unwrap();
2555 let _: SpawnBackend = serde_json::from_str(&json).unwrap();
2556 }
2557
2558 #[test]
2559 fn spawn_backend_wasm_serde_roundtrip() {
2560 let backend = SpawnBackend::Wasm {
2561 module: PathBuf::from("/opt/modules/agent.wasm"),
2562 };
2563 let json = serde_json::to_string(&backend).unwrap();
2564 let restored: SpawnBackend = serde_json::from_str(&json).unwrap();
2565 assert!(matches!(restored, SpawnBackend::Wasm { .. }));
2566 }
2567
2568 #[test]
2569 fn spawn_backend_container_serde_roundtrip() {
2570 let backend = SpawnBackend::Container {
2571 image: "ghcr.io/org/agent:v1".into(),
2572 };
2573 let json = serde_json::to_string(&backend).unwrap();
2574 let restored: SpawnBackend = serde_json::from_str(&json).unwrap();
2575 assert!(matches!(restored, SpawnBackend::Container { .. }));
2576 }
2577
2578 #[test]
2579 fn spawn_backend_tee_serde_roundtrip() {
2580 let backend = SpawnBackend::Tee {
2581 enclave: EnclaveConfig {
2582 enclave_type: "sgx".into(),
2583 },
2584 };
2585 let json = serde_json::to_string(&backend).unwrap();
2586 let restored: SpawnBackend = serde_json::from_str(&json).unwrap();
2587 assert!(matches!(restored, SpawnBackend::Tee { .. }));
2588 }
2589
2590 #[test]
2591 fn spawn_backend_remote_serde_roundtrip() {
2592 let backend = SpawnBackend::Remote {
2593 node_id: "node-42".into(),
2594 };
2595 let json = serde_json::to_string(&backend).unwrap();
2596 let restored: SpawnBackend = serde_json::from_str(&json).unwrap();
2597 assert!(matches!(restored, SpawnBackend::Remote { .. }));
2598 }
2599
2600 #[test]
2601 fn enclave_config_serde_roundtrip() {
2602 let cfg = EnclaveConfig {
2603 enclave_type: "trustzone".into(),
2604 };
2605 let json = serde_json::to_string(&cfg).unwrap();
2606 let restored: EnclaveConfig = serde_json::from_str(&json).unwrap();
2607 assert_eq!(restored.enclave_type, "trustzone");
2608 }
2609
2610 #[test]
2611 fn spawn_request_with_backend_serde_roundtrip() {
2612 let req = SpawnRequest {
2613 agent_id: "test-agent".into(),
2614 capabilities: None,
2615 parent_pid: Some(42),
2616 env: HashMap::from([("LOG_LEVEL".into(), "debug".into())]),
2617 backend: Some(SpawnBackend::Native),
2618 };
2619 let json = serde_json::to_string(&req).unwrap();
2620 let restored: SpawnRequest = serde_json::from_str(&json).unwrap();
2621 assert_eq!(restored.agent_id, "test-agent");
2622 assert_eq!(restored.parent_pid, Some(42));
2623 assert!(restored.backend.is_some());
2624 }
2625
2626 #[test]
2627 fn spawn_request_minimal_json_deserializes() {
2628 let json = r#"{"agent_id":"minimal"}"#;
2629 let req: SpawnRequest = serde_json::from_str(json).unwrap();
2630 assert_eq!(req.agent_id, "minimal");
2631 assert!(req.capabilities.is_none());
2632 assert!(req.parent_pid.is_none());
2633 assert!(req.env.is_empty());
2634 assert!(req.backend.is_none());
2635 }
2636}