Skip to main content

tsift_quality/
runtime_churn.rs

1use serde::Serialize;
2use std::collections::BTreeMap;
3
4const MAX_RESTART_CHURN: usize = 8;
5
6#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
7pub struct RestartChurnSummary {
8    pub family: String,
9    pub occurrences: usize,
10    #[serde(skip_serializing_if = "Option::is_none")]
11    pub max_restart_count: Option<usize>,
12    pub sample: String,
13}
14
15#[derive(Debug, Default)]
16pub struct RestartChurnState {
17    counts: BTreeMap<String, usize>,
18    max_restart_counts: BTreeMap<String, usize>,
19    samples: BTreeMap<String, String>,
20}
21
22impl RestartChurnState {
23    pub fn observe(&mut self, event_name: &str, detail: &str) {
24        let restart_count = extract_field(detail, "restart_count").and_then(parse_usize);
25        let sample = truncate_detail(detail, 140);
26        for family in classify_restart_churn_families(event_name, detail) {
27            *self.counts.entry(family.to_string()).or_default() += 1;
28            if let Some(count) = restart_count {
29                let entry = self
30                    .max_restart_counts
31                    .entry(family.to_string())
32                    .or_default();
33                *entry = (*entry).max(count);
34            }
35            self.samples
36                .entry(family.to_string())
37                .or_insert_with(|| sample.clone());
38        }
39    }
40
41    pub fn groups(&self) -> usize {
42        self.counts.len()
43    }
44
45    pub fn summaries(&self) -> Vec<RestartChurnSummary> {
46        let mut summaries = self
47            .counts
48            .iter()
49            .map(|(family, occurrences)| RestartChurnSummary {
50                family: family.clone(),
51                occurrences: *occurrences,
52                max_restart_count: self.max_restart_counts.get(family).copied(),
53                sample: self.samples.get(family).cloned().unwrap_or_default(),
54            })
55            .collect::<Vec<_>>();
56        summaries.sort_by(|left, right| {
57            right
58                .occurrences
59                .cmp(&left.occurrences)
60                .then(left.family.cmp(&right.family))
61        });
62        summaries.truncate(MAX_RESTART_CHURN);
63        summaries
64    }
65}
66
67pub fn classify_restart_churn_families(event_name: &str, detail: &str) -> Vec<&'static str> {
68    let mut families = Vec::new();
69
70    if is_fresh_restart(event_name, detail) {
71        families.push("fresh_restart");
72    }
73    if event_name == "auto_trigger_timeout" {
74        families.push("auto_trigger_timeout");
75    }
76    if is_ctrl_d_restart_loop(event_name) {
77        families.push("ctrl_d_restart_loop");
78    }
79    if is_quit_after_eof(event_name, detail) {
80        families.push("quit_after_eof");
81    }
82
83    families
84}
85
86pub fn is_restart_churn_warning_family(family: &str) -> bool {
87    family != "quit_after_eof"
88}
89
90fn is_fresh_restart(event_name: &str, detail: &str) -> bool {
91    if matches!(
92        event_name,
93        "claude_start" | "codex_start" | "claude_restart" | "codex_restart"
94    ) {
95        return extract_field(detail, "mode") == Some("fresh_restart");
96    }
97
98    if event_name == "ipc_restart" {
99        return extract_field(detail, "mode") == Some("fresh");
100    }
101
102    matches!(
103        event_name,
104        "fresh_restart_before_prompt"
105            | "ctrl_d_restart_fresh"
106            | "ctrl_d_before_prompt_restart_fresh"
107            | "ctrl_d_committed_cycle_restart_fresh"
108    )
109}
110
111fn is_ctrl_d_restart_loop(event_name: &str) -> bool {
112    matches!(
113        event_name,
114        "ctrl_d_restart_fresh"
115            | "ctrl_d_before_prompt_restart_fresh"
116            | "ctrl_d_committed_cycle_restart_fresh"
117    )
118}
119
120fn is_quit_after_eof(event_name: &str, detail: &str) -> bool {
121    if matches!(event_name, "user_quit_after_eof" | "user_quit_after_ctrl_d") {
122        return true;
123    }
124
125    event_name == "supervisor_exit"
126        && extract_field(detail, "reason")
127            .is_some_and(|reason| reason.starts_with("user_quit_after_"))
128}
129
130fn extract_field<'a>(detail: &'a str, key: &str) -> Option<&'a str> {
131    let needle = format!("{key}=");
132    let start = detail.find(&needle)? + needle.len();
133    let remainder = &detail[start..];
134    let end = remainder
135        .find(char::is_whitespace)
136        .unwrap_or(remainder.len());
137    Some(remainder[..end].trim_matches('"'))
138}
139
140fn parse_usize(raw: &str) -> Option<usize> {
141    raw.parse::<usize>().ok()
142}
143
144fn truncate_detail(detail: &str, limit: usize) -> String {
145    let normalized = detail.split_whitespace().collect::<Vec<_>>().join(" ");
146    if normalized.chars().count() <= limit {
147        return normalized;
148    }
149
150    let mut truncated = String::new();
151    for ch in normalized.chars().take(limit.saturating_sub(1)) {
152        truncated.push(ch);
153    }
154    truncated.push('…');
155    truncated
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn restart_churn_detects_requested_families() {
164        let mut state = RestartChurnState::default();
165        state.observe(
166            "codex_start",
167            "codex_start mode=fresh_restart restart_count=2",
168        );
169        state.observe(
170            "auto_trigger_timeout",
171            "auto_trigger_timeout harness=codex reason=no_prompt_after_30s",
172        );
173        state.observe(
174            "ctrl_d_restart_fresh",
175            "ctrl_d_restart_fresh restart_count=3",
176        );
177        state.observe("user_quit_after_ctrl_d", "user_quit_after_ctrl_d");
178        state.observe(
179            "supervisor_exit",
180            "supervisor_exit reason=user_quit_after_ctrl_d pane=%26 restart_count=0",
181        );
182
183        let summaries = state.summaries();
184        assert_eq!(state.groups(), 4);
185        assert!(summaries.iter().any(|entry| entry.family == "fresh_restart"
186            && entry.occurrences == 2
187            && entry.max_restart_count == Some(3)));
188        assert!(
189            summaries
190                .iter()
191                .any(|entry| entry.family == "auto_trigger_timeout" && entry.occurrences == 1)
192        );
193        assert!(
194            summaries
195                .iter()
196                .any(|entry| entry.family == "ctrl_d_restart_loop"
197                    && entry.occurrences == 1
198                    && entry.max_restart_count == Some(3))
199        );
200        assert!(
201            summaries
202                .iter()
203                .any(|entry| entry.family == "quit_after_eof" && entry.occurrences == 2)
204        );
205        assert!(!is_restart_churn_warning_family("quit_after_eof"));
206        assert!(is_restart_churn_warning_family("fresh_restart"));
207    }
208}