Skip to main content

agent_orchestrator/
anomaly.rs

1use serde::Serialize;
2
3/// Severity assigned to an anomaly detected in task traces or runtime events.
4#[derive(Debug, Serialize, Clone, PartialEq, Eq)]
5#[serde(rename_all = "lowercase")]
6pub enum Severity {
7    /// The anomaly indicates a correctness or safety problem.
8    Error,
9    /// The anomaly indicates degraded or suspicious behavior.
10    Warning,
11    /// The anomaly is informational and may not require action.
12    Info,
13}
14
15/// Recommended operator response for an anomaly.
16#[derive(Debug, Serialize, Clone, PartialEq)]
17#[serde(rename_all = "lowercase")]
18pub enum Escalation {
19    /// Record the anomaly without interrupting execution.
20    Notice,
21    /// Bring the anomaly to operator attention for follow-up.
22    Attention,
23    /// Interrupt or actively intervene in execution.
24    Intervene,
25}
26
27impl Escalation {
28    /// Returns a stable uppercase label for operator-facing displays.
29    pub fn label(&self) -> &'static str {
30        match self {
31            Escalation::Notice => "NOTICE",
32            Escalation::Attention => "ATTENTION",
33            Escalation::Intervene => "INTERVENE",
34        }
35    }
36}
37
38/// Canonical anomaly rules emitted by trace analysis.
39#[derive(Debug, Clone, PartialEq)]
40pub enum AnomalyRule {
41    /// A step produced too little output to be considered trustworthy.
42    LowOutput,
43    /// A step or cycle ran longer than expected.
44    LongRunning,
45    /// A transient read error interrupted trace collection.
46    TransientReadError,
47    /// More than one runner processed the same logical work.
48    DuplicateRunner,
49    /// Multiple workflow cycles overlapped unexpectedly.
50    OverlappingCycles,
51    /// Multiple steps overlapped unexpectedly.
52    OverlappingSteps,
53    /// A step start event was observed without a matching end event.
54    MissingStepEnd,
55    /// A workflow cycle completed without processing any steps.
56    EmptyCycle,
57    /// A command was observed without a matching task-step context.
58    OrphanCommand,
59    /// A command exited with a non-zero status.
60    NonzeroExit,
61    /// A templated variable remained unexpanded in emitted output.
62    UnexpandedTemplateVar,
63    /// FR-035: An item-phase pair failed repeatedly, indicating a degenerate loop.
64    DegenerateLoop,
65    /// FR-044: The sandbox denied one or more file-system writes during step execution.
66    SandboxDenied,
67    /// The daemon restarted (exec() or cold start) during the task's lifetime.
68    IncarnationBoundary,
69}
70
71impl AnomalyRule {
72    /// Returns the stable machine-readable name for the rule.
73    pub fn canonical_name(&self) -> &'static str {
74        match self {
75            AnomalyRule::LowOutput => "low_output",
76            AnomalyRule::LongRunning => "long_running",
77            AnomalyRule::TransientReadError => "transient_read_error",
78            AnomalyRule::DuplicateRunner => "duplicate_runner",
79            AnomalyRule::OverlappingCycles => "overlapping_cycles",
80            AnomalyRule::OverlappingSteps => "overlapping_steps",
81            AnomalyRule::MissingStepEnd => "missing_step_end",
82            AnomalyRule::EmptyCycle => "empty_cycle",
83            AnomalyRule::OrphanCommand => "orphan_command",
84            AnomalyRule::NonzeroExit => "nonzero_exit",
85            AnomalyRule::UnexpandedTemplateVar => "unexpanded_template_var",
86            AnomalyRule::DegenerateLoop => "degenerate_loop",
87            AnomalyRule::SandboxDenied => "sandbox_denied",
88            AnomalyRule::IncarnationBoundary => "incarnation_boundary",
89        }
90    }
91
92    /// Returns the default severity associated with the rule.
93    pub fn default_severity(&self) -> Severity {
94        match self {
95            AnomalyRule::DuplicateRunner
96            | AnomalyRule::OverlappingCycles
97            | AnomalyRule::OverlappingSteps
98            | AnomalyRule::DegenerateLoop
99            | AnomalyRule::SandboxDenied => Severity::Error,
100
101            AnomalyRule::LowOutput
102            | AnomalyRule::MissingStepEnd
103            | AnomalyRule::OrphanCommand
104            | AnomalyRule::NonzeroExit
105            | AnomalyRule::UnexpandedTemplateVar
106            | AnomalyRule::TransientReadError
107            | AnomalyRule::IncarnationBoundary => Severity::Warning,
108
109            AnomalyRule::LongRunning | AnomalyRule::EmptyCycle => Severity::Info,
110        }
111    }
112
113    /// Returns the default escalation policy associated with the rule.
114    pub fn escalation(&self) -> Escalation {
115        match self {
116            AnomalyRule::LowOutput
117            | AnomalyRule::DuplicateRunner
118            | AnomalyRule::OverlappingCycles
119            | AnomalyRule::OverlappingSteps
120            | AnomalyRule::DegenerateLoop
121            | AnomalyRule::SandboxDenied => Escalation::Intervene,
122
123            AnomalyRule::NonzeroExit
124            | AnomalyRule::OrphanCommand
125            | AnomalyRule::MissingStepEnd
126            | AnomalyRule::UnexpandedTemplateVar
127            | AnomalyRule::TransientReadError
128            | AnomalyRule::IncarnationBoundary => Escalation::Attention,
129
130            AnomalyRule::LongRunning | AnomalyRule::EmptyCycle => Escalation::Notice,
131        }
132    }
133
134    /// Returns the uppercase display tag used in reports and logs.
135    pub fn display_tag(&self) -> &'static str {
136        match self {
137            AnomalyRule::LowOutput => "LOW_OUTPUT",
138            AnomalyRule::LongRunning => "LONG_RUNNING",
139            AnomalyRule::TransientReadError => "TRANSIENT_READ_ERROR",
140            AnomalyRule::DuplicateRunner => "DUPLICATE_RUNNER",
141            AnomalyRule::OverlappingCycles => "OVERLAPPING_CYCLES",
142            AnomalyRule::OverlappingSteps => "OVERLAPPING_STEPS",
143            AnomalyRule::MissingStepEnd => "MISSING_STEP_END",
144            AnomalyRule::EmptyCycle => "EMPTY_CYCLE",
145            AnomalyRule::OrphanCommand => "ORPHAN_COMMAND",
146            AnomalyRule::NonzeroExit => "NONZERO_EXIT",
147            AnomalyRule::UnexpandedTemplateVar => "UNEXPANDED_TEMPLATE_VAR",
148            AnomalyRule::DegenerateLoop => "DEGENERATE_LOOP",
149            AnomalyRule::SandboxDenied => "SANDBOX_DENIED",
150            AnomalyRule::IncarnationBoundary => "INCARNATION_BOUNDARY",
151        }
152    }
153
154    /// Parses a canonical rule name back into an [`AnomalyRule`].
155    pub fn from_canonical(name: &str) -> Option<AnomalyRule> {
156        match name {
157            "low_output" => Some(AnomalyRule::LowOutput),
158            "long_running" => Some(AnomalyRule::LongRunning),
159            "transient_read_error" => Some(AnomalyRule::TransientReadError),
160            "duplicate_runner" => Some(AnomalyRule::DuplicateRunner),
161            "overlapping_cycles" => Some(AnomalyRule::OverlappingCycles),
162            "overlapping_steps" => Some(AnomalyRule::OverlappingSteps),
163            "missing_step_end" => Some(AnomalyRule::MissingStepEnd),
164            "empty_cycle" => Some(AnomalyRule::EmptyCycle),
165            "orphan_command" => Some(AnomalyRule::OrphanCommand),
166            "nonzero_exit" => Some(AnomalyRule::NonzeroExit),
167            "unexpanded_template_var" => Some(AnomalyRule::UnexpandedTemplateVar),
168            "degenerate_loop" => Some(AnomalyRule::DegenerateLoop),
169            "sandbox_denied" => Some(AnomalyRule::SandboxDenied),
170            "incarnation_boundary" => Some(AnomalyRule::IncarnationBoundary),
171            _ => None,
172        }
173    }
174}
175
176/// Serializable anomaly payload returned by trace analysis.
177#[derive(Debug, Serialize, Clone)]
178pub struct Anomaly {
179    /// Canonical rule name.
180    pub rule: String,
181    /// Default severity for the detected rule.
182    pub severity: Severity,
183    /// Recommended escalation level.
184    pub escalation: Escalation,
185    /// Human-readable anomaly description.
186    pub message: String,
187    /// Optional timestamp or location associated with the anomaly.
188    pub at: Option<String>,
189}
190
191impl Anomaly {
192    /// Builds an anomaly payload from a rule and message.
193    pub fn new(rule: AnomalyRule, message: String, at: Option<String>) -> Self {
194        Anomaly {
195            severity: rule.default_severity(),
196            escalation: rule.escalation(),
197            rule: rule.canonical_name().to_string(),
198            message,
199            at,
200        }
201    }
202}
203
204// ── Tests ───────────────────────────────────────────────────────────
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    const ALL_RULES: &[AnomalyRule] = &[
211        AnomalyRule::LowOutput,
212        AnomalyRule::LongRunning,
213        AnomalyRule::TransientReadError,
214        AnomalyRule::DuplicateRunner,
215        AnomalyRule::OverlappingCycles,
216        AnomalyRule::OverlappingSteps,
217        AnomalyRule::MissingStepEnd,
218        AnomalyRule::EmptyCycle,
219        AnomalyRule::OrphanCommand,
220        AnomalyRule::NonzeroExit,
221        AnomalyRule::UnexpandedTemplateVar,
222        AnomalyRule::DegenerateLoop,
223        AnomalyRule::SandboxDenied,
224        AnomalyRule::IncarnationBoundary,
225    ];
226
227    #[test]
228    fn canonical_name_roundtrip() {
229        for rule in ALL_RULES {
230            let name = rule.canonical_name();
231            let parsed = AnomalyRule::from_canonical(name);
232            assert_eq!(
233                parsed.as_ref(),
234                Some(rule),
235                "roundtrip failed for {:?}",
236                rule
237            );
238        }
239    }
240
241    #[test]
242    fn severity_mapping() {
243        assert_eq!(
244            AnomalyRule::DuplicateRunner.default_severity(),
245            Severity::Error
246        );
247        assert_eq!(
248            AnomalyRule::OverlappingCycles.default_severity(),
249            Severity::Error
250        );
251        assert_eq!(
252            AnomalyRule::OverlappingSteps.default_severity(),
253            Severity::Error
254        );
255        assert_eq!(
256            AnomalyRule::DegenerateLoop.default_severity(),
257            Severity::Error
258        );
259        assert_eq!(
260            AnomalyRule::SandboxDenied.default_severity(),
261            Severity::Error
262        );
263
264        assert_eq!(AnomalyRule::LowOutput.default_severity(), Severity::Warning);
265        assert_eq!(
266            AnomalyRule::NonzeroExit.default_severity(),
267            Severity::Warning
268        );
269        assert_eq!(
270            AnomalyRule::MissingStepEnd.default_severity(),
271            Severity::Warning
272        );
273        assert_eq!(
274            AnomalyRule::OrphanCommand.default_severity(),
275            Severity::Warning
276        );
277        assert_eq!(
278            AnomalyRule::UnexpandedTemplateVar.default_severity(),
279            Severity::Warning
280        );
281        assert_eq!(
282            AnomalyRule::TransientReadError.default_severity(),
283            Severity::Warning
284        );
285
286        assert_eq!(AnomalyRule::LongRunning.default_severity(), Severity::Info);
287        assert_eq!(AnomalyRule::EmptyCycle.default_severity(), Severity::Info);
288    }
289
290    #[test]
291    fn escalation_mapping() {
292        assert_eq!(AnomalyRule::LowOutput.escalation(), Escalation::Intervene);
293        assert_eq!(
294            AnomalyRule::DuplicateRunner.escalation(),
295            Escalation::Intervene
296        );
297        assert_eq!(
298            AnomalyRule::OverlappingCycles.escalation(),
299            Escalation::Intervene
300        );
301        assert_eq!(
302            AnomalyRule::OverlappingSteps.escalation(),
303            Escalation::Intervene
304        );
305        assert_eq!(
306            AnomalyRule::DegenerateLoop.escalation(),
307            Escalation::Intervene
308        );
309        assert_eq!(
310            AnomalyRule::SandboxDenied.escalation(),
311            Escalation::Intervene
312        );
313
314        assert_eq!(AnomalyRule::NonzeroExit.escalation(), Escalation::Attention);
315        assert_eq!(
316            AnomalyRule::OrphanCommand.escalation(),
317            Escalation::Attention
318        );
319        assert_eq!(
320            AnomalyRule::MissingStepEnd.escalation(),
321            Escalation::Attention
322        );
323        assert_eq!(
324            AnomalyRule::UnexpandedTemplateVar.escalation(),
325            Escalation::Attention
326        );
327        assert_eq!(
328            AnomalyRule::TransientReadError.escalation(),
329            Escalation::Attention
330        );
331
332        assert_eq!(AnomalyRule::LongRunning.escalation(), Escalation::Notice);
333        assert_eq!(AnomalyRule::EmptyCycle.escalation(), Escalation::Notice);
334    }
335
336    #[test]
337    fn display_tag_non_empty() {
338        for rule in ALL_RULES {
339            assert!(!rule.display_tag().is_empty(), "empty tag for {:?}", rule);
340        }
341    }
342
343    #[test]
344    fn anomaly_new_sets_defaults() {
345        let a = Anomaly::new(
346            AnomalyRule::LowOutput,
347            "test message".to_string(),
348            Some("2025-01-01".to_string()),
349        );
350        assert_eq!(a.rule, "low_output");
351        assert_eq!(a.severity, Severity::Warning);
352        assert_eq!(a.escalation, Escalation::Intervene);
353        assert_eq!(a.message, "test message");
354        assert_eq!(a.at.as_deref(), Some("2025-01-01"));
355    }
356
357    #[test]
358    fn anomaly_serialization_includes_escalation() {
359        let a = Anomaly::new(AnomalyRule::DuplicateRunner, "dup".to_string(), None);
360        let json = serde_json::to_value(&a).expect("anomaly should serialize");
361        assert_eq!(json["escalation"], "intervene");
362        assert_eq!(json["severity"], "error");
363        assert_eq!(json["rule"], "duplicate_runner");
364    }
365
366    #[test]
367    fn from_canonical_returns_none_for_unknown() {
368        assert_eq!(AnomalyRule::from_canonical("bogus_rule"), None);
369    }
370}