Skip to main content

edda_postmortem/
trigger.rs

1//! Deterministic trigger detection for post-mortem analysis.
2//!
3//! Checks session statistics against thresholds to decide whether a
4//! post-mortem analysis should run. No LLM calls — pure deterministic logic.
5
6use serde::{Deserialize, Serialize};
7
8/// Default threshold: sessions longer than this many user prompts are "abnormally long".
9const DEFAULT_LONG_SESSION_THRESHOLD: u64 = 20;
10
11/// Default threshold: a file edited this many times signals churn.
12const DEFAULT_FILE_EDIT_THRESHOLD: u64 = 3;
13
14/// Reason why a post-mortem was triggered.
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
16#[serde(rename_all = "snake_case")]
17pub enum TriggerReason {
18    /// Session had command failures (tests broken, PR blocked, etc.)
19    SessionFailures,
20    /// Abnormally long session (> threshold user prompts)
21    AbnormallyLong,
22    /// Same file edited 3+ times (churn indicator)
23    ExcessiveFileEdits,
24    /// A decision was superseded during this session
25    DecisionSuperseded,
26    /// Multi-agent conflict detected
27    MultiAgentConflict,
28}
29
30impl std::fmt::Display for TriggerReason {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        match self {
33            Self::SessionFailures => write!(f, "session_failures"),
34            Self::AbnormallyLong => write!(f, "abnormally_long"),
35            Self::ExcessiveFileEdits => write!(f, "excessive_file_edits"),
36            Self::DecisionSuperseded => write!(f, "decision_superseded"),
37            Self::MultiAgentConflict => write!(f, "multi_agent_conflict"),
38        }
39    }
40}
41
42/// Input data for trigger evaluation. Mirrors fields available from
43/// `PrevDigest` / `SessionStats` without coupling to those types.
44#[derive(Debug, Clone, Default)]
45pub struct SessionSummary {
46    pub session_id: String,
47    pub user_prompts: u64,
48    pub tool_failures: u64,
49    pub failed_commands: Vec<String>,
50    /// Map of file path -> edit count during session.
51    pub file_edit_counts: Vec<(String, u64)>,
52    /// Number of decisions superseded during this session.
53    pub decisions_superseded: u64,
54    /// Whether multi-agent conflicts were detected.
55    pub had_conflict: bool,
56    /// Session outcome: "completed", "interrupted", "error_stuck".
57    pub outcome: String,
58}
59
60/// Result of trigger evaluation.
61#[derive(Debug, Clone)]
62pub struct PostMortemTrigger {
63    pub should_analyze: bool,
64    pub reasons: Vec<TriggerReason>,
65    pub session_id: String,
66}
67
68/// Configuration for trigger thresholds. Overridable via environment variables.
69#[derive(Debug, Clone)]
70pub struct TriggerConfig {
71    pub long_session_threshold: u64,
72    pub file_edit_threshold: u64,
73}
74
75impl Default for TriggerConfig {
76    fn default() -> Self {
77        Self {
78            long_session_threshold: std::env::var("EDDA_PM_LONG_SESSION")
79                .ok()
80                .and_then(|v| v.parse().ok())
81                .unwrap_or(DEFAULT_LONG_SESSION_THRESHOLD),
82            file_edit_threshold: std::env::var("EDDA_PM_FILE_EDIT_THRESHOLD")
83                .ok()
84                .and_then(|v| v.parse().ok())
85                .unwrap_or(DEFAULT_FILE_EDIT_THRESHOLD),
86        }
87    }
88}
89
90/// Evaluate whether a session warrants post-mortem analysis.
91///
92/// Returns a `PostMortemTrigger` with the decision and reasons.
93/// All checks are deterministic — no LLM calls.
94pub fn evaluate_triggers(summary: &SessionSummary) -> PostMortemTrigger {
95    evaluate_triggers_with_config(summary, &TriggerConfig::default())
96}
97
98/// Evaluate with explicit config (useful for testing).
99pub fn evaluate_triggers_with_config(
100    summary: &SessionSummary,
101    config: &TriggerConfig,
102) -> PostMortemTrigger {
103    let mut reasons = Vec::new();
104
105    // 1. Session had failures
106    if summary.tool_failures > 0
107        || !summary.failed_commands.is_empty()
108        || summary.outcome == "error_stuck"
109    {
110        reasons.push(TriggerReason::SessionFailures);
111    }
112
113    // 2. Abnormally long session
114    if summary.user_prompts > config.long_session_threshold {
115        reasons.push(TriggerReason::AbnormallyLong);
116    }
117
118    // 3. Excessive file edits (same file modified 3+ times)
119    for (_, count) in &summary.file_edit_counts {
120        if *count >= config.file_edit_threshold {
121            reasons.push(TriggerReason::ExcessiveFileEdits);
122            break; // one trigger is enough
123        }
124    }
125
126    // 4. Decision superseded
127    if summary.decisions_superseded > 0 {
128        reasons.push(TriggerReason::DecisionSuperseded);
129    }
130
131    // 5. Multi-agent conflict
132    if summary.had_conflict {
133        reasons.push(TriggerReason::MultiAgentConflict);
134    }
135
136    let should_analyze = !reasons.is_empty();
137
138    PostMortemTrigger {
139        should_analyze,
140        reasons,
141        session_id: summary.session_id.clone(),
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    fn base_summary() -> SessionSummary {
150        SessionSummary {
151            session_id: "test-session-1".to_string(),
152            outcome: "completed".to_string(),
153            ..Default::default()
154        }
155    }
156
157    #[test]
158    fn no_triggers_for_clean_session() {
159        let summary = base_summary();
160        let result = evaluate_triggers(&summary);
161        assert!(!result.should_analyze);
162        assert!(result.reasons.is_empty());
163    }
164
165    #[test]
166    fn triggers_on_failures() {
167        let mut summary = base_summary();
168        summary.tool_failures = 3;
169        let result = evaluate_triggers(&summary);
170        assert!(result.should_analyze);
171        assert!(result.reasons.contains(&TriggerReason::SessionFailures));
172    }
173
174    #[test]
175    fn triggers_on_failed_commands() {
176        let mut summary = base_summary();
177        summary.failed_commands = vec!["npm test".to_string()];
178        let result = evaluate_triggers(&summary);
179        assert!(result.should_analyze);
180        assert!(result.reasons.contains(&TriggerReason::SessionFailures));
181    }
182
183    #[test]
184    fn triggers_on_error_stuck_outcome() {
185        let mut summary = base_summary();
186        summary.outcome = "error_stuck".to_string();
187        let result = evaluate_triggers(&summary);
188        assert!(result.should_analyze);
189        assert!(result.reasons.contains(&TriggerReason::SessionFailures));
190    }
191
192    #[test]
193    fn triggers_on_long_session() {
194        let mut summary = base_summary();
195        summary.user_prompts = 25;
196        let result = evaluate_triggers(&summary);
197        assert!(result.should_analyze);
198        assert!(result.reasons.contains(&TriggerReason::AbnormallyLong));
199    }
200
201    #[test]
202    fn triggers_on_file_churn() {
203        let mut summary = base_summary();
204        summary.file_edit_counts = vec![
205            ("src/main.rs".to_string(), 5),
206            ("src/lib.rs".to_string(), 1),
207        ];
208        let result = evaluate_triggers(&summary);
209        assert!(result.should_analyze);
210        assert!(result.reasons.contains(&TriggerReason::ExcessiveFileEdits));
211    }
212
213    #[test]
214    fn triggers_on_decision_superseded() {
215        let mut summary = base_summary();
216        summary.decisions_superseded = 1;
217        let result = evaluate_triggers(&summary);
218        assert!(result.should_analyze);
219        assert!(result.reasons.contains(&TriggerReason::DecisionSuperseded));
220    }
221
222    #[test]
223    fn triggers_on_conflict() {
224        let mut summary = base_summary();
225        summary.had_conflict = true;
226        let result = evaluate_triggers(&summary);
227        assert!(result.should_analyze);
228        assert!(result.reasons.contains(&TriggerReason::MultiAgentConflict));
229    }
230
231    #[test]
232    fn multiple_triggers_accumulate() {
233        let mut summary = base_summary();
234        summary.tool_failures = 2;
235        summary.user_prompts = 30;
236        summary.had_conflict = true;
237        let result = evaluate_triggers(&summary);
238        assert!(result.should_analyze);
239        assert_eq!(result.reasons.len(), 3);
240    }
241
242    #[test]
243    fn custom_config_thresholds() {
244        let mut summary = base_summary();
245        summary.user_prompts = 10; // Below default 20, above custom 5
246        summary.file_edit_counts = vec![("a.rs".to_string(), 2)]; // Below default 3, at custom 2
247
248        let config = TriggerConfig {
249            long_session_threshold: 5,
250            file_edit_threshold: 2,
251        };
252        let result = evaluate_triggers_with_config(&summary, &config);
253        assert!(result.should_analyze);
254        assert_eq!(result.reasons.len(), 2);
255    }
256}