Skip to main content

clawft_kernel/
process.rs

1//! Process table for PID-based agent tracking.
2//!
3//! The [`ProcessTable`] uses [`DashMap`] for lock-free concurrent
4//! access, allowing multiple kernel subsystems to query and update
5//! process state without contention.
6
7#[cfg(feature = "exochain")]
8use std::sync::Arc;
9use std::sync::atomic::{AtomicU64, Ordering};
10
11use dashmap::DashMap;
12use serde::{Deserialize, Serialize};
13
14use crate::capability::AgentCapabilities;
15use crate::error::KernelError;
16
17// Re-export CancellationToken: use tokio_util when available (native),
18// otherwise provide a minimal shim for non-async targets (WASI).
19#[cfg(feature = "native")]
20pub use tokio_util::sync::CancellationToken;
21
22#[cfg(not(feature = "native"))]
23mod cancel_shim {
24    /// Minimal CancellationToken shim for non-async (WASI) builds.
25    ///
26    /// The real token lives in `tokio_util`. This provides the same
27    /// construction + Clone API so that `ProcessEntry` compiles on all
28    /// targets. The async `cancelled()` future is intentionally absent
29    /// because WASI builds do not run an async runtime.
30    #[derive(Debug, Clone)]
31    pub struct CancellationToken {
32        _priv: (),
33    }
34
35    impl CancellationToken {
36        pub fn new() -> Self {
37            Self { _priv: () }
38        }
39
40        /// Request cancellation (no-op in shim -- no waiters).
41        pub fn cancel(&self) {}
42    }
43
44    impl Default for CancellationToken {
45        fn default() -> Self {
46            Self::new()
47        }
48    }
49}
50
51#[cfg(not(feature = "native"))]
52pub use cancel_shim::CancellationToken;
53
54/// Process identifier. Monotonically increasing, never reused.
55pub type Pid = u64;
56
57/// State of a kernel-managed process.
58#[non_exhaustive]
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub enum ProcessState {
61    /// Process is initializing.
62    Starting,
63    /// Process is actively running.
64    Running,
65    /// Process has been suspended (paused).
66    Suspended,
67    /// Process is in the process of stopping.
68    Stopping,
69    /// Process has exited with a status code.
70    Exited(i32),
71}
72
73impl ProcessState {
74    /// Check whether a state transition is valid.
75    ///
76    /// Valid transitions:
77    /// - Starting -> Running | Exited
78    /// - Running -> Suspended | Stopping | Exited
79    /// - Suspended -> Running | Stopping | Exited
80    /// - Stopping -> Exited
81    /// - Exited -> (none)
82    pub fn can_transition_to(&self, next: &ProcessState) -> bool {
83        matches!(
84            (self, next),
85            (ProcessState::Starting, ProcessState::Running)
86                | (ProcessState::Starting, ProcessState::Exited(_))
87                | (ProcessState::Running, ProcessState::Suspended)
88                | (ProcessState::Running, ProcessState::Stopping)
89                | (ProcessState::Running, ProcessState::Exited(_))
90                | (ProcessState::Suspended, ProcessState::Running)
91                | (ProcessState::Suspended, ProcessState::Stopping)
92                | (ProcessState::Suspended, ProcessState::Exited(_))
93                | (ProcessState::Stopping, ProcessState::Exited(_))
94        )
95    }
96}
97
98impl std::fmt::Display for ProcessState {
99    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100        match self {
101            ProcessState::Starting => write!(f, "starting"),
102            ProcessState::Running => write!(f, "running"),
103            ProcessState::Suspended => write!(f, "suspended"),
104            ProcessState::Stopping => write!(f, "stopping"),
105            ProcessState::Exited(code) => write!(f, "exited({code})"),
106        }
107    }
108}
109
110/// Resource usage counters for a process.
111#[derive(Debug, Clone, Default, Serialize, Deserialize)]
112pub struct ResourceUsage {
113    /// Approximate memory usage in bytes.
114    pub memory_bytes: u64,
115    /// Accumulated CPU time in milliseconds.
116    pub cpu_time_ms: u64,
117    /// Total number of tool calls made.
118    pub tool_calls: u64,
119    /// Total number of IPC messages sent.
120    pub messages_sent: u64,
121}
122
123/// A single entry in the process table.
124#[derive(Debug, Clone)]
125pub struct ProcessEntry {
126    /// Unique process identifier.
127    pub pid: Pid,
128    /// Agent identifier string.
129    pub agent_id: String,
130    /// Current state.
131    pub state: ProcessState,
132    /// Capabilities granted to this process.
133    pub capabilities: AgentCapabilities,
134    /// Resource usage counters.
135    pub resource_usage: ResourceUsage,
136    /// Cancellation token for cooperative shutdown.
137    pub cancel_token: CancellationToken,
138    /// PID of the parent process (None for the root process).
139    pub parent_pid: Option<Pid>,
140}
141
142/// Concurrent process table with PID allocation.
143///
144/// Uses [`DashMap`] for lock-free concurrent reads and writes.
145/// PIDs are allocated monotonically from an [`AtomicU64`] and are
146/// never reused within a kernel session.
147pub struct ProcessTable {
148    next_pid: AtomicU64,
149    entries: DashMap<Pid, ProcessEntry>,
150    max_processes: u32,
151    #[cfg(feature = "exochain")]
152    chain_manager: Option<Arc<crate::chain::ChainManager>>,
153}
154
155impl ProcessTable {
156    /// Create a new process table with the given maximum capacity.
157    pub fn new(max_processes: u32) -> Self {
158        Self {
159            next_pid: AtomicU64::new(1), // PID 0 reserved for kernel
160            entries: DashMap::new(),
161            max_processes,
162            #[cfg(feature = "exochain")]
163            chain_manager: None,
164        }
165    }
166
167    /// Attach a chain manager for exochain event logging.
168    #[cfg(feature = "exochain")]
169    pub fn set_chain_manager(&mut self, cm: Arc<crate::chain::ChainManager>) {
170        self.chain_manager = Some(cm);
171    }
172
173    /// Allocate the next PID without inserting an entry.
174    pub fn allocate_pid(&self) -> Pid {
175        self.next_pid.fetch_add(1, Ordering::Relaxed)
176    }
177
178    /// Insert a process entry into the table.
179    ///
180    /// The entry's `pid` field is set to the next available PID.
181    /// Returns the assigned PID, or an error if the process table
182    /// is at capacity.
183    pub fn insert(&self, mut entry: ProcessEntry) -> Result<Pid, KernelError> {
184        if self.entries.len() >= self.max_processes as usize {
185            return Err(KernelError::ProcessTableFull {
186                max: self.max_processes,
187            });
188        }
189        let pid = self.allocate_pid();
190        entry.pid = pid;
191
192        #[cfg(feature = "exochain")]
193        if let Some(ref cm) = self.chain_manager {
194            cm.append(
195                "process",
196                crate::chain::EVENT_KIND_PROCESS_REGISTER,
197                Some(serde_json::json!({
198                    "pid": pid,
199                    "agent_id": &entry.agent_id,
200                    "parent_pid": entry.parent_pid,
201                })),
202            );
203        }
204
205        self.entries.insert(pid, entry);
206        Ok(pid)
207    }
208
209    /// Insert a process entry with a specific PID (for kernel PID 0).
210    pub fn insert_with_pid(&self, entry: ProcessEntry) -> Result<(), KernelError> {
211        if self.entries.len() >= self.max_processes as usize {
212            return Err(KernelError::ProcessTableFull {
213                max: self.max_processes,
214            });
215        }
216        self.entries.insert(entry.pid, entry);
217        Ok(())
218    }
219
220    /// Get a clone of a process entry by PID.
221    pub fn get(&self, pid: Pid) -> Option<ProcessEntry> {
222        self.entries.get(&pid).map(|e| e.value().clone())
223    }
224
225    /// Remove a process entry by PID.
226    pub fn remove(&self, pid: Pid) -> Option<ProcessEntry> {
227        let removed = self.entries.remove(&pid).map(|(_, e)| e);
228
229        #[cfg(feature = "exochain")]
230        if let Some(ref entry) = removed
231            && let Some(ref cm) = self.chain_manager
232        {
233            cm.append(
234                "process",
235                crate::chain::EVENT_KIND_PROCESS_DEREGISTER,
236                Some(serde_json::json!({
237                    "pid": pid,
238                    "agent_id": &entry.agent_id,
239                })),
240            );
241        }
242
243        removed
244    }
245
246    /// List all process entries (cloned).
247    pub fn list(&self) -> Vec<ProcessEntry> {
248        self.entries.iter().map(|e| e.value().clone()).collect()
249    }
250
251    /// Update the state of a process.
252    ///
253    /// Validates the state transition before applying.
254    pub fn update_state(&self, pid: Pid, new_state: ProcessState) -> Result<(), KernelError> {
255        let mut entry = self
256            .entries
257            .get_mut(&pid)
258            .ok_or(KernelError::ProcessNotFound { pid })?;
259
260        if !entry.state.can_transition_to(&new_state) {
261            return Err(KernelError::InvalidStateTransition {
262                pid,
263                from: entry.state.clone(),
264                to: new_state,
265            });
266        }
267
268        #[cfg(feature = "exochain")]
269        if let Some(ref cm) = self.chain_manager {
270            cm.append(
271                "process",
272                crate::chain::EVENT_KIND_PROCESS_STATE,
273                Some(serde_json::json!({
274                    "pid": pid,
275                    "from": entry.state.to_string(),
276                    "to": new_state.to_string(),
277                })),
278            );
279        }
280
281        entry.state = new_state;
282        Ok(())
283    }
284
285    /// Update resource usage for a process.
286    pub fn update_resources(&self, pid: Pid, usage: ResourceUsage) -> Result<(), KernelError> {
287        let mut entry = self
288            .entries
289            .get_mut(&pid)
290            .ok_or(KernelError::ProcessNotFound { pid })?;
291        entry.resource_usage = usage;
292        Ok(())
293    }
294
295    /// Get the number of processes in the table.
296    pub fn len(&self) -> usize {
297        self.entries.len()
298    }
299
300    /// Check whether the process table is empty.
301    pub fn is_empty(&self) -> bool {
302        self.entries.is_empty()
303    }
304
305    /// Get the maximum process capacity.
306    pub fn max_processes(&self) -> u32 {
307        self.max_processes
308    }
309
310    /// Update the capabilities of a process.
311    ///
312    /// Replaces the existing capabilities with the given ones.
313    /// This is used by the supervisor when hot-updating an agent's
314    /// permissions (future work) or during restart.
315    pub fn set_capabilities(
316        &self,
317        pid: Pid,
318        capabilities: AgentCapabilities,
319    ) -> Result<(), KernelError> {
320        let mut entry = self
321            .entries
322            .get_mut(&pid)
323            .ok_or(KernelError::ProcessNotFound { pid })?;
324        entry.capabilities = capabilities;
325        Ok(())
326    }
327
328    /// Count processes in the given state.
329    pub fn count_by_state(&self, state: &ProcessState) -> usize {
330        self.entries.iter().filter(|e| &e.state == state).count()
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    fn make_entry(agent_id: &str) -> ProcessEntry {
339        ProcessEntry {
340            pid: 0, // Will be set by insert()
341            agent_id: agent_id.to_owned(),
342            state: ProcessState::Starting,
343            capabilities: AgentCapabilities::default(),
344            resource_usage: ResourceUsage::default(),
345            cancel_token: CancellationToken::new(),
346            parent_pid: None,
347        }
348    }
349
350    #[test]
351    fn insert_and_get() {
352        let table = ProcessTable::new(64);
353        let pid = table.insert(make_entry("agent-1")).unwrap();
354        assert_eq!(pid, 1);
355
356        let entry = table.get(pid).unwrap();
357        assert_eq!(entry.agent_id, "agent-1");
358        assert_eq!(entry.pid, 1);
359    }
360
361    #[test]
362    fn insert_multiple() {
363        let table = ProcessTable::new(64);
364        let p1 = table.insert(make_entry("a1")).unwrap();
365        let p2 = table.insert(make_entry("a2")).unwrap();
366        let p3 = table.insert(make_entry("a3")).unwrap();
367
368        assert_eq!(p1, 1);
369        assert_eq!(p2, 2);
370        assert_eq!(p3, 3);
371        assert_eq!(table.len(), 3);
372    }
373
374    #[test]
375    fn remove() {
376        let table = ProcessTable::new(64);
377        let pid = table.insert(make_entry("agent-1")).unwrap();
378        let removed = table.remove(pid);
379        assert!(removed.is_some());
380        assert!(table.get(pid).is_none());
381        assert!(table.is_empty());
382    }
383
384    #[test]
385    fn remove_nonexistent() {
386        let table = ProcessTable::new(64);
387        assert!(table.remove(999).is_none());
388    }
389
390    #[test]
391    fn list() {
392        let table = ProcessTable::new(64);
393        table.insert(make_entry("a1")).unwrap();
394        table.insert(make_entry("a2")).unwrap();
395
396        let entries = table.list();
397        assert_eq!(entries.len(), 2);
398    }
399
400    #[test]
401    fn update_state_valid() {
402        let table = ProcessTable::new(64);
403        let pid = table.insert(make_entry("agent-1")).unwrap();
404
405        // Starting -> Running
406        table.update_state(pid, ProcessState::Running).unwrap();
407        assert_eq!(table.get(pid).unwrap().state, ProcessState::Running);
408
409        // Running -> Stopping
410        table.update_state(pid, ProcessState::Stopping).unwrap();
411        assert_eq!(table.get(pid).unwrap().state, ProcessState::Stopping);
412
413        // Stopping -> Exited
414        table.update_state(pid, ProcessState::Exited(0)).unwrap();
415        assert_eq!(table.get(pid).unwrap().state, ProcessState::Exited(0));
416    }
417
418    #[test]
419    fn update_state_invalid_transition() {
420        let table = ProcessTable::new(64);
421        let pid = table.insert(make_entry("agent-1")).unwrap();
422
423        // Starting -> Suspended is not valid
424        let result = table.update_state(pid, ProcessState::Suspended);
425        assert!(result.is_err());
426    }
427
428    #[test]
429    fn update_state_nonexistent_pid() {
430        let table = ProcessTable::new(64);
431        let result = table.update_state(999, ProcessState::Running);
432        assert!(result.is_err());
433    }
434
435    #[test]
436    fn process_table_full() {
437        let table = ProcessTable::new(2);
438        table.insert(make_entry("a1")).unwrap();
439        table.insert(make_entry("a2")).unwrap();
440        let result = table.insert(make_entry("a3"));
441        assert!(result.is_err());
442    }
443
444    #[test]
445    fn update_resources() {
446        let table = ProcessTable::new(64);
447        let pid = table.insert(make_entry("agent-1")).unwrap();
448
449        let usage = ResourceUsage {
450            memory_bytes: 1024,
451            cpu_time_ms: 500,
452            tool_calls: 10,
453            messages_sent: 5,
454        };
455        table.update_resources(pid, usage).unwrap();
456
457        let entry = table.get(pid).unwrap();
458        assert_eq!(entry.resource_usage.memory_bytes, 1024);
459        assert_eq!(entry.resource_usage.cpu_time_ms, 500);
460    }
461
462    #[test]
463    fn state_display() {
464        assert_eq!(ProcessState::Starting.to_string(), "starting");
465        assert_eq!(ProcessState::Running.to_string(), "running");
466        assert_eq!(ProcessState::Suspended.to_string(), "suspended");
467        assert_eq!(ProcessState::Stopping.to_string(), "stopping");
468        assert_eq!(ProcessState::Exited(0).to_string(), "exited(0)");
469        assert_eq!(ProcessState::Exited(-1).to_string(), "exited(-1)");
470    }
471
472    #[test]
473    fn state_transitions() {
474        // Valid transitions
475        assert!(ProcessState::Starting.can_transition_to(&ProcessState::Running));
476        assert!(ProcessState::Starting.can_transition_to(&ProcessState::Exited(1)));
477        assert!(ProcessState::Running.can_transition_to(&ProcessState::Suspended));
478        assert!(ProcessState::Running.can_transition_to(&ProcessState::Stopping));
479        assert!(ProcessState::Running.can_transition_to(&ProcessState::Exited(0)));
480        assert!(ProcessState::Suspended.can_transition_to(&ProcessState::Running));
481        assert!(ProcessState::Suspended.can_transition_to(&ProcessState::Stopping));
482        assert!(ProcessState::Stopping.can_transition_to(&ProcessState::Exited(0)));
483
484        // Invalid transitions
485        assert!(!ProcessState::Starting.can_transition_to(&ProcessState::Suspended));
486        assert!(!ProcessState::Starting.can_transition_to(&ProcessState::Stopping));
487        assert!(!ProcessState::Stopping.can_transition_to(&ProcessState::Running));
488        assert!(!ProcessState::Exited(0).can_transition_to(&ProcessState::Running));
489        assert!(!ProcessState::Exited(0).can_transition_to(&ProcessState::Starting));
490    }
491
492    #[test]
493    fn insert_with_pid_kernel() {
494        let table = ProcessTable::new(64);
495        let entry = ProcessEntry {
496            pid: 0,
497            agent_id: "kernel".to_owned(),
498            state: ProcessState::Running,
499            capabilities: AgentCapabilities::default(),
500            resource_usage: ResourceUsage::default(),
501            cancel_token: CancellationToken::new(),
502            parent_pid: None,
503        };
504        table.insert_with_pid(entry).unwrap();
505        assert_eq!(table.get(0).unwrap().agent_id, "kernel");
506    }
507
508    #[test]
509    fn set_capabilities() {
510        let table = ProcessTable::new(64);
511        let pid = table.insert(make_entry("agent-1")).unwrap();
512
513        let new_caps = AgentCapabilities {
514            can_spawn: false,
515            can_network: true,
516            ..Default::default()
517        };
518        table.set_capabilities(pid, new_caps).unwrap();
519
520        let entry = table.get(pid).unwrap();
521        assert!(!entry.capabilities.can_spawn);
522        assert!(entry.capabilities.can_network);
523    }
524
525    #[test]
526    fn set_capabilities_nonexistent_pid() {
527        let table = ProcessTable::new(64);
528        let result = table.set_capabilities(999, AgentCapabilities::default());
529        assert!(result.is_err());
530    }
531
532    #[test]
533    fn count_by_state() {
534        let table = ProcessTable::new(64);
535        let p1 = table.insert(make_entry("a1")).unwrap();
536        let p2 = table.insert(make_entry("a2")).unwrap();
537        table.insert(make_entry("a3")).unwrap();
538
539        // All start as Starting
540        assert_eq!(table.count_by_state(&ProcessState::Starting), 3);
541        assert_eq!(table.count_by_state(&ProcessState::Running), 0);
542
543        table.update_state(p1, ProcessState::Running).unwrap();
544        table.update_state(p2, ProcessState::Running).unwrap();
545
546        assert_eq!(table.count_by_state(&ProcessState::Starting), 1);
547        assert_eq!(table.count_by_state(&ProcessState::Running), 2);
548    }
549}