1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct DesiredAgentState {
24 pub app_id: String,
26 pub agent_id: String,
28 pub agent_type: String,
30 pub replicas: u32,
32 pub capabilities: AgentCapabilities,
34}
35
36#[non_exhaustive]
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub enum DriftEvent {
40 AgentMissing { agent_id: String, app_id: String },
42 ExtraAgent { pid: Pid, agent_id: String },
44 WrongState {
46 pid: Pid,
47 expected: String,
48 actual: String,
49 },
50 AgentSpawned { agent_id: String, app_id: String },
52 AgentStopped { pid: Pid, agent_id: String },
54}
55
56pub 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 #[cfg(feature = "exochain")]
69 chain_manager: Option<Arc<crate::chain::ChainManager>>,
70}
71
72impl ReconciliationController {
73 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 #[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 pub fn interval(&self) -> Duration {
94 self.interval
95 }
96
97 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 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 pub fn list_desired(&self) -> Vec<DesiredAgentState> {
132 self.desired.iter().map(|e| e.value().clone()).collect()
133 }
134
135 pub async fn recent_drifts(&self) -> Vec<DriftEvent> {
137 self.drifts.read().await.clone()
138 }
139
140 pub async fn tick(&self) -> Vec<DriftEvent> {
146 let mut drifts = Vec::new();
147
148 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 for entry in self.process_table.list() {
175 if entry.state != ProcessState::Running {
176 continue;
177 }
178 if entry.agent_id.starts_with("kernel.") || entry.agent_id.starts_with("system.") {
180 continue;
181 }
182 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 {
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 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 pub fn clear_desired(&self) {
246 self.desired.clear();
247 }
248
249 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 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 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 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 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}