Skip to main content

clawft_kernel/
supervisor.rs

1//! Agent supervisor for process lifecycle management.
2//!
3//! The [`AgentSupervisor`] manages the full lifecycle of kernel-managed
4//! agents: spawn, stop, restart, inspect, and watch. It wraps the
5//! existing `AgentLoop` spawn mechanism without replacing it, adding
6//! capability enforcement, resource tracking, and process table integration.
7
8use 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// ── K1-G1: Restart strategies (os-patterns) ─────────────────────
28
29/// Supervisor restart strategy (Erlang-inspired).
30///
31/// Determines what happens to sibling agents when one agent fails.
32/// Configured per AppManifest or per supervisor instance.
33#[non_exhaustive]
34#[cfg(feature = "os-patterns")]
35#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
36pub enum RestartStrategy {
37    /// Restart only the failed child.
38    #[default]
39    OneForOne,
40    /// Restart all children if one fails.
41    OneForAll,
42    /// Restart the failed child and all children started after it.
43    RestForOne,
44    /// Do not restart -- let the agent stay dead.
45    Permanent,
46    /// Restart only if the agent exited abnormally (non-zero exit code).
47    Transient,
48}
49
50/// Restart budget: max N restarts within M seconds.
51///
52/// When the budget is exceeded, the supervisor escalates (stops itself
53/// and notifies its parent). Prevents infinite restart loops.
54#[cfg(feature = "os-patterns")]
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct RestartBudget {
57    /// Maximum restarts allowed in the time window.
58    pub max_restarts: u32,
59    /// Time window in seconds.
60    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/// Restart state tracking per supervised agent.
74#[cfg(feature = "os-patterns")]
75pub struct RestartTracker {
76    /// Number of restarts in the current window.
77    pub restart_count: u32,
78    /// When the current budget window started.
79    pub window_start: std::time::Instant,
80    /// When the last restart occurred.
81    pub last_restart: Option<std::time::Instant>,
82    /// Current backoff delay in milliseconds (exponential: 100ms -> 30s max).
83    pub backoff_ms: u64,
84}
85
86#[cfg(feature = "os-patterns")]
87impl RestartTracker {
88    /// Create a new tracker with the window starting now.
89    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    /// Calculate the next backoff delay in milliseconds.
99    ///
100    /// Exponential backoff: 100ms * 2^(restart_count - 1), capped at 30s.
101    ///
102    /// Preserved as the hardcoded fallback. See
103    /// [`Self::next_backoff_ms_with_model`] for the EML-aware variant
104    /// used by the supervisor. The fallback remains reachable so
105    /// callers without a model (and every existing test) behave
106    /// identically.
107    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    /// Calculate the next backoff delay, consulting an optional
115    /// [`RestartStrategyModel`](crate::eml_kernel::RestartStrategyModel).
116    ///
117    /// When `model` is `None` or untrained, this returns the same
118    /// exponential-backoff value as [`Self::next_backoff_ms`] (the
119    /// model's `predict` falls back to the identical formula when
120    /// `!is_trained`). When trained, the model's learned delay is used
121    /// instead.
122    ///
123    /// `failure_type` is the error-kind ordinal (0 = unknown / default
124    /// when the caller doesn't track failure kinds), `uptime_secs` is
125    /// the agent uptime before the failure, and `system_load` is a
126    /// normalised 0.0-1.0 load signal.
127    ///
128    /// NOTE(eml-swap): wired — Finding #5 (RestartStrategyModel).
129    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    /// Check if the restart budget window has expired and reset if so.
147    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    /// Record a restart attempt. Returns `true` if within budget,
156    /// `false` if budget exceeded.
157    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    /// Record a restart attempt with an optional learned
166    /// [`RestartStrategyModel`](crate::eml_kernel::RestartStrategyModel).
167    ///
168    /// Behaviour is identical to [`Self::record_restart`] when `model`
169    /// is `None` or untrained; when trained, the computed
170    /// `backoff_ms` is sourced from the model.
171    ///
172    /// NOTE(eml-swap): wired — Finding #5 (RestartStrategyModel).
173    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    /// Check whether the restart budget is exhausted.
190    pub fn is_exhausted(&self, budget: &RestartBudget) -> bool {
191        // If the window hasn't expired, check the count.
192        let now = std::time::Instant::now();
193        if now.duration_since(self.window_start).as_secs() > budget.within_secs {
194            // Window expired -- budget would reset on next record_restart.
195            return false;
196        }
197        self.restart_count >= budget.max_restarts
198    }
199
200    /// Number of restarts remaining in the current window.
201    ///
202    /// Returns 0 if the budget is already exhausted. Returns
203    /// `max_restarts` if the window has expired (it would reset
204    /// on the next `record_restart`).
205    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    /// Determine whether a process should be restarted based on the
214    /// strategy and exit code.
215    pub fn should_restart(strategy: &RestartStrategy, exit_code: i32) -> bool {
216        match strategy {
217            RestartStrategy::Permanent => false,
218            RestartStrategy::Transient => exit_code != 0,
219            // OneForOne, OneForAll, RestForOne always restart
220            _ => 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// ── K1-G3: Resource enforcement types ───────────────────────────
233
234/// Result of a resource limit check.
235#[non_exhaustive]
236#[cfg(feature = "os-patterns")]
237#[derive(Debug, Clone, PartialEq, Eq)]
238pub enum ResourceCheckResult {
239    /// Resource usage is within safe limits.
240    Ok,
241    /// Resource usage is at 80-99% of the limit.
242    Warning {
243        resource: String,
244        current: u64,
245        limit: u64,
246    },
247    /// Resource usage has reached or exceeded the limit.
248    Exceeded {
249        resource: String,
250        current: u64,
251        limit: u64,
252    },
253}
254
255/// Check resource usage against limits and return the worst result.
256#[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; // unlimited
273        }
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/// Execution backend for spawning an agent process.
294///
295/// Determines how the agent's work is executed at runtime. Only `Native`
296/// is implemented in K0-K2; other variants are defined to crystallize the
297/// API surface (see Symposium decisions D2, D3, C1, C8) and will return
298/// [`KernelError::BackendNotAvailable`] until their respective K-phases.
299#[non_exhaustive]
300#[derive(Debug, Clone, Serialize, Deserialize)]
301pub enum SpawnBackend {
302    /// Tokio task with agent_loop (K0-K2, default).
303    Native,
304    /// WASM sandbox via Wasmtime (K3).
305    Wasm {
306        /// Path to the compiled WASM module.
307        module: PathBuf,
308    },
309    /// Docker/Podman container (K4).
310    Container {
311        /// Container image reference (e.g. "ghcr.io/org/agent:latest").
312        image: String,
313    },
314    /// Trusted Execution Environment -- SGX, TrustZone, SEV (K6+).
315    Tee {
316        /// Enclave configuration.
317        enclave: EnclaveConfig,
318    },
319    /// Delegate to a remote node in the cluster (K6).
320    Remote {
321        /// Cluster node identifier.
322        node_id: String,
323    },
324}
325
326/// Placeholder configuration for TEE enclaves (D14, C8).
327///
328/// Will be expanded with actual hardware parameters when TEE
329/// runtime support is implemented.
330#[derive(Debug, Clone, Serialize, Deserialize)]
331pub struct EnclaveConfig {
332    /// Enclave type: "sgx", "trustzone", "sev".
333    pub enclave_type: String,
334}
335
336/// Request to spawn a new supervised agent process.
337#[derive(Debug, Clone, Serialize, Deserialize)]
338pub struct SpawnRequest {
339    /// Unique identifier for the agent.
340    pub agent_id: String,
341
342    /// Capabilities to assign. If `None`, the supervisor's default
343    /// capabilities are used.
344    #[serde(default)]
345    pub capabilities: Option<AgentCapabilities>,
346
347    /// PID of the parent process (for tracking spawn lineage).
348    #[serde(default)]
349    pub parent_pid: Option<Pid>,
350
351    /// Environment variables for the agent.
352    #[serde(default)]
353    pub env: HashMap<String, String>,
354
355    /// Execution backend. `None` defaults to `SpawnBackend::Native`.
356    ///
357    /// Non-Native backends return [`KernelError::BackendNotAvailable`]
358    /// until their respective K-phase implements them.
359    #[serde(default)]
360    pub backend: Option<SpawnBackend>,
361}
362
363/// Result of a successful agent spawn.
364#[derive(Debug, Clone, Serialize, Deserialize)]
365pub struct SpawnResult {
366    /// The PID assigned to the new process.
367    pub pid: Pid,
368
369    /// The agent identifier.
370    pub agent_id: String,
371}
372
373/// Manages the lifecycle of kernel-managed agent processes.
374///
375/// The supervisor sits between the CLI/API surface and the core
376/// `AgentLoop`, providing:
377///
378/// - **Spawn**: creates a process entry, assigns capabilities,
379///   allocates a PID, and tracks the agent in the process table.
380/// - **Stop**: signals cancellation (graceful) or immediate termination.
381/// - **Restart**: stops then re-spawns with the same configuration.
382/// - **Inspect**: returns full process entry with capabilities and
383///   resource usage.
384/// - **Watch**: returns a receiver for process state changes.
385///
386/// The supervisor does not own the actual `AgentLoop` execution; that
387/// remains the responsibility of the caller (kernel boot or CLI).
388/// Instead, the supervisor manages the process table entries and
389/// provides the cancellation tokens that control agent lifecycle.
390pub 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    /// Monitor registry for process links and monitors (os-patterns).
405    #[cfg(feature = "os-patterns")]
406    monitor_registry: Arc<crate::monitor::MonitorRegistry>,
407    /// Per-supervisor restart strategy (os-patterns).
408    #[cfg(feature = "os-patterns")]
409    restart_strategy: RestartStrategy,
410    /// Per-supervisor restart budget (os-patterns).
411    #[cfg(feature = "os-patterns")]
412    restart_budget: RestartBudget,
413    /// Per-PID restart trackers (os-patterns).
414    #[cfg(feature = "os-patterns")]
415    restart_trackers: Arc<DashMap<Pid, RestartTracker>>,
416    _platform: PhantomData<P>,
417}
418
419impl<P: Platform> AgentSupervisor<P> {
420    /// Create a new agent supervisor.
421    ///
422    /// # Arguments
423    ///
424    /// * `process_table` - Shared process table (also held by Kernel)
425    /// * `kernel_ipc` - IPC subsystem for sending lifecycle signals
426    /// * `default_capabilities` - Capabilities assigned to agents that
427    ///   don't specify their own
428    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    /// Configure A2A router and cron service.
460    ///
461    /// When set, `spawn_and_run` will create per-agent inboxes via the
462    /// A2ARouter and pass the cron service handle to the agent work loop.
463    #[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    /// Get the A2A router (if configured).
475    #[cfg(feature = "native")]
476    pub fn a2a_router(&self) -> Option<&Arc<crate::a2a::A2ARouter>> {
477        self.a2a_router.as_ref()
478    }
479
480    /// Get the cron service (if configured).
481    #[cfg(feature = "native")]
482    pub fn cron_service(&self) -> Option<&Arc<crate::cron::CronService>> {
483        self.cron_service.as_ref()
484    }
485
486    /// Configure exochain integration (tree + chain managers).
487    ///
488    /// When set, agent spawn/stop/restart events are recorded in
489    /// the resource tree and hash chain.
490    #[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    /// Configure the restart strategy and budget for this supervisor.
502    #[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    /// Get the monitor registry (os-patterns).
510    #[cfg(feature = "os-patterns")]
511    pub fn monitor_registry(&self) -> &Arc<crate::monitor::MonitorRegistry> {
512        &self.monitor_registry
513    }
514
515    /// Get the restart strategy.
516    #[cfg(feature = "os-patterns")]
517    pub fn restart_strategy(&self) -> &RestartStrategy {
518        &self.restart_strategy
519    }
520
521    /// Get the restart budget.
522    #[cfg(feature = "os-patterns")]
523    pub fn restart_budget(&self) -> &RestartBudget {
524        &self.restart_budget
525    }
526
527    /// Notify the supervisor that a process has exited.
528    ///
529    /// Processes link/monitor signals via the [`MonitorRegistry`] and
530    /// applies the configured [`RestartStrategy`] to determine whether
531    /// the exited process (or siblings) should be restarted.
532    ///
533    /// Returns a list of `(old_pid, new_spawn_result)` for any restarts
534    /// that were performed, or an empty vec if no restarts occurred.
535    #[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        // Deliver link/monitor signals
546        let (_link_signals, _down_signals) = self.monitor_registry.process_exited(pid, &reason);
547
548        let mut restarts = Vec::new();
549
550        // Determine whether to restart based on strategy
551        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        // Check budget via per-PID tracker
557        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        // Log restart event to chain
576        #[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        // Apply strategy
591        match self.restart_strategy {
592            RestartStrategy::OneForOne | RestartStrategy::Transient => {
593                // Restart only the failed process
594                if let Ok(result) = self.restart(pid) {
595                    restarts.push((pid, result));
596                }
597            }
598            RestartStrategy::OneForAll => {
599                // Restart the failed process and all siblings
600                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                // Restart the failed process and all processes started after it
618                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                // Never restart -- already handled above via should_restart
639            }
640        }
641
642        restarts
643    }
644
645    /// Spawn a new supervised agent process.
646    ///
647    /// This creates a process table entry and returns the assigned PID.
648    /// The actual agent execution (AgentLoop) must be started separately
649    /// by the caller using the returned `SpawnResult` and the
650    /// cancellation token from the process entry.
651    ///
652    /// # Errors
653    ///
654    /// Returns `KernelError::ProcessTableFull` if the process table
655    /// has reached its maximum capacity.
656    pub fn spawn(&self, request: SpawnRequest) -> KernelResult<SpawnResult> {
657        // Check backend availability -- only Native is implemented.
658        match &request.backend {
659            None | Some(SpawnBackend::Native) => { /* supported */ }
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, // Will be set by insert()
702            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    /// Spawn a supervised agent and run its work as a tokio task.
721    ///
722    /// Unlike `spawn`, this method also:
723    /// 1. Transitions the process to `Running`
724    /// 2. Registers the agent in the resource tree (if exochain enabled)
725    /// 3. Spawns a tokio task to execute the provided work closure
726    /// 4. On completion: transitions to `Exited`, unregisters from tree,
727    ///    logs chain events, and cleans up the task handle
728    ///
729    /// The `work` closure receives the assigned PID and a
730    /// [`CancellationToken`]; it should return an exit code (0 = success).
731    ///
732    /// # Errors
733    ///
734    /// Returns `KernelError::ProcessTableFull` if the process table
735    /// has reached its maximum capacity.
736    #[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        // Capture parent_pid before spawn() consumes the request
743        #[cfg(feature = "exochain")]
744        let parent_pid = request.parent_pid;
745
746        // 1. Create process entry via existing spawn()
747        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        // 2. Register in resource tree (exochain)
757        #[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        // 3. Transition to Running
765        let _ = self.process_table.update_state(pid, ProcessState::Running);
766
767        // 3b. Log spawn chain event
768        #[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        // 4. Spawn tokio task
782        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            // Transition to Exited
795            let _ = process_table.update_state(pid, ProcessState::Exited(exit_code));
796
797            // Blend scoring on agent exit — performance observation
798            #[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                // Build observation: successful exit boosts trust/reliability,
804                // failure reduces them.
805                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                // Blend with alpha=0.3 (30% observation, 70% prior)
815                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            // Unregister from tree
821            #[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            // Log exit chain event
829            #[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            // Remove from running agents map
843            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    /// Stop a supervised agent process.
856    ///
857    /// If `graceful` is true, the process is moved to `Stopping` state
858    /// and its cancellation token is cancelled, allowing the agent to
859    /// finish its current work. If `graceful` is false, the process is
860    /// immediately moved to `Exited(-1)`.
861    ///
862    /// Stopping an already-exited process is idempotent and returns `Ok`.
863    ///
864    /// # Errors
865    ///
866    /// Returns `KernelError::ProcessNotFound` if the PID is not in
867    /// the process table.
868    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        // Already exited -- idempotent
875        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            // Transition to Stopping, then cancel the token.
883            // The spawned task (if any) will detect cancellation,
884            // exit, and handle tree/chain cleanup.
885            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            // Abort the running task handle (cleanup won't run)
895            #[cfg(feature = "native")]
896            if let Some((_, handle)) = self.running_agents.remove(&pid) {
897                handle.abort();
898            }
899
900            // Since the spawned task was aborted, do tree/chain
901            // cleanup directly here.
902            #[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    /// Restart a supervised agent process.
924    ///
925    /// Stops the existing process (gracefully), then spawns a new one
926    /// with the same agent_id and capabilities. The new process gets
927    /// a fresh PID; the old entry remains in the table with
928    /// `Exited(0)` state.
929    ///
930    /// The `parent_pid` of the new process is set to the restarted
931    /// PID, creating a restart lineage.
932    ///
933    /// # Errors
934    ///
935    /// Returns `KernelError::ProcessNotFound` if the PID is not in
936    /// the process table.
937    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        // Stop the old process
946        self.stop(pid, true)?;
947
948        // Mark as cleanly exited if not already
949        if !matches!(old_entry.state, ProcessState::Exited(_)) {
950            let _ = self
951                .process_table
952                .update_state(pid, ProcessState::Exited(0));
953        }
954
955        // Spawn replacement with same config
956        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, // restarts always use Native
962        };
963
964        let result = self.spawn(request)?;
965
966        // Log restart chain event linking old PID to new PID
967        #[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    /// Inspect a supervised agent process.
984    ///
985    /// Returns a clone of the full [`ProcessEntry`] including
986    /// capabilities and resource usage.
987    ///
988    /// # Errors
989    ///
990    /// Returns `KernelError::ProcessNotFound` if the PID is not in
991    /// the process table.
992    pub fn inspect(&self, pid: Pid) -> KernelResult<ProcessEntry> {
993        self.process_table
994            .get(pid)
995            .ok_or(KernelError::ProcessNotFound { pid })
996    }
997
998    /// List processes filtered by state.
999    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    /// List all running agent processes (excludes kernel PID 0).
1008    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    /// Get a reference to the shared process table.
1017    pub fn process_table(&self) -> &Arc<ProcessTable> {
1018        &self.process_table
1019    }
1020
1021    /// Get a reference to the IPC subsystem.
1022    pub fn ipc(&self) -> &Arc<KernelIpc> {
1023        &self.kernel_ipc
1024    }
1025
1026    /// Get the default capabilities assigned to new agents.
1027    pub fn default_capabilities(&self) -> &AgentCapabilities {
1028        &self.default_capabilities
1029    }
1030
1031    /// Count running processes (excluding kernel PID 0).
1032    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    /// Get the number of actively tracked running agent tasks.
1041    #[cfg(feature = "native")]
1042    pub fn running_task_count(&self) -> usize {
1043        self.running_agents.len()
1044    }
1045
1046    /// Abort all running agent tasks (used during forced shutdown).
1047    #[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    /// Sweep finished agent handles that were not cleaned up normally.
1056    ///
1057    /// Iterates `running_agents`, checks `is_finished()` on each
1058    /// `JoinHandle`, and for any that are finished:
1059    /// 1. Removes the handle from the map
1060    /// 2. If the process table still shows `Running`, transitions to
1061    ///    `Exited(-2)` (watchdog reap) or `Exited(-3)` (panic reap)
1062    /// 3. Logs a chain event (when exochain is enabled)
1063    ///
1064    /// Returns a list of (pid, exit_code) for all reaped processes.
1065    #[cfg(feature = "native")]
1066    pub async fn watchdog_sweep(&self) -> Vec<(Pid, i32)> {
1067        let mut reaped = Vec::new();
1068
1069        // Collect finished PIDs first to avoid holding DashMap refs across await
1070        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                // Check if the task panicked
1080                let exit_code = match handle.await {
1081                    Ok(()) => -2, // Watchdog reap (task finished but cleanup didn't remove from map)
1082                    Err(e) if e.is_panic() => -3, // Panic reap
1083                    Err(_) => -2, // Cancelled or other
1084                };
1085
1086                // Only transition if process table still shows Running
1087                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    /// Gracefully shut down all running agents with a timeout.
1117    ///
1118    /// 1. Cancels all agent cancellation tokens via the process table
1119    /// 2. Drains all JoinHandles from `running_agents`
1120    /// 3. Waits for all tasks to complete, with a timeout
1121    /// 4. On timeout, aborts any remaining tasks
1122    ///
1123    /// Returns a list of (pid, exit_code) for all agents.
1124    #[cfg(feature = "native")]
1125    pub async fn shutdown_all(&self, timeout: std::time::Duration) -> Vec<(Pid, i32)> {
1126        // 1. Cancel all agent tokens
1127        for entry in self.process_table.list() {
1128            if entry.pid == 0 {
1129                continue; // Don't cancel the kernel process
1130            }
1131            entry.cancel_token.cancel();
1132        }
1133
1134        // 2. Drain all handles from running_agents
1135        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        // 3. Wait for all handles concurrently with timeout.
1153        //    Use futures::future::join_all-style: wrap each handle in a
1154        //    tokio::time::timeout so no single stuck handle blocks the rest.
1155        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                // All handles completed within timeout
1169                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                // Timeout expired. Any handles still alive need to be aborted.
1187                // Since we moved the handles into join_all, they're already being
1188                // awaited. The timeout drop aborts them. Record all remaining
1189                // agents from the running_agents map.
1190                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 no remaining handles were in the map (handles were consumed by
1200                // join_all), check the process table for any non-exited agents.
1201                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    /// Get the tree manager (when exochain feature is enabled).
1216    #[cfg(feature = "exochain")]
1217    pub fn tree_manager(&self) -> Option<&Arc<crate::tree_manager::TreeManager>> {
1218        self.tree_manager.as_ref()
1219    }
1220
1221    /// Get the chain manager (when exochain feature is enabled).
1222    #[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        // Move to Running first (Starting -> Running -> Stopping)
1338        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        // Move to Running first
1355        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        // Move to exited
1371        sup.process_table()
1372            .update_state(result.pid, ProcessState::Exited(0))
1373            .unwrap();
1374
1375        // Should succeed without error
1376        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        // Move to Running so it can be stopped
1392        sup.process_table()
1393            .update_state(original.pid, ProcessState::Running)
1394            .unwrap();
1395
1396        let restarted = sup.restart(original.pid).unwrap();
1397
1398        // New PID, same agent_id
1399        assert_ne!(restarted.pid, original.pid);
1400        assert_eq!(restarted.agent_id, "agent-1");
1401
1402        // New process has parent_pid pointing to old PID
1403        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        // Move first two to Running
1443        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        // Insert kernel PID 0
1462        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        // Spawn an agent
1474        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); // All Starting
1489
1490        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        // Process should be Running immediately after spawn_and_run
1555        let entry = sup.inspect(result.pid).unwrap();
1556        assert_eq!(entry.state, ProcessState::Running);
1557
1558        // Wait for the task to complete
1559        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1560
1561        // Process should be Exited after work completes
1562        let entry = sup.inspect(result.pid).unwrap();
1563        assert!(matches!(entry.state, ProcessState::Exited(0)));
1564
1565        // Running task should be cleaned up
1566        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        // Stop the agent
1583        sup.stop(result.pid, true).unwrap();
1584
1585        // Wait for the task to detect cancellation and exit
1586        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        // Force stop should abort the task immediately
1605        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        // Spawn a task that completes instantly
1639        let result = sup
1640            .spawn_and_run(simple_request("instant"), |_pid, _cancel| async { 0 })
1641            .unwrap();
1642
1643        // Give the task time to complete and clean up normally
1644        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1645
1646        // The task should have cleaned itself up. If it did, sweep returns empty.
1647        // If by race condition it didn't, sweep should reap it.
1648        let reaped = sup.watchdog_sweep().await;
1649
1650        // Either the task cleaned up on its own (reaped is empty and state is Exited)
1651        // or the watchdog reaped it.
1652        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        // Running task count should be 0 either way
1660        assert_eq!(sup.running_task_count(), 0);
1661
1662        // If reaped, verify exit code
1663        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        // Spawn a task that ignores cancellation (just sleeps forever)
1697        sup.spawn_and_run(simple_request("stubborn"), |_pid, _cancel| async move {
1698            // Ignore cancellation — sleep for a very long time
1699            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        // shutdown_all with a very short timeout
1707        let results = sup
1708            .shutdown_all(std::time::Duration::from_millis(100))
1709            .await;
1710
1711        // Should have at least 1 result (might be aborted)
1712        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        // Wait for the task to complete
1741        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1742
1743        // Verify agent.spawn event on chain
1744        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        // Should also have agent.exit from task completion
1755        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    // ── SpawnBackend tests (K2.1 T1: C1 + C8) ──────────────────
1760
1761    #[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    // ── K1-G1: Restart strategy tests (os-patterns) ─────────────
1874
1875    #[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            // First restart: 100ms
1939            tracker.record_restart(&budget);
1940            assert_eq!(tracker.backoff_ms, 100);
1941
1942            // Second restart: 200ms
1943            tracker.record_restart(&budget);
1944            assert_eq!(tracker.backoff_ms, 200);
1945
1946            // Third restart: 400ms
1947            tracker.record_restart(&budget);
1948            assert_eq!(tracker.backoff_ms, 400);
1949
1950            // Fourth restart: 800ms
1951            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)); // 1
1972            assert!(tracker.record_restart(&budget)); // 2
1973            assert!(!tracker.record_restart(&budget)); // 3 > 2 = exceeded
1974        }
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            // Finding #5: untrained RestartStrategyModel must reproduce
2002            // the exponential-backoff formula bit-for-bit.
2003            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            // Finding #5: with a trained RestartStrategyModel the
2024            // backoff must come from the model, not the formula. We
2025            // force the trained flag via JSON patch.
2026            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; // would give 400ms hardcoded
2037
2038            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            // With zero-param trained model the prediction differs
2042            // from the formula — invariant is that the branch fires.
2043            assert_ne!(hardcoded, via_model);
2044        }
2045
2046        #[test]
2047        fn tracker_record_with_model_matches_plain_when_untrained() {
2048            // Finding #5: record_restart_with_model(untrained) must
2049            // produce the same `backoff_ms` as plain record_restart.
2050            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        // ── K1-G3: Resource enforcement tests ───────────────────
2065
2066        #[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(); // 256 MiB, etc.
2075            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, // ~86% of 256 MiB
2083                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, // >256 MiB
2099                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        // ── Sprint 10 W1: Self-healing tests ────────────────────
2187
2188        #[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            // Normal exit (0) should NOT restart
2223            assert!(!RestartTracker::should_restart(
2224                &RestartStrategy::Transient,
2225                0
2226            ));
2227            // Abnormal exits should restart
2228            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); // 1
2289            tracker.record_restart(&budget); // 2
2290            assert!(!tracker.is_exhausted(&budget));
2291
2292            tracker.record_restart(&budget); // 3 == max
2293            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); // 1
2320            tracker.record_restart(&budget); // 2
2321            assert_eq!(tracker.remaining(&budget), 0);
2322            tracker.record_restart(&budget); // 3 (over budget)
2323            assert_eq!(tracker.remaining(&budget), 0);
2324        }
2325
2326        #[test]
2327        fn tracker_backoff_sequence_matches_spec() {
2328            // Verify the exact sequence: 100, 200, 400, 800, 1600, 3200, ...
2329            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            // Mark as exited
2356            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            // Only r1 should be restarted
2438            assert_eq!(restarts.len(), 1);
2439            assert_eq!(restarts[0].0, r1.pid);
2440
2441            // r2 should still be running
2442            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            // First agent and restart
2457            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            // Second crash of same PID -- budget exhausted
2469            // Need to mark new process as exited too
2470            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, // Don't restart, just test notification
2489                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            // Link r1 and r2
2495            sup.monitor_registry().link(r1.pid, r2.pid);
2496
2497            // Also set up a monitor
2498            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            // handle_exit should process links and monitors
2508            let _restarts = sup.handle_exit(r1.pid, 1);
2509
2510            // After exit, r1 should no longer be linked to r2
2511            assert!(!sup.monitor_registry().is_linked(r1.pid, r2.pid));
2512            // Monitor on r1 should be cleaned up
2513            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    // ── Sprint 09a: serde roundtrip tests ────────────────────────
2550
2551    #[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}