Skip to main content

quorum_rs/status/
mod.rs

1//! Agent status monitoring types + optional HTTP dashboard.
2//!
3//! Provides [`AgentStatusSnapshot`] — a real-time view of agent state for
4//! operator monitoring — and the associated event/task log entry types.
5//!
6//! Enable the `status-server` feature to serve a lightweight dashboard
7//! via an embedded axum server (see [`server`] / [`multi_server`]).
8
9pub mod agent_events;
10
11#[cfg(feature = "status-server")]
12pub mod server;
13
14#[cfg(feature = "status-server")]
15pub mod multi_server;
16
17use serde::{Deserialize, Serialize};
18use std::collections::VecDeque;
19use std::sync::Arc;
20use tokio::sync::RwLock;
21use utoipa::ToSchema;
22
23/// Maximum number of recent tasks retained in the snapshot.
24const MAX_RECENT_TASKS: usize = 20;
25
26/// Maximum number of event log entries retained in the snapshot.
27///
28/// The event log is a fixed-size ring buffer, not a time window: it holds the
29/// most recent `MAX_EVENT_LOG` lifecycle events per agent regardless of age.
30/// Time-scoped views (e.g. the 24h error feed) filter this buffer by timestamp,
31/// so on an agent that emits more than this many events within the window, the
32/// oldest in-window entries are evicted before the cutoff.
33pub(crate) const MAX_EVENT_LOG: usize = 200;
34
35/// Maximum number of recent peer evaluation scores retained.
36const MAX_RECENT_SCORES: usize = 50;
37
38/// Shared handle to the agent status snapshot.
39pub type SharedAgentStatus = Arc<RwLock<AgentStatusSnapshot>>;
40
41/// Real-time snapshot of agent state for operator monitoring.
42#[derive(Debug, Clone, Serialize, ToSchema)]
43pub struct AgentStatusSnapshot {
44    pub agent_id: String,
45    pub model_name: String,
46    pub provider_id: String,
47    pub nats_connected: bool,
48    pub current_job: Option<String>,
49    pub current_round: Option<u32>,
50    /// Current deliberation phase: `"propose"`, `"evaluate"`, or `null`.
51    pub current_phase: Option<String>,
52    pub uptime_secs: u64,
53    pub tasks_completed: u64,
54    pub tasks_failed: u64,
55    #[schema(value_type = Vec<TaskLogEntry>)]
56    pub recent_tasks: VecDeque<TaskLogEntry>,
57    pub scratchpad_keys: u64,
58    /// Chronological event log for the dashboard event stream.
59    #[schema(value_type = Vec<EventLogEntry>)]
60    pub event_log: VecDeque<EventLogEntry>,
61    /// Whether the agent is paused (HITL control plane).
62    pub is_paused: bool,
63    /// Number of responses currently held in the HITL buffer.
64    pub buffered_count: u32,
65    /// Rolling error rate: `tasks_failed / (tasks_completed + tasks_failed)`.
66    pub error_rate: f32,
67    /// Recent peer evaluation scores received from the orchestrator.
68    /// Primary divergence indicator — consistently low scores flag a problem.
69    #[schema(value_type = Vec<ScoreEntry>)]
70    pub recent_scores: VecDeque<ScoreEntry>,
71    /// Rolling mean of `recent_scores`. `None` if no scores received yet.
72    pub mean_score: Option<f32>,
73    /// Standard deviation of recent scores — higher values indicate divergence.
74    pub score_std_dev: Option<f32>,
75    /// Whether the agent is flagged for operator attention.
76    pub is_flagged: bool,
77    /// Human-readable reason why the agent is flagged.
78    pub flag_reason: Option<String>,
79}
80
81/// Log entry for a completed task.
82#[derive(Debug, Clone, Serialize, ToSchema)]
83pub struct TaskLogEntry {
84    pub timestamp: String,
85    pub action: String,
86    pub job_id: String,
87    pub round: u32,
88    /// `"ok"` or `"error"`.
89    pub status: String,
90    pub duration_ms: u64,
91    /// Truncated preview of the response content (proposal text or evaluation summary).
92    /// Kept short to avoid bloating the status snapshot.
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub content_preview: Option<String>,
95}
96
97/// Event log entry for the dashboard event stream.
98#[derive(Debug, Clone, Serialize, ToSchema)]
99pub struct EventLogEntry {
100    pub timestamp: String,
101    /// Event type: `"agent_accepted"`, `"agent_working"`, `"task_complete"`,
102    /// `"agent_error"`, `"heartbeat"`, `"connected"`, etc.
103    pub event_type: String,
104    /// Job or session ID (if applicable).
105    pub job_id: Option<String>,
106    /// Human-readable detail string.
107    pub detail: String,
108}
109
110/// Score received from a peer evaluator via the orchestrator.
111///
112/// Tracks how this agent's proposals are rated by others — a consistently
113/// low `mean_score` across the `recent_scores` deque is the primary
114/// divergence flag in the HITL control plane UI.
115#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
116pub struct ScoreEntry {
117    pub timestamp: String,
118    /// Session / job ID.
119    pub job_id: String,
120    /// Deliberation round number.
121    pub round: u32,
122    /// ID of the evaluator who scored this agent.
123    pub evaluator: String,
124    /// The evaluation score.
125    pub score: f32,
126}
127
128impl AgentStatusSnapshot {
129    /// Create a new snapshot with identity fields populated.
130    pub fn new(agent_id: String, model_name: String, provider_id: String) -> Self {
131        Self {
132            agent_id,
133            model_name,
134            provider_id,
135            nats_connected: false,
136            current_job: None,
137            current_round: None,
138            current_phase: None,
139            uptime_secs: 0,
140            tasks_completed: 0,
141            tasks_failed: 0,
142            recent_tasks: VecDeque::with_capacity(MAX_RECENT_TASKS + 1),
143            scratchpad_keys: 0,
144            event_log: VecDeque::with_capacity(MAX_EVENT_LOG + 1),
145            is_paused: false,
146            buffered_count: 0,
147            error_rate: 0.0,
148            recent_scores: VecDeque::with_capacity(MAX_RECENT_SCORES + 1),
149            mean_score: None,
150            score_std_dev: None,
151            is_flagged: false,
152            flag_reason: None,
153        }
154    }
155
156    /// Record a completed task. Trims the log to `MAX_RECENT_TASKS`.
157    pub fn push_task(&mut self, entry: TaskLogEntry) {
158        if entry.status == "ok" {
159            self.tasks_completed += 1;
160        } else {
161            self.tasks_failed += 1;
162        }
163        let total = self.tasks_completed + self.tasks_failed;
164        // total is always ≥ 1 here because we just incremented a counter above.
165        self.error_rate = self.tasks_failed as f32 / total as f32;
166        self.recent_tasks.push_front(entry);
167        if self.recent_tasks.len() > MAX_RECENT_TASKS {
168            self.recent_tasks.pop_back();
169        }
170    }
171
172    /// Record a peer evaluation score and update `mean_score`, `score_std_dev`,
173    /// and flagging state.
174    pub fn push_score(&mut self, entry: ScoreEntry) {
175        self.recent_scores.push_back(entry);
176        if self.recent_scores.len() > MAX_RECENT_SCORES {
177            self.recent_scores.pop_front();
178        }
179        // Recompute rolling mean and std dev.
180        // n is always ≥ 1 here because we just pushed an entry above.
181        let n = self.recent_scores.len();
182        let sum: f32 = self.recent_scores.iter().map(|s| s.score).sum();
183        let mean = sum / n as f32;
184        self.mean_score = Some(mean);
185        if n >= 2 {
186            let variance: f32 = self
187                .recent_scores
188                .iter()
189                .map(|s| (s.score - mean).powi(2))
190                .sum::<f32>()
191                / n as f32;
192            self.score_std_dev = Some(variance.sqrt());
193        } else {
194            self.score_std_dev = None;
195        }
196        self.check_flags();
197    }
198
199    /// Evaluate flagging conditions based on recent score history.
200    ///
201    /// Scores are signed QV sums (`Σ score_q_s`): positive = endorsed, negative
202    /// = rejected.  Thresholds are calibrated for this range.
203    ///
204    /// - **Rejected**: Recent 3-score average < −0.3 → flagged.
205    /// - **High divergence**: Score std_dev > 1.5 → flagged.
206    /// - Flags are cleared when conditions no longer apply.
207    pub fn check_flags(&mut self) {
208        let n = self.recent_scores.len();
209
210        // Check recent-3 average for persistently rejected proposals
211        if n >= 3 {
212            let recent_3_avg: f32 = self
213                .recent_scores
214                .iter()
215                .rev()
216                .take(3)
217                .map(|s| s.score)
218                .sum::<f32>()
219                / 3.0;
220            /// Score below this threshold flags the agent as persistently rejected.
221            const LOW_SCORE_THRESHOLD: f32 = -0.3;
222            if recent_3_avg < LOW_SCORE_THRESHOLD {
223                self.is_flagged = true;
224                self.flag_reason = Some(format!("Low scores: recent avg {:.2}", recent_3_avg));
225                return;
226            }
227        }
228
229        // Check std_dev for high divergence flag
230        if let Some(std_dev) = self.score_std_dev {
231            /// Std-dev above this threshold flags the agent as high-divergence.
232            const HIGH_DIVERGENCE_THRESHOLD: f32 = 1.5;
233            if std_dev > HIGH_DIVERGENCE_THRESHOLD {
234                self.is_flagged = true;
235                self.flag_reason = Some(format!("High divergence: std_dev {:.1}", std_dev));
236                return;
237            }
238        }
239
240        // No flag conditions met — clear
241        self.is_flagged = false;
242        self.flag_reason = None;
243    }
244
245    /// Append a lifecycle event to the event stream log.
246    pub fn push_event(&mut self, event_type: &str, job_id: Option<&str>, detail: &str) {
247        self.event_log.push_back(EventLogEntry {
248            timestamp: chrono::Utc::now().to_rfc3339(),
249            event_type: event_type.to_string(),
250            job_id: job_id.map(String::from),
251            detail: detail.to_string(),
252        });
253        if self.event_log.len() > MAX_EVENT_LOG {
254            self.event_log.pop_front();
255        }
256    }
257}
258
259/// Create a new shared status snapshot handle.
260pub fn new_shared_status(
261    agent_id: String,
262    model_name: String,
263    provider_id: String,
264) -> SharedAgentStatus {
265    Arc::new(RwLock::new(AgentStatusSnapshot::new(
266        agent_id,
267        model_name,
268        provider_id,
269    )))
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn test_snapshot_push_task_trims() {
278        let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
279        for i in 0..25 {
280            snap.push_task(TaskLogEntry {
281                timestamp: format!("2025-01-01T00:00:{:02}Z", i),
282                action: "propose".into(),
283                job_id: format!("job-{}", i),
284                round: 1,
285                status: if i % 3 == 0 {
286                    "error".into()
287                } else {
288                    "ok".into()
289                },
290                duration_ms: 100,
291                content_preview: None,
292            });
293        }
294        assert_eq!(snap.recent_tasks.len(), MAX_RECENT_TASKS);
295        // i % 3 == 0 → errors at 0,3,6,9,12,15,18,21,24 = 9 errors
296        assert_eq!(snap.tasks_failed, 9);
297        assert_eq!(snap.tasks_completed, 16);
298    }
299
300    #[test]
301    fn test_snapshot_counters() {
302        let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
303        snap.push_task(TaskLogEntry {
304            timestamp: "t".into(),
305            action: "propose".into(),
306            job_id: "j1".into(),
307            round: 1,
308            status: "ok".into(),
309            duration_ms: 50,
310            content_preview: None,
311        });
312        snap.push_task(TaskLogEntry {
313            timestamp: "t".into(),
314            action: "evaluate".into(),
315            job_id: "j1".into(),
316            round: 1,
317            status: "error".into(),
318            duration_ms: 100,
319            content_preview: None,
320        });
321        assert_eq!(snap.tasks_completed, 1);
322        assert_eq!(snap.tasks_failed, 1);
323        assert_eq!(snap.recent_tasks.len(), 2);
324        // Most recent first
325        assert_eq!(snap.recent_tasks[0].action, "evaluate");
326    }
327
328    #[test]
329    fn test_event_log_push_and_trim() {
330        let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
331        for i in 0..250 {
332            snap.push_event("test", Some("job1"), &format!("event {}", i));
333        }
334        assert_eq!(snap.event_log.len(), MAX_EVENT_LOG);
335        // Oldest events should have been trimmed
336        assert!(snap.event_log.front().unwrap().detail.contains("50"));
337    }
338
339    #[test]
340    fn test_event_log_entry_fields() {
341        let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
342        snap.push_event("agent_accepted", Some("job-123"), "Accepted manifest");
343        assert_eq!(snap.event_log.len(), 1);
344        let entry = snap.event_log.front().unwrap();
345        assert_eq!(entry.event_type, "agent_accepted");
346        assert_eq!(entry.job_id.as_deref(), Some("job-123"));
347        assert_eq!(entry.detail, "Accepted manifest");
348        assert!(!entry.timestamp.is_empty());
349    }
350
351    #[test]
352    fn test_snapshot_new_defaults() {
353        let snap = AgentStatusSnapshot::new("agent-1".into(), "gpt-4".into(), "openai".into());
354        assert_eq!(snap.agent_id, "agent-1");
355        assert_eq!(snap.model_name, "gpt-4");
356        assert_eq!(snap.provider_id, "openai");
357        assert!(!snap.nats_connected);
358        assert!(snap.current_job.is_none());
359        assert!(snap.current_round.is_none());
360        assert!(snap.current_phase.is_none());
361        assert_eq!(snap.uptime_secs, 0);
362        assert_eq!(snap.tasks_completed, 0);
363        assert_eq!(snap.tasks_failed, 0);
364        assert!(snap.recent_tasks.is_empty());
365        assert_eq!(snap.scratchpad_keys, 0);
366        assert!(snap.event_log.is_empty());
367    }
368
369    #[test]
370    fn test_push_event_without_job_id() {
371        let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
372        snap.push_event("heartbeat", None, "Still alive");
373        assert_eq!(snap.event_log.len(), 1);
374        let entry = snap.event_log.front().unwrap();
375        assert_eq!(entry.event_type, "heartbeat");
376        assert!(entry.job_id.is_none());
377        assert_eq!(entry.detail, "Still alive");
378    }
379
380    #[test]
381    fn test_push_task_ordering() {
382        let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
383        for i in 0..3 {
384            snap.push_task(TaskLogEntry {
385                timestamp: format!("t{}", i),
386                action: format!("action-{}", i),
387                job_id: "j1".into(),
388                round: 1,
389                status: "ok".into(),
390                duration_ms: 10,
391                content_preview: None,
392            });
393        }
394        // LIFO order: most recent first
395        assert_eq!(snap.recent_tasks[0].action, "action-2");
396        assert_eq!(snap.recent_tasks[1].action, "action-1");
397        assert_eq!(snap.recent_tasks[2].action, "action-0");
398    }
399
400    #[test]
401    fn test_snapshot_serialization() {
402        let mut snap =
403            AgentStatusSnapshot::new("agent-x".into(), "claude".into(), "anthropic".into());
404        snap.nats_connected = true;
405        snap.tasks_completed = 5;
406        snap.push_event("connected", None, "Connected to NATS");
407        let json = serde_json::to_string(&snap).unwrap();
408        assert!(json.contains("\"agent_id\":\"agent-x\""));
409        assert!(json.contains("\"nats_connected\":true"));
410        assert!(json.contains("\"tasks_completed\":5"));
411        assert!(json.contains("\"event_log\""));
412    }
413
414    #[tokio::test]
415    async fn test_new_shared_status() {
416        let shared = new_shared_status("agent-1".into(), "model".into(), "provider".into());
417        let snap = shared.read().await;
418        assert_eq!(snap.agent_id, "agent-1");
419        assert_eq!(snap.model_name, "model");
420        assert_eq!(snap.provider_id, "provider");
421        assert!(!snap.nats_connected);
422    }
423
424    // -------------------------------------------------------------------
425    // HITL control plane fields
426    // -------------------------------------------------------------------
427
428    #[test]
429    fn test_snapshot_new_has_default_hitl_fields() {
430        let snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
431        assert!(!snap.is_paused);
432        assert_eq!(snap.buffered_count, 0);
433        assert_eq!(snap.error_rate, 0.0);
434        assert!(snap.recent_scores.is_empty());
435        assert!(snap.mean_score.is_none());
436    }
437
438    #[test]
439    fn test_snapshot_error_rate_computed() {
440        let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
441        for i in 0..4 {
442            snap.push_task(TaskLogEntry {
443                timestamp: "t".into(),
444                action: "propose".into(),
445                job_id: format!("j{}", i),
446                round: 1,
447                status: if i == 3 { "error" } else { "ok" }.into(),
448                duration_ms: 10,
449                content_preview: None,
450            });
451        }
452        // 3 ok + 1 error → error_rate = 0.25
453        assert!((snap.error_rate - 0.25).abs() < f32::EPSILON);
454    }
455
456    #[test]
457    fn test_snapshot_error_rate_zero_tasks() {
458        let snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
459        assert_eq!(snap.error_rate, 0.0);
460    }
461
462    #[test]
463    fn test_snapshot_push_score_updates_mean() {
464        let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
465        snap.push_score(ScoreEntry {
466            timestamp: "t1".into(),
467            job_id: "j1".into(),
468            round: 1,
469            evaluator: "BETA".into(),
470            score: 6.0,
471        });
472        snap.push_score(ScoreEntry {
473            timestamp: "t2".into(),
474            job_id: "j1".into(),
475            round: 1,
476            evaluator: "GAMMA".into(),
477            score: 9.0,
478        });
479        snap.push_score(ScoreEntry {
480            timestamp: "t3".into(),
481            job_id: "j2".into(),
482            round: 1,
483            evaluator: "BETA".into(),
484            score: 3.0,
485        });
486        assert_eq!(snap.recent_scores.len(), 3);
487        // mean = (6 + 9 + 3) / 3 = 6.0
488        assert!((snap.mean_score.unwrap() - 6.0).abs() < f32::EPSILON);
489    }
490
491    #[test]
492    fn test_snapshot_push_score_trims_to_max() {
493        let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
494        for i in 0..60 {
495            snap.push_score(ScoreEntry {
496                timestamp: format!("t{}", i),
497                job_id: "j".into(),
498                round: 1,
499                evaluator: "e".into(),
500                score: i as f32,
501            });
502        }
503        assert_eq!(snap.recent_scores.len(), MAX_RECENT_SCORES);
504        // Oldest (0..9) trimmed; remaining are 10..59
505        assert_eq!(snap.recent_scores.front().unwrap().score, 10.0);
506    }
507
508    #[test]
509    fn test_score_entry_serialization() {
510        let entry = ScoreEntry {
511            timestamp: "2025-01-01T00:00:00Z".into(),
512            job_id: "job-abc".into(),
513            round: 3,
514            evaluator: "BETA".into(),
515            score: 7.5,
516        };
517        let json = serde_json::to_string(&entry).unwrap();
518        let roundtripped: ScoreEntry = serde_json::from_str(&json).unwrap();
519        assert_eq!(roundtripped.job_id, "job-abc");
520        assert_eq!(roundtripped.round, 3);
521        assert_eq!(roundtripped.evaluator, "BETA");
522        assert!((roundtripped.score - 7.5).abs() < f32::EPSILON);
523    }
524
525    // -------------------------------------------------------------------
526    // Divergence flagging tests
527    // -------------------------------------------------------------------
528
529    fn push_n_scores(snap: &mut AgentStatusSnapshot, scores: &[f32]) {
530        for (i, &s) in scores.iter().enumerate() {
531            snap.push_score(ScoreEntry {
532                timestamp: format!("t{}", i),
533                job_id: "j".into(),
534                round: 1,
535                evaluator: "e".into(),
536                score: s,
537            });
538        }
539    }
540
541    #[test]
542    fn test_push_score_computes_std_dev() {
543        let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
544        push_n_scores(&mut snap, &[4.0, 6.0, 8.0]);
545        // mean = 6.0, variance = ((4-6)^2 + (6-6)^2 + (8-6)^2) / 3 = 8/3
546        // std_dev = sqrt(8/3) ≈ 1.633
547        let std_dev = snap.score_std_dev.unwrap();
548        assert!((std_dev - 1.633).abs() < 0.01, "std_dev was {}", std_dev);
549    }
550
551    #[test]
552    fn test_std_dev_none_with_single_score() {
553        let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
554        push_n_scores(&mut snap, &[5.0]);
555        assert!(snap.score_std_dev.is_none());
556    }
557
558    #[test]
559    fn test_check_flags_low_scores() {
560        let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
561        // Recent 3 scores consistently negative (rejected proposals)
562        push_n_scores(&mut snap, &[-0.5, -0.8, -0.4]);
563        assert!(snap.is_flagged, "should be flagged for low scores");
564        assert!(
565            snap.flag_reason.as_ref().unwrap().contains("Low scores"),
566            "flag_reason: {:?}",
567            snap.flag_reason
568        );
569    }
570
571    #[test]
572    fn test_check_flags_high_divergence() {
573        let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
574        // Scores with high variance: std_dev > 1.5
575        push_n_scores(&mut snap, &[-2.0, 2.0, -2.0, 2.0, -2.0, 2.0]);
576        assert!(
577            snap.score_std_dev.unwrap() > 1.5,
578            "std_dev should be > 1.5, got {}",
579            snap.score_std_dev.unwrap()
580        );
581        assert!(snap.is_flagged);
582        assert!(
583            snap.flag_reason.as_ref().unwrap().contains("divergence"),
584            "flag_reason: {:?}",
585            snap.flag_reason
586        );
587    }
588
589    #[test]
590    fn test_check_flags_clears_when_scores_improve() {
591        let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
592        // Start flagged with negative scores
593        push_n_scores(&mut snap, &[-0.5, -0.6, -0.7]);
594        assert!(snap.is_flagged);
595
596        // Add positive scores — recent 3 avg now above -0.3
597        push_n_scores(&mut snap, &[0.8, 0.9, 1.0]);
598        assert!(!snap.is_flagged, "flag should be cleared after good scores");
599        assert!(snap.flag_reason.is_none());
600    }
601
602    #[test]
603    fn test_check_flags_not_flagged_with_good_scores() {
604        let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
605        // Positive signed scores = endorsed proposals
606        push_n_scores(&mut snap, &[0.7, 0.8, 0.9]);
607        assert!(!snap.is_flagged);
608        assert!(snap.flag_reason.is_none());
609    }
610
611    #[test]
612    fn test_snapshot_new_has_default_flag_fields() {
613        let snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
614        assert!(!snap.is_flagged);
615        assert!(snap.flag_reason.is_none());
616        assert!(snap.score_std_dev.is_none());
617    }
618
619    #[test]
620    fn test_agent_summary_includes_flag_fields_in_serialization() {
621        let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
622        // Consistently rejected proposals → triggers low-score flag
623        push_n_scores(&mut snap, &[-0.5, -0.6, -0.7]);
624        let json = serde_json::to_string(&snap).unwrap();
625        assert!(json.contains("\"is_flagged\":true"));
626        assert!(json.contains("\"flag_reason\""));
627        assert!(json.contains("\"score_std_dev\""));
628    }
629
630    #[test]
631    fn test_402_payment_sets_paused_and_flagged() {
632        // Simulates the worker's 402 Payment Required handling:
633        // the worker detects a 402 error and sets is_paused + is_flagged + flag_reason.
634        let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
635        assert!(!snap.is_paused);
636        assert!(!snap.is_flagged);
637        assert!(snap.flag_reason.is_none());
638
639        // Simulate what the worker does on 402
640        snap.is_paused = true;
641        snap.is_flagged = true;
642        snap.flag_reason = Some("Provider payment required (402) — agent paused".to_string());
643
644        assert!(snap.is_paused);
645        assert!(snap.is_flagged);
646        assert_eq!(
647            snap.flag_reason.as_deref(),
648            Some("Provider payment required (402) — agent paused")
649        );
650
651        // Verify it serializes correctly for the dashboard
652        let json = serde_json::to_string(&snap).unwrap();
653        assert!(json.contains("\"is_paused\":true"));
654        assert!(json.contains("\"is_flagged\":true"));
655        assert!(json.contains("402"));
656        assert!(json.contains("payment"));
657    }
658
659    // -------------------------------------------------------------------
660    // Event lifecycle ordering tests
661    // -------------------------------------------------------------------
662
663    #[test]
664    fn test_event_lifecycle_ordering_non_buffered() {
665        // Simulates the non-buffered lifecycle:
666        // connected → agent_working → task_complete
667        let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
668        snap.push_event("connected", None, "NATS connected");
669        snap.push_event("agent_working", Some("job-1"), "Round 1 propose");
670        snap.push_event("task_complete", Some("job-1"), "propose ok 150ms");
671
672        let types: Vec<&str> = snap
673            .event_log
674            .iter()
675            .map(|e| e.event_type.as_str())
676            .collect();
677        assert_eq!(types, vec!["connected", "agent_working", "task_complete"]);
678    }
679
680    #[test]
681    fn test_event_lifecycle_ordering_buffered() {
682        // Simulates the buffered lifecycle:
683        // connected → agent_working → response_buffered → buffer_released → task_complete
684        let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
685        snap.push_event("connected", None, "NATS connected");
686        snap.push_event("agent_working", Some("job-1"), "Round 1 propose");
687        snap.push_event(
688            "response_buffered",
689            Some("job-1"),
690            "Round 1 propose buffered 5000ms hold",
691        );
692        snap.push_event(
693            "buffer_released",
694            Some("job-1"),
695            "Round 1 propose released from buffer",
696        );
697        snap.push_event("task_complete", Some("job-1"), "Round 1 propose released");
698
699        let types: Vec<&str> = snap
700            .event_log
701            .iter()
702            .map(|e| e.event_type.as_str())
703            .collect();
704        assert_eq!(
705            types,
706            vec![
707                "connected",
708                "agent_working",
709                "response_buffered",
710                "buffer_released",
711                "task_complete"
712            ]
713        );
714
715        // All buffered events should have the same job_id
716        let job_events: Vec<_> = snap
717            .event_log
718            .iter()
719            .filter(|e| e.job_id.as_deref() == Some("job-1"))
720            .collect();
721        assert_eq!(job_events.len(), 4, "4 events should reference job-1");
722    }
723
724    #[test]
725    fn test_event_lifecycle_with_stop_unstop() {
726        // Simulates: buffered → operator stops → edits → unstops → released
727        let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
728        snap.push_event("agent_working", Some("job-1"), "Round 1 propose");
729        snap.push_event(
730            "response_buffered",
731            Some("job-1"),
732            "Round 1 propose buffered 5000ms hold",
733        );
734        snap.push_event(
735            "buffer_stopped",
736            Some("entry-1"),
737            "Response stopped by operator",
738        );
739        snap.push_event("buffer_unstopped", Some("entry-1"), "Stop removed");
740        snap.push_event("buffer_released", Some("job-1"), "Round 1 propose released");
741        snap.push_event("task_complete", Some("job-1"), "Round 1 propose released");
742
743        let types: Vec<&str> = snap
744            .event_log
745            .iter()
746            .map(|e| e.event_type.as_str())
747            .collect();
748        assert_eq!(
749            types,
750            vec![
751                "agent_working",
752                "response_buffered",
753                "buffer_stopped",
754                "buffer_unstopped",
755                "buffer_released",
756                "task_complete"
757            ]
758        );
759    }
760
761    #[test]
762    fn test_event_lifecycle_with_reject() {
763        // Simulates: buffered → operator rejects
764        let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
765        snap.push_event("agent_working", Some("job-1"), "Round 1 propose");
766        snap.push_event(
767            "response_buffered",
768            Some("job-1"),
769            "Round 1 propose buffered",
770        );
771        snap.push_event(
772            "buffer_rejected",
773            Some("job-1"),
774            "propose rejected by operator",
775        );
776
777        let types: Vec<&str> = snap
778            .event_log
779            .iter()
780            .map(|e| e.event_type.as_str())
781            .collect();
782        assert_eq!(
783            types,
784            vec!["agent_working", "response_buffered", "buffer_rejected"]
785        );
786        // No task_complete after reject — task is discarded
787    }
788
789    #[test]
790    fn test_event_lifecycle_multi_agent_interleaved() {
791        // Two agents working concurrently — events interleave correctly
792        let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
793        snap.push_event("agent_working", Some("job-A"), "Round 1 propose");
794        snap.push_event("agent_working", Some("job-B"), "Round 1 evaluate");
795        snap.push_event(
796            "response_buffered",
797            Some("job-A"),
798            "Round 1 propose buffered",
799        );
800        snap.push_event("task_complete", Some("job-B"), "evaluate ok");
801        snap.push_event("buffer_released", Some("job-A"), "Round 1 propose released");
802        snap.push_event("task_complete", Some("job-A"), "Round 1 propose released");
803
804        assert_eq!(snap.event_log.len(), 6);
805        // Verify job-A events are in order (not necessarily contiguous)
806        let job_a: Vec<&str> = snap
807            .event_log
808            .iter()
809            .filter(|e| e.job_id.as_deref() == Some("job-A"))
810            .map(|e| e.event_type.as_str())
811            .collect();
812        assert_eq!(
813            job_a,
814            vec![
815                "agent_working",
816                "response_buffered",
817                "buffer_released",
818                "task_complete"
819            ]
820        );
821    }
822
823    #[test]
824    fn test_event_buffered_count_tracking() {
825        // Verify buffered_count field tracks correctly
826        let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
827        assert_eq!(snap.buffered_count, 0);
828
829        snap.buffered_count = 3;
830        assert_eq!(snap.buffered_count, 3);
831
832        snap.buffered_count = 0;
833        assert_eq!(snap.buffered_count, 0);
834    }
835}