Skip to main content

ralph_core/event_loop/
loop_state.rs

1//! Loop state tracking for the event loop.
2//!
3//! This module contains the `LoopState` struct that tracks the current
4//! state of the orchestration loop including iteration count, failures,
5//! timing, and hat activation tracking.
6
7use ralph_proto::{Event, HatId};
8use std::collections::{HashMap, HashSet};
9use std::hash::{DefaultHasher, Hash, Hasher};
10use std::time::{Duration, Instant};
11
12/// Fingerprint of the last emitted event for stale loop detection.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct EventSignature {
15    pub topic: String,
16    pub source: Option<HatId>,
17    pub payload_fingerprint: u64,
18}
19
20/// Current state of the event loop.
21#[derive(Debug)]
22pub struct LoopState {
23    /// Current iteration number (1-indexed).
24    pub iteration: u32,
25    /// Number of consecutive failures.
26    pub consecutive_failures: u32,
27    /// Cumulative cost in USD (if tracked).
28    pub cumulative_cost: f64,
29    /// When the loop started.
30    pub started_at: Instant,
31    /// The last hat that executed.
32    pub last_hat: Option<HatId>,
33    /// Consecutive blocked events from the same hat.
34    pub consecutive_blocked: u32,
35    /// Hat that emitted the last blocked event.
36    pub last_blocked_hat: Option<HatId>,
37    /// Per-task block counts for task-level thrashing detection.
38    pub task_block_counts: HashMap<String, u32>,
39    /// Tasks that have been abandoned after 3+ blocks.
40    pub abandoned_tasks: Vec<String>,
41    /// Count of times planner dispatched an already-abandoned task.
42    pub abandoned_task_redispatches: u32,
43    /// Consecutive malformed JSONL lines encountered (for validation backpressure).
44    pub consecutive_malformed_events: u32,
45    /// Whether a completion event has been observed in JSONL.
46    pub completion_requested: bool,
47
48    /// Per-hat activation counts (used for max_activations).
49    pub hat_activation_counts: HashMap<HatId, u32>,
50
51    /// Hats for which `<hat_id>.exhausted` has been emitted.
52    pub exhausted_hats: HashSet<HatId>,
53
54    /// When the last Telegram check-in message was sent.
55    /// `None` means no check-in has been sent yet.
56    pub last_checkin_at: Option<Instant>,
57
58    /// Hat IDs that were active in the last iteration.
59    /// Used to inject `default_publishes` when agent writes no events.
60    pub last_active_hat_ids: Vec<HatId>,
61
62    /// Topics seen during the loop's lifetime (for event chain validation).
63    pub seen_topics: HashSet<String>,
64
65    /// The last event signature emitted (for stale loop detection).
66    pub last_emitted_signature: Option<EventSignature>,
67
68    /// Consecutive times the same event signature was emitted (for stale loop detection).
69    pub consecutive_same_signature: u32,
70
71    /// Set to true when a loop.cancel event is detected.
72    pub cancellation_requested: bool,
73
74    /// Session-scoped peak context-token count across all iterations.
75    pub peak_input_tokens: u64,
76
77    /// Last iteration's context-token count (if any).
78    pub last_input_tokens: Option<u64>,
79
80    /// Per-hat session-scoped peak context-token count.
81    pub hat_peak_input_tokens: HashMap<HatId, u64>,
82
83    /// Human guidance messages that must be acknowledged before completion.
84    pub unacknowledged_guidance: Vec<String>,
85}
86
87impl Default for LoopState {
88    fn default() -> Self {
89        Self {
90            iteration: 0,
91            consecutive_failures: 0,
92            cumulative_cost: 0.0,
93            started_at: Instant::now(),
94            last_hat: None,
95            consecutive_blocked: 0,
96            last_blocked_hat: None,
97            task_block_counts: HashMap::new(),
98            abandoned_tasks: Vec::new(),
99            abandoned_task_redispatches: 0,
100            consecutive_malformed_events: 0,
101            completion_requested: false,
102            hat_activation_counts: HashMap::new(),
103            exhausted_hats: HashSet::new(),
104            last_checkin_at: None,
105            last_active_hat_ids: Vec::new(),
106            seen_topics: HashSet::new(),
107            last_emitted_signature: None,
108            consecutive_same_signature: 0,
109            cancellation_requested: false,
110            peak_input_tokens: 0,
111            last_input_tokens: None,
112            hat_peak_input_tokens: HashMap::new(),
113            unacknowledged_guidance: Vec::new(),
114        }
115    }
116}
117
118impl LoopState {
119    /// Creates a new loop state.
120    pub fn new() -> Self {
121        Self::default()
122    }
123
124    /// Returns the elapsed time since the loop started.
125    pub fn elapsed(&self) -> Duration {
126        self.started_at.elapsed()
127    }
128
129    fn event_counts_toward_stale_loop(event: &Event) -> bool {
130        !matches!(event.topic.as_str(), "task.complete")
131    }
132
133    /// Record that an event has been seen during this loop run.
134    ///
135    /// Also tracks consecutive same-signature emissions for stale loop detection.
136    pub fn record_event(&mut self, event: &Event) {
137        self.seen_topics.insert(event.topic.to_string());
138
139        if !Self::event_counts_toward_stale_loop(event) {
140            self.consecutive_same_signature = 0;
141            self.last_emitted_signature = Some(EventSignature::from_event(event));
142            return;
143        }
144
145        let signature = EventSignature::from_event(event);
146        if self.last_emitted_signature.as_ref() == Some(&signature) {
147            self.consecutive_same_signature += 1;
148        } else {
149            self.consecutive_same_signature = 1;
150            self.last_emitted_signature = Some(signature);
151        }
152    }
153
154    /// Record this iteration's context-token usage for the hat that ran it.
155    ///
156    /// `tokens` is the iteration's adapter-reported live context occupancy.
157    /// No-op when `tokens == 0` (ACP / non-token backends suppressed).
158    /// Peaks are session-scoped — they never reset on iteration boundaries.
159    pub fn record_iteration_tokens(&mut self, hat: &HatId, tokens: u64) {
160        if tokens == 0 {
161            return;
162        }
163        let entry = self.hat_peak_input_tokens.entry(hat.clone()).or_insert(0);
164        *entry = (*entry).max(tokens);
165        self.peak_input_tokens = self.peak_input_tokens.max(tokens);
166        self.last_input_tokens = Some(tokens);
167    }
168
169    /// Check if all required topics have been seen.
170    pub fn missing_required_events<'a>(&self, required: &'a [String]) -> Vec<&'a String> {
171        required
172            .iter()
173            .filter(|topic| !self.seen_topics.contains(topic.as_str()))
174            .collect()
175    }
176}
177
178impl EventSignature {
179    pub fn from_event(event: &Event) -> Self {
180        Self {
181            topic: event.topic.to_string(),
182            source: event.source.clone(),
183            payload_fingerprint: fingerprint_payload(&event.payload),
184        }
185    }
186}
187
188fn fingerprint_payload(payload: &str) -> u64 {
189    let mut hasher = DefaultHasher::new();
190    payload.hash(&mut hasher);
191    hasher.finish()
192}
193
194#[cfg(test)]
195mod tests {
196    use super::LoopState;
197    use ralph_proto::{Event, HatId};
198
199    #[test]
200    fn repeated_task_complete_does_not_accumulate_stale_loop_count() {
201        let mut state = LoopState::new();
202
203        state.record_event(&Event::new("task.complete", "task 1 complete"));
204        assert_eq!(state.consecutive_same_signature, 0);
205
206        state.record_event(&Event::new("task.complete", "task 2 complete"));
207        state.record_event(&Event::new("task.complete", "task 3 complete"));
208
209        assert_eq!(state.consecutive_same_signature, 0);
210        assert_eq!(
211            state
212                .last_emitted_signature
213                .as_ref()
214                .map(|s| s.topic.as_str()),
215            Some("task.complete")
216        );
217    }
218
219    #[test]
220    fn record_iteration_tokens_tracks_per_hat_and_global_peak() {
221        let mut state = LoopState::new();
222        let builder = HatId::new("builder");
223        let critic = HatId::new("critic");
224
225        state.record_iteration_tokens(&builder, 10_000);
226        assert_eq!(
227            state.hat_peak_input_tokens.get(&builder).copied(),
228            Some(10_000)
229        );
230        assert_eq!(state.peak_input_tokens, 10_000);
231        assert_eq!(state.last_input_tokens, Some(10_000));
232
233        state.record_iteration_tokens(&builder, 5_000);
234        assert_eq!(
235            state.hat_peak_input_tokens.get(&builder).copied(),
236            Some(10_000),
237            "per-hat peak must retain the max across iterations"
238        );
239        assert_eq!(
240            state.peak_input_tokens, 10_000,
241            "global peak must retain the overall max"
242        );
243        assert_eq!(
244            state.last_input_tokens,
245            Some(5_000),
246            "last_input_tokens tracks the most recent iteration, not the peak"
247        );
248
249        state.record_iteration_tokens(&critic, 25_000);
250        assert_eq!(
251            state.hat_peak_input_tokens.get(&builder).copied(),
252            Some(10_000)
253        );
254        assert_eq!(
255            state.hat_peak_input_tokens.get(&critic).copied(),
256            Some(25_000)
257        );
258        assert_eq!(state.peak_input_tokens, 25_000);
259        assert_eq!(state.last_input_tokens, Some(25_000));
260
261        state.record_iteration_tokens(&builder, 0);
262        assert_eq!(
263            state.hat_peak_input_tokens.get(&builder).copied(),
264            Some(10_000),
265            "zero-token iteration must be a no-op"
266        );
267        assert_eq!(state.peak_input_tokens, 25_000);
268        assert_eq!(
269            state.last_input_tokens,
270            Some(25_000),
271            "zero tokens must not overwrite last_input_tokens"
272        );
273    }
274
275    #[test]
276    fn repeated_non_progress_topics_still_accumulate_stale_loop_count() {
277        let mut state = LoopState::new();
278
279        state.record_event(&Event::new("task.resume", "same payload"));
280        state.record_event(&Event::new("task.resume", "same payload"));
281        state.record_event(&Event::new("task.resume", "same payload"));
282
283        assert_eq!(state.consecutive_same_signature, 3);
284        assert_eq!(
285            state
286                .last_emitted_signature
287                .as_ref()
288                .map(|s| s.topic.as_str()),
289            Some("task.resume")
290        );
291    }
292}