Skip to main content

clawft_kernel/
reconciler.rs

1//! Reconciliation controller (K2b-G1).
2//!
3//! Compares desired state (from `AppManifest`) against actual state
4//! (from `ProcessTable`) and takes corrective action: spawning
5//! missing agents, stopping extra agents, and logging state
6//! mismatches.
7//!
8//! Gated behind `cfg(feature = "os-patterns")`.
9
10use std::sync::Arc;
11use std::time::Duration;
12
13use dashmap::DashMap;
14use serde::{Deserialize, Serialize};
15use tokio::sync::RwLock;
16use tracing::debug;
17
18use crate::capability::AgentCapabilities;
19use crate::process::{Pid, ProcessState, ProcessTable};
20
21/// Desired state for a single agent.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct DesiredAgentState {
24    /// Application that owns this agent.
25    pub app_id: String,
26    /// Agent identifier.
27    pub agent_id: String,
28    /// Agent type label.
29    pub agent_type: String,
30    /// Number of replicas desired.
31    pub replicas: u32,
32    /// Capabilities to assign.
33    pub capabilities: AgentCapabilities,
34}
35
36/// A drift event detected by the reconciler.
37#[non_exhaustive]
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub enum DriftEvent {
40    /// A desired agent is not running.
41    AgentMissing { agent_id: String, app_id: String },
42    /// An agent is running but not in the desired state.
43    ExtraAgent { pid: Pid, agent_id: String },
44    /// An agent is in the wrong state.
45    WrongState {
46        pid: Pid,
47        expected: String,
48        actual: String,
49    },
50    /// An agent was spawned to correct drift.
51    AgentSpawned { agent_id: String, app_id: String },
52    /// An extra agent was stopped.
53    AgentStopped { pid: Pid, agent_id: String },
54}
55
56/// Reconciliation controller: desired state vs actual state.
57///
58/// Registered as a `SystemService` with `ServiceType::Core`.
59/// Runs a background tick every `interval` to detect and correct
60/// drift between desired and actual process states.
61pub struct ReconciliationController {
62    interval: Duration,
63    desired: DashMap<String, DesiredAgentState>,
64    drifts: Arc<RwLock<Vec<DriftEvent>>>,
65    process_table: Arc<ProcessTable>,
66    max_drift_history: usize,
67    /// Optional chain manager for exochain audit logging.
68    #[cfg(feature = "exochain")]
69    chain_manager: Option<Arc<crate::chain::ChainManager>>,
70}
71
72impl ReconciliationController {
73    /// Create a new reconciliation controller.
74    pub fn new(process_table: Arc<ProcessTable>, interval: Duration) -> Self {
75        Self {
76            interval,
77            desired: DashMap::new(),
78            drifts: Arc::new(RwLock::new(Vec::new())),
79            process_table,
80            max_drift_history: 100,
81            #[cfg(feature = "exochain")]
82            chain_manager: None,
83        }
84    }
85
86    /// Attach a chain manager for exochain audit logging.
87    #[cfg(feature = "exochain")]
88    pub fn set_chain_manager(&mut self, cm: Arc<crate::chain::ChainManager>) {
89        self.chain_manager = Some(cm);
90    }
91
92    /// Get the tick interval.
93    pub fn interval(&self) -> Duration {
94        self.interval
95    }
96
97    /// Set desired state for an agent.
98    pub fn set_desired(&self, key: String, state: DesiredAgentState) {
99        #[cfg(feature = "exochain")]
100        if let Some(ref cm) = self.chain_manager {
101            cm.append(
102                "reconciler",
103                crate::chain::EVENT_KIND_RECONCILER_DESIRED_SET,
104                Some(serde_json::json!({
105                    "key": &key,
106                    "agent_id": &state.agent_id,
107                    "app_id": &state.app_id,
108                    "replicas": state.replicas,
109                })),
110            );
111        }
112        self.desired.insert(key, state);
113    }
114
115    /// Remove desired state for an agent.
116    pub fn remove_desired(&self, key: &str) -> Option<DesiredAgentState> {
117        #[cfg(feature = "exochain")]
118        if let Some(ref cm) = self.chain_manager {
119            cm.append(
120                "reconciler",
121                crate::chain::EVENT_KIND_RECONCILER_DESIRED_REMOVE,
122                Some(serde_json::json!({
123                    "key": key,
124                })),
125            );
126        }
127        self.desired.remove(key).map(|(_, v)| v)
128    }
129
130    /// Get all desired states.
131    pub fn list_desired(&self) -> Vec<DesiredAgentState> {
132        self.desired.iter().map(|e| e.value().clone()).collect()
133    }
134
135    /// Get recent drift events.
136    pub async fn recent_drifts(&self) -> Vec<DriftEvent> {
137        self.drifts.read().await.clone()
138    }
139
140    /// Run a single reconciliation tick.
141    ///
142    /// Compares desired state against process table and returns
143    /// detected drifts. Does NOT take corrective action (that
144    /// requires a supervisor reference and governance gate).
145    pub async fn tick(&self) -> Vec<DriftEvent> {
146        let mut drifts = Vec::new();
147
148        // Check desired agents exist in process table
149        for entry in self.desired.iter() {
150            let desired = entry.value();
151            let matching: Vec<_> = self
152                .process_table
153                .list()
154                .into_iter()
155                .filter(|p| p.agent_id == desired.agent_id)
156                .collect();
157
158            let running_count = matching
159                .iter()
160                .filter(|p| p.state == ProcessState::Running)
161                .count() as u32;
162
163            if running_count < desired.replicas {
164                for _ in 0..(desired.replicas - running_count) {
165                    drifts.push(DriftEvent::AgentMissing {
166                        agent_id: desired.agent_id.clone(),
167                        app_id: desired.app_id.clone(),
168                    });
169                }
170            }
171        }
172
173        // Check for extra agents (not in desired state)
174        for entry in self.process_table.list() {
175            if entry.state != ProcessState::Running {
176                continue;
177            }
178            // Skip kernel and system processes
179            if entry.agent_id.starts_with("kernel.") || entry.agent_id.starts_with("system.") {
180                continue;
181            }
182            // Skip PID 0 (kernel)
183            if entry.pid == 0 {
184                continue;
185            }
186
187            let is_desired = self
188                .desired
189                .iter()
190                .any(|d| d.value().agent_id == entry.agent_id);
191
192            if !is_desired {
193                drifts.push(DriftEvent::ExtraAgent {
194                    pid: entry.pid,
195                    agent_id: entry.agent_id.clone(),
196                });
197            }
198        }
199
200        // Store drift history (bounded)
201        {
202            let mut history = self.drifts.write().await;
203            history.extend(drifts.clone());
204            while history.len() > self.max_drift_history {
205                history.remove(0);
206            }
207        }
208
209        #[cfg(feature = "exochain")]
210        if let Some(ref cm) = self.chain_manager {
211            cm.append(
212                "reconciler",
213                crate::chain::EVENT_KIND_RECONCILER_TICK,
214                Some(serde_json::json!({
215                    "drift_count": drifts.len(),
216                    "desired_count": self.desired.len(),
217                })),
218            );
219        }
220
221        debug!(drift_count = drifts.len(), "reconciliation tick completed");
222        drifts
223    }
224
225    /// Record a corrective action in drift history.
226    pub async fn record_action(&self, event: DriftEvent) {
227        #[cfg(feature = "exochain")]
228        if let Some(ref cm) = self.chain_manager {
229            cm.append(
230                "reconciler",
231                crate::chain::EVENT_KIND_RECONCILER_ACTION,
232                Some(serde_json::json!({
233                    "event": serde_json::to_value(&event).unwrap_or_default(),
234                })),
235            );
236        }
237        let mut history = self.drifts.write().await;
238        history.push(event);
239        while history.len() > self.max_drift_history {
240            history.remove(0);
241        }
242    }
243
244    /// Clear all desired state (used during shutdown).
245    pub fn clear_desired(&self) {
246        self.desired.clear();
247    }
248
249    /// Get the number of desired agent entries.
250    pub fn desired_count(&self) -> usize {
251        self.desired.len()
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use crate::capability::AgentCapabilities;
259    use crate::process::{ProcessEntry, ResourceUsage};
260    use tokio_util::sync::CancellationToken;
261
262    fn make_process_table() -> Arc<ProcessTable> {
263        Arc::new(ProcessTable::new(64))
264    }
265
266    fn make_controller(pt: Arc<ProcessTable>) -> ReconciliationController {
267        ReconciliationController::new(pt, Duration::from_secs(5))
268    }
269
270    fn insert_running_agent(pt: &ProcessTable, agent_id: &str) -> Pid {
271        let entry = ProcessEntry {
272            pid: 0,
273            agent_id: agent_id.to_owned(),
274            state: ProcessState::Starting,
275            capabilities: AgentCapabilities::default(),
276            resource_usage: ResourceUsage::default(),
277            cancel_token: CancellationToken::new(),
278            parent_pid: None,
279        };
280        let pid = pt.insert(entry).unwrap();
281        pt.update_state(pid, ProcessState::Running).unwrap();
282        pid
283    }
284
285    fn make_desired(app_id: &str, agent_id: &str) -> DesiredAgentState {
286        DesiredAgentState {
287            app_id: app_id.to_owned(),
288            agent_id: agent_id.to_owned(),
289            agent_type: "worker".to_owned(),
290            replicas: 1,
291            capabilities: AgentCapabilities::default(),
292        }
293    }
294
295    #[tokio::test]
296    async fn detects_missing_agent() {
297        let pt = make_process_table();
298        let ctrl = make_controller(pt);
299
300        ctrl.set_desired("app/worker-1".into(), make_desired("app", "worker-1"));
301
302        let drifts = ctrl.tick().await;
303        assert_eq!(drifts.len(), 1);
304        assert!(
305            matches!(&drifts[0], DriftEvent::AgentMissing { agent_id, .. } if agent_id == "worker-1")
306        );
307    }
308
309    #[tokio::test]
310    async fn no_drift_when_agent_running() {
311        let pt = make_process_table();
312        insert_running_agent(&pt, "worker-1");
313        let ctrl = make_controller(pt);
314
315        ctrl.set_desired("app/worker-1".into(), make_desired("app", "worker-1"));
316
317        let drifts = ctrl.tick().await;
318        // No missing agents, but worker-1 is desired so no extra either
319        assert!(drifts.is_empty());
320    }
321
322    #[tokio::test]
323    async fn detects_extra_agent() {
324        let pt = make_process_table();
325        insert_running_agent(&pt, "rogue-agent");
326        let ctrl = make_controller(pt);
327
328        // No desired state set -- rogue-agent is extra
329        let drifts = ctrl.tick().await;
330        assert_eq!(drifts.len(), 1);
331        assert!(
332            matches!(&drifts[0], DriftEvent::ExtraAgent { agent_id, .. } if agent_id == "rogue-agent")
333        );
334    }
335
336    #[tokio::test]
337    async fn skips_kernel_and_system_agents() {
338        let pt = make_process_table();
339        insert_running_agent(&pt, "kernel.boot");
340        insert_running_agent(&pt, "system.health");
341        let ctrl = make_controller(pt);
342
343        let drifts = ctrl.tick().await;
344        assert!(drifts.is_empty());
345    }
346
347    #[tokio::test]
348    async fn detects_multiple_missing_replicas() {
349        let pt = make_process_table();
350        let ctrl = make_controller(pt);
351
352        let mut desired = make_desired("app", "worker-1");
353        desired.replicas = 3;
354        ctrl.set_desired("app/worker-1".into(), desired);
355
356        let drifts = ctrl.tick().await;
357        assert_eq!(drifts.len(), 3);
358    }
359
360    #[tokio::test]
361    async fn partial_missing_replicas() {
362        let pt = make_process_table();
363        insert_running_agent(&pt, "worker-1");
364        let ctrl = make_controller(pt);
365
366        let mut desired = make_desired("app", "worker-1");
367        desired.replicas = 3;
368        ctrl.set_desired("app/worker-1".into(), desired);
369
370        let drifts = ctrl.tick().await;
371        // 3 desired, 1 running = 2 missing
372        let missing: Vec<_> = drifts
373            .iter()
374            .filter(|d| matches!(d, DriftEvent::AgentMissing { .. }))
375            .collect();
376        assert_eq!(missing.len(), 2);
377    }
378
379    #[tokio::test]
380    async fn drift_history_bounded() {
381        let pt = make_process_table();
382        let ctrl = make_controller(pt);
383
384        // Insert 150 drifts manually via record_action
385        for i in 0..150 {
386            ctrl.record_action(DriftEvent::AgentMissing {
387                agent_id: format!("agent-{i}"),
388                app_id: "app".into(),
389            })
390            .await;
391        }
392
393        let history = ctrl.recent_drifts().await;
394        assert!(history.len() <= 100);
395    }
396
397    #[tokio::test]
398    async fn set_and_remove_desired() {
399        let pt = make_process_table();
400        let ctrl = make_controller(pt);
401
402        ctrl.set_desired("app/w1".into(), make_desired("app", "w1"));
403        assert_eq!(ctrl.desired_count(), 1);
404
405        let removed = ctrl.remove_desired("app/w1");
406        assert!(removed.is_some());
407        assert_eq!(ctrl.desired_count(), 0);
408    }
409
410    #[tokio::test]
411    async fn clear_desired() {
412        let pt = make_process_table();
413        let ctrl = make_controller(pt);
414
415        ctrl.set_desired("app/w1".into(), make_desired("app", "w1"));
416        ctrl.set_desired("app/w2".into(), make_desired("app", "w2"));
417        assert_eq!(ctrl.desired_count(), 2);
418
419        ctrl.clear_desired();
420        assert_eq!(ctrl.desired_count(), 0);
421    }
422
423    #[tokio::test]
424    async fn interval_accessor() {
425        let pt = make_process_table();
426        let ctrl = ReconciliationController::new(pt, Duration::from_secs(10));
427        assert_eq!(ctrl.interval(), Duration::from_secs(10));
428    }
429
430    #[test]
431    fn drift_event_serde_roundtrip() {
432        let events = vec![
433            DriftEvent::AgentMissing {
434                agent_id: "w1".into(),
435                app_id: "app".into(),
436            },
437            DriftEvent::ExtraAgent {
438                pid: 42,
439                agent_id: "rogue".into(),
440            },
441            DriftEvent::WrongState {
442                pid: 5,
443                expected: "running".into(),
444                actual: "stopped".into(),
445            },
446        ];
447        for event in events {
448            let json = serde_json::to_string(&event).unwrap();
449            let _: DriftEvent = serde_json::from_str(&json).unwrap();
450        }
451    }
452
453    #[test]
454    fn desired_agent_state_serde() {
455        let state = make_desired("app", "worker-1");
456        let json = serde_json::to_string(&state).unwrap();
457        let restored: DesiredAgentState = serde_json::from_str(&json).unwrap();
458        assert_eq!(restored.agent_id, "worker-1");
459        assert_eq!(restored.app_id, "app");
460    }
461}