返回 CodeWhale
progress.rs
根目录 / crates / tui / src / core / runtime_contract / progress.rs
1 use std::collections::VecDeque;
2
3 use serde::{Deserialize, Serialize};
4
5 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6 pub struct ActionFingerprint {
7 pub operation: String,
8 #[serde(default, skip_serializing_if = "Option::is_none")]
9 pub target: Option<String>,
10 pub input_digest: String,
11 #[serde(default, skip_serializing_if = "Option::is_none")]
12 pub outcome_digest: Option<String>,
13 pub succeeded: bool,
14 pub changed_state: bool,
15 }
16
17 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18 pub struct ProgressGuardConfig {
19 pub identical_action_limit: usize,
20 pub alternating_cycle_limit: usize,
21 pub no_progress_limit: usize,
22 pub history_limit: usize,
23 }
24
25 impl Default for ProgressGuardConfig {
26 fn default() -> Self {
27 Self {
28 identical_action_limit: 3,
29 alternating_cycle_limit: 3,
30 no_progress_limit: 6,
31 history_limit: 16,
32 }
33 }
34 }
35
36 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37 #[serde(rename_all = "snake_case")]
38 pub enum GuardDecision {
39 Continue,
40 Warn { reason: String },
41 Stop { reason: String },
42 }
43
44 /// Structural no-progress detector. It consumes normalized fingerprints, not
45 /// provider prose, so the same policy can run in TUI, headless, and tests.
46 #[derive(Debug, Clone)]
47 pub struct ProgressGuard {
48 config: ProgressGuardConfig,
49 history: VecDeque<ActionFingerprint>,
50 warning_issued: bool,
51 }
52
53 impl ProgressGuard {
54 #[must_use]
55 pub fn new(config: ProgressGuardConfig) -> Self {
56 Self {
57 config,
58 history: VecDeque::with_capacity(config.history_limit),
59 warning_issued: false,
60 }
61 }
62
63 pub fn observe(&mut self, action: ActionFingerprint) -> GuardDecision {
64 if action.changed_state {
65 self.warning_issued = false;
66 }
67 self.history.push_back(action);
68 while self.history.len() > self.config.history_limit {
69 self.history.pop_front();
70 }
71
72 let reason = self
73 .identical_loop_reason()
74 .or_else(|| self.alternating_loop_reason())
75 .or_else(|| self.no_progress_reason());
76 let Some(reason) = reason else {
77 return GuardDecision::Continue;
78 };
79 if self.warning_issued {
80 GuardDecision::Stop { reason }
81 } else {
82 self.warning_issued = true;
83 GuardDecision::Warn { reason }
84 }
85 }
86
87 fn identical_loop_reason(&self) -> Option<String> {
88 let limit = self.config.identical_action_limit;
89 if limit < 2 || self.history.len() < limit {
90 return None;
91 }
92 let recent = self.history.iter().rev().take(limit).collect::<Vec<_>>();
93 let first = recent.first()?;
94 recent
95 .iter()
96 .all(|action| same_action(first, action))
97 .then(|| {
98 format!(
99 "repeated identical `{}` action without progress",
100 first.operation
101 )
102 })
103 }
104
105 fn alternating_loop_reason(&self) -> Option<String> {
106 let cycles = self.config.alternating_cycle_limit;
107 let needed = cycles.saturating_mul(2);
108 if cycles < 2 || self.history.len() < needed {
109 return None;
110 }
111 let recent = self.history.iter().rev().take(needed).collect::<Vec<_>>();
112 let a = recent.first()?;
113 let b = recent.get(1)?;
114 if same_action(a, b) {
115 return None;
116 }
117 recent
118 .iter()
119 .enumerate()
120 .all(|(index, action)| same_action(if index % 2 == 0 { a } else { b }, action))
121 .then(|| {
122 format!(
123 "alternating `{}`/`{}` actions are cycling without progress",
124 a.operation, b.operation
125 )
126 })
127 }
128
129 fn no_progress_reason(&self) -> Option<String> {
130 let limit = self.config.no_progress_limit;
131 if limit == 0 || self.history.len() < limit {
132 return None;
133 }
134 self.history
135 .iter()
136 .rev()
137 .take(limit)
138 .all(|action| !action.changed_state)
139 .then(|| format!("{limit} consecutive actions produced no observable state change"))
140 }
141 }
142
143 fn same_action(left: &ActionFingerprint, right: &ActionFingerprint) -> bool {
144 left.operation == right.operation
145 && left.target == right.target
146 && left.input_digest == right.input_digest
147 && left.outcome_digest == right.outcome_digest
148 && left.succeeded == right.succeeded
149 && left.changed_state == right.changed_state
150 }
151
152 #[cfg(test)]
153 mod tests {
154 use super::*;
155
156 fn action(operation: &str, changed_state: bool) -> ActionFingerprint {
157 ActionFingerprint {
158 operation: operation.to_string(),
159 target: Some("src/lib.rs".to_string()),
160 input_digest: operation.to_string(),
161 outcome_digest: Some("same".to_string()),
162 succeeded: false,
163 changed_state,
164 }
165 }
166
167 #[test]
168 fn repeated_action_warns_then_stops() {
169 let mut guard = ProgressGuard::new(ProgressGuardConfig {
170 identical_action_limit: 3,
171 alternating_cycle_limit: 99,
172 no_progress_limit: 99,
173 history_limit: 16,
174 });
175 assert_eq!(
176 guard.observe(action("search", false)),
177 GuardDecision::Continue
178 );
179 assert_eq!(
180 guard.observe(action("search", false)),
181 GuardDecision::Continue
182 );
183 assert!(matches!(
184 guard.observe(action("search", false)),
185 GuardDecision::Warn { .. }
186 ));
187 assert!(matches!(
188 guard.observe(action("search", false)),
189 GuardDecision::Stop { .. }
190 ));
191 }
192
193 #[test]
194 fn real_progress_clears_warning_latch() {
195 let mut guard = ProgressGuard::new(ProgressGuardConfig {
196 identical_action_limit: 2,
197 alternating_cycle_limit: 99,
198 no_progress_limit: 99,
199 history_limit: 16,
200 });
201 guard.observe(action("search", false));
202 assert!(matches!(
203 guard.observe(action("search", false)),
204 GuardDecision::Warn { .. }
205 ));
206 assert_eq!(guard.observe(action("edit", true)), GuardDecision::Continue);
207 assert_eq!(
208 guard.observe(action("search", false)),
209 GuardDecision::Continue
210 );
211 }
212
213 #[test]
214 fn alternating_cycle_is_detected() {
215 let mut guard = ProgressGuard::new(ProgressGuardConfig {
216 identical_action_limit: 99,
217 alternating_cycle_limit: 3,
218 no_progress_limit: 99,
219 history_limit: 16,
220 });
221 for operation in ["search", "read", "search", "read", "search"] {
222 assert_eq!(
223 guard.observe(action(operation, false)),
224 GuardDecision::Continue
225 );
226 }
227 assert!(matches!(
228 guard.observe(action("read", false)),
229 GuardDecision::Warn { .. }
230 ));
231 }
232 }
233
233 lines RUST