tsift_quality/
runtime_churn.rs1use 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}