edda_postmortem/
trigger.rs1use serde::{Deserialize, Serialize};
7
8const DEFAULT_LONG_SESSION_THRESHOLD: u64 = 20;
10
11const DEFAULT_FILE_EDIT_THRESHOLD: u64 = 3;
13
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
16#[serde(rename_all = "snake_case")]
17pub enum TriggerReason {
18 SessionFailures,
20 AbnormallyLong,
22 ExcessiveFileEdits,
24 DecisionSuperseded,
26 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#[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 pub file_edit_counts: Vec<(String, u64)>,
52 pub decisions_superseded: u64,
54 pub had_conflict: bool,
56 pub outcome: String,
58}
59
60#[derive(Debug, Clone)]
62pub struct PostMortemTrigger {
63 pub should_analyze: bool,
64 pub reasons: Vec<TriggerReason>,
65 pub session_id: String,
66}
67
68#[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
90pub fn evaluate_triggers(summary: &SessionSummary) -> PostMortemTrigger {
95 evaluate_triggers_with_config(summary, &TriggerConfig::default())
96}
97
98pub fn evaluate_triggers_with_config(
100 summary: &SessionSummary,
101 config: &TriggerConfig,
102) -> PostMortemTrigger {
103 let mut reasons = Vec::new();
104
105 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 if summary.user_prompts > config.long_session_threshold {
115 reasons.push(TriggerReason::AbnormallyLong);
116 }
117
118 for (_, count) in &summary.file_edit_counts {
120 if *count >= config.file_edit_threshold {
121 reasons.push(TriggerReason::ExcessiveFileEdits);
122 break; }
124 }
125
126 if summary.decisions_superseded > 0 {
128 reasons.push(TriggerReason::DecisionSuperseded);
129 }
130
131 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; summary.file_edit_counts = vec![("a.rs".to_string(), 2)]; 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}