Skip to main content

clawft_kernel/
heartbeat.rs

1//! Heartbeat scheduler for agent lifecycle management.
2//!
3//! The [`HeartbeatScheduler`] manages periodic wake-up cycles for agents.
4//! Each registered agent goes through a [`HeartbeatPhase`] cycle:
5//! Wake -> CheckQueue -> Execute -> Sleep -> Report, then back to Wake.
6//!
7//! This is a separate abstraction from the cron system: cron handles
8//! arbitrary scheduled jobs, while heartbeats manage the regular
9//! tick-based lifecycle of agents.
10
11use std::collections::HashMap;
12use std::time::Duration;
13
14use serde::{Deserialize, Serialize};
15
16/// Configuration for a single agent's heartbeat.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct HeartbeatConfig {
19    /// The agent this heartbeat belongs to.
20    pub agent_id: String,
21    /// How often the agent should wake up.
22    pub interval: Duration,
23    /// Whether the heartbeat is currently active.
24    pub enabled: bool,
25}
26
27impl HeartbeatConfig {
28    /// Create a new enabled heartbeat config.
29    pub fn new(agent_id: impl Into<String>, interval: Duration) -> Self {
30        Self {
31            agent_id: agent_id.into(),
32            interval,
33            enabled: true,
34        }
35    }
36}
37
38/// Phase of an agent's heartbeat cycle.
39#[non_exhaustive]
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case")]
42#[derive(Default)]
43pub enum HeartbeatPhase {
44    /// Agent is waking up and initialising for the tick.
45    Wake,
46    /// Agent is checking its work queue.
47    CheckQueue,
48    /// Agent is executing queued work.
49    Execute,
50    /// Agent is idle, waiting for the next tick.
51    #[default]
52    Sleep,
53    /// Agent is reporting results from this cycle.
54    Report,
55}
56
57impl HeartbeatPhase {
58    /// Advance to the next phase in the cycle.
59    pub fn next(self) -> Self {
60        match self {
61            Self::Wake => Self::CheckQueue,
62            Self::CheckQueue => Self::Execute,
63            Self::Execute => Self::Sleep,
64            Self::Sleep => Self::Report,
65            Self::Report => Self::Wake,
66        }
67    }
68}
69
70impl std::fmt::Display for HeartbeatPhase {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        match self {
73            Self::Wake => write!(f, "wake"),
74            Self::CheckQueue => write!(f, "check_queue"),
75            Self::Execute => write!(f, "execute"),
76            Self::Sleep => write!(f, "sleep"),
77            Self::Report => write!(f, "report"),
78        }
79    }
80}
81
82/// Internal state for a single agent's heartbeat.
83#[derive(Debug, Clone)]
84struct AgentHeartbeat {
85    config: HeartbeatConfig,
86    phase: HeartbeatPhase,
87    /// Accumulated time since last wake. When >= interval, the agent wakes.
88    elapsed: Duration,
89    /// Number of completed cycles.
90    tick_count: u64,
91}
92
93/// Result of a single tick for one agent.
94#[derive(Debug, Clone)]
95pub struct HeartbeatTickResult {
96    /// The agent that needs to wake.
97    pub agent_id: String,
98    /// The phase the agent is entering.
99    pub phase: HeartbeatPhase,
100    /// How many complete cycles this agent has done.
101    pub tick_count: u64,
102}
103
104/// Manages heartbeat schedules for multiple agents.
105///
106/// Call [`tick`] with the elapsed wall-clock delta. Agents whose
107/// accumulated elapsed time exceeds their configured interval will
108/// be returned as needing a wake-up.
109#[derive(Debug)]
110pub struct HeartbeatScheduler {
111    agents: HashMap<String, AgentHeartbeat>,
112}
113
114impl HeartbeatScheduler {
115    /// Create an empty scheduler.
116    pub fn new() -> Self {
117        Self {
118            agents: HashMap::new(),
119        }
120    }
121
122    /// Register an agent heartbeat. Replaces any existing entry.
123    pub fn register(&mut self, config: HeartbeatConfig) {
124        let id = config.agent_id.clone();
125        self.agents.insert(
126            id,
127            AgentHeartbeat {
128                config,
129                phase: HeartbeatPhase::Sleep,
130                elapsed: Duration::ZERO,
131                tick_count: 0,
132            },
133        );
134    }
135
136    /// Remove an agent from the scheduler. Returns `true` if it existed.
137    pub fn unregister(&mut self, agent_id: &str) -> bool {
138        self.agents.remove(agent_id).is_some()
139    }
140
141    /// Advance all heartbeats by `delta` time.
142    ///
143    /// Returns a list of agents that need to wake up (their interval
144    /// has elapsed). Agents that are disabled are skipped.
145    pub fn tick(&mut self, delta: Duration) -> Vec<HeartbeatTickResult> {
146        let mut wakeups = Vec::new();
147
148        for hb in self.agents.values_mut() {
149            if !hb.config.enabled {
150                continue;
151            }
152
153            hb.elapsed += delta;
154
155            if hb.elapsed >= hb.config.interval {
156                hb.elapsed -= hb.config.interval;
157                hb.phase = HeartbeatPhase::Wake;
158                hb.tick_count += 1;
159
160                wakeups.push(HeartbeatTickResult {
161                    agent_id: hb.config.agent_id.clone(),
162                    phase: hb.phase,
163                    tick_count: hb.tick_count,
164                });
165            }
166        }
167
168        wakeups
169    }
170
171    /// Return the current phase of a given agent.
172    pub fn phase(&self, agent_id: &str) -> Option<HeartbeatPhase> {
173        self.agents.get(agent_id).map(|hb| hb.phase)
174    }
175
176    /// Advance a specific agent to its next phase.
177    pub fn advance_phase(&mut self, agent_id: &str) -> Option<HeartbeatPhase> {
178        if let Some(hb) = self.agents.get_mut(agent_id) {
179            hb.phase = hb.phase.next();
180            Some(hb.phase)
181        } else {
182            None
183        }
184    }
185
186    /// Number of registered agents.
187    pub fn len(&self) -> usize {
188        self.agents.len()
189    }
190
191    /// Whether the scheduler has no registered agents.
192    pub fn is_empty(&self) -> bool {
193        self.agents.is_empty()
194    }
195
196    /// Return all registered agent IDs.
197    pub fn agent_ids(&self) -> Vec<&str> {
198        self.agents.keys().map(|s| s.as_str()).collect()
199    }
200
201    /// Get the tick count for a given agent.
202    pub fn tick_count(&self, agent_id: &str) -> Option<u64> {
203        self.agents.get(agent_id).map(|hb| hb.tick_count)
204    }
205}
206
207impl Default for HeartbeatScheduler {
208    fn default() -> Self {
209        Self::new()
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn heartbeat_config_new() {
219        let cfg = HeartbeatConfig::new("agent-1", Duration::from_secs(10));
220        assert_eq!(cfg.agent_id, "agent-1");
221        assert_eq!(cfg.interval, Duration::from_secs(10));
222        assert!(cfg.enabled);
223    }
224
225    #[test]
226    fn heartbeat_phase_cycle() {
227        let mut phase = HeartbeatPhase::Wake;
228        phase = phase.next(); // CheckQueue
229        assert_eq!(phase, HeartbeatPhase::CheckQueue);
230        phase = phase.next(); // Execute
231        assert_eq!(phase, HeartbeatPhase::Execute);
232        phase = phase.next(); // Sleep
233        assert_eq!(phase, HeartbeatPhase::Sleep);
234        phase = phase.next(); // Report
235        assert_eq!(phase, HeartbeatPhase::Report);
236        phase = phase.next(); // Wake (cycle)
237        assert_eq!(phase, HeartbeatPhase::Wake);
238    }
239
240    #[test]
241    fn heartbeat_phase_default_is_sleep() {
242        assert_eq!(HeartbeatPhase::default(), HeartbeatPhase::Sleep);
243    }
244
245    #[test]
246    fn heartbeat_phase_display() {
247        assert_eq!(HeartbeatPhase::Wake.to_string(), "wake");
248        assert_eq!(HeartbeatPhase::CheckQueue.to_string(), "check_queue");
249        assert_eq!(HeartbeatPhase::Execute.to_string(), "execute");
250        assert_eq!(HeartbeatPhase::Sleep.to_string(), "sleep");
251        assert_eq!(HeartbeatPhase::Report.to_string(), "report");
252    }
253
254    #[test]
255    fn heartbeat_phase_serde() {
256        let phases = [
257            (HeartbeatPhase::Wake, "\"wake\""),
258            (HeartbeatPhase::CheckQueue, "\"check_queue\""),
259            (HeartbeatPhase::Execute, "\"execute\""),
260            (HeartbeatPhase::Sleep, "\"sleep\""),
261            (HeartbeatPhase::Report, "\"report\""),
262        ];
263        for (phase, expected) in &phases {
264            let json = serde_json::to_string(phase).unwrap();
265            assert_eq!(&json, expected);
266            let restored: HeartbeatPhase = serde_json::from_str(&json).unwrap();
267            assert_eq!(&restored, phase);
268        }
269    }
270
271    #[test]
272    fn scheduler_empty() {
273        let sched = HeartbeatScheduler::new();
274        assert!(sched.is_empty());
275        assert_eq!(sched.len(), 0);
276    }
277
278    #[test]
279    fn scheduler_register_unregister() {
280        let mut sched = HeartbeatScheduler::new();
281        sched.register(HeartbeatConfig::new("a1", Duration::from_secs(5)));
282        assert_eq!(sched.len(), 1);
283        assert!(!sched.is_empty());
284
285        assert!(sched.unregister("a1"));
286        assert!(sched.is_empty());
287        assert!(!sched.unregister("a1")); // already removed
288    }
289
290    #[test]
291    fn scheduler_tick_no_wakeup_before_interval() {
292        let mut sched = HeartbeatScheduler::new();
293        sched.register(HeartbeatConfig::new("a1", Duration::from_secs(10)));
294
295        let wakeups = sched.tick(Duration::from_secs(5));
296        assert!(wakeups.is_empty());
297    }
298
299    #[test]
300    fn scheduler_tick_wakeup_at_interval() {
301        let mut sched = HeartbeatScheduler::new();
302        sched.register(HeartbeatConfig::new("a1", Duration::from_secs(10)));
303
304        let wakeups = sched.tick(Duration::from_secs(10));
305        assert_eq!(wakeups.len(), 1);
306        assert_eq!(wakeups[0].agent_id, "a1");
307        assert_eq!(wakeups[0].phase, HeartbeatPhase::Wake);
308        assert_eq!(wakeups[0].tick_count, 1);
309    }
310
311    #[test]
312    fn scheduler_tick_accumulates() {
313        let mut sched = HeartbeatScheduler::new();
314        sched.register(HeartbeatConfig::new("a1", Duration::from_secs(10)));
315
316        // 4 + 4 = 8, not enough
317        let w1 = sched.tick(Duration::from_secs(4));
318        assert!(w1.is_empty());
319        let w2 = sched.tick(Duration::from_secs(4));
320        assert!(w2.is_empty());
321
322        // 8 + 3 = 11 >= 10, wake up
323        let w3 = sched.tick(Duration::from_secs(3));
324        assert_eq!(w3.len(), 1);
325    }
326
327    #[test]
328    fn scheduler_tick_disabled_agent_skipped() {
329        let mut sched = HeartbeatScheduler::new();
330        let mut cfg = HeartbeatConfig::new("a1", Duration::from_secs(1));
331        cfg.enabled = false;
332        sched.register(cfg);
333
334        let wakeups = sched.tick(Duration::from_secs(5));
335        assert!(wakeups.is_empty());
336    }
337
338    #[test]
339    fn scheduler_tick_multiple_agents() {
340        let mut sched = HeartbeatScheduler::new();
341        sched.register(HeartbeatConfig::new("fast", Duration::from_secs(5)));
342        sched.register(HeartbeatConfig::new("slow", Duration::from_secs(15)));
343
344        // At t=5: fast wakes, slow does not
345        let w1 = sched.tick(Duration::from_secs(5));
346        assert_eq!(w1.len(), 1);
347        assert_eq!(w1[0].agent_id, "fast");
348
349        // At t=10: fast wakes again, slow still not
350        let w2 = sched.tick(Duration::from_secs(5));
351        assert_eq!(w2.len(), 1);
352        assert_eq!(w2[0].agent_id, "fast");
353
354        // At t=15: both wake
355        let w3 = sched.tick(Duration::from_secs(5));
356        assert_eq!(w3.len(), 2);
357    }
358
359    #[test]
360    fn scheduler_phase_tracking() {
361        let mut sched = HeartbeatScheduler::new();
362        sched.register(HeartbeatConfig::new("a1", Duration::from_secs(1)));
363
364        // Initially Sleep
365        assert_eq!(sched.phase("a1"), Some(HeartbeatPhase::Sleep));
366
367        // After tick, should be Wake
368        sched.tick(Duration::from_secs(1));
369        assert_eq!(sched.phase("a1"), Some(HeartbeatPhase::Wake));
370
371        // Advance through phases
372        assert_eq!(sched.advance_phase("a1"), Some(HeartbeatPhase::CheckQueue));
373        assert_eq!(sched.advance_phase("a1"), Some(HeartbeatPhase::Execute));
374        assert_eq!(sched.advance_phase("a1"), Some(HeartbeatPhase::Sleep));
375        assert_eq!(sched.advance_phase("a1"), Some(HeartbeatPhase::Report));
376        assert_eq!(sched.advance_phase("a1"), Some(HeartbeatPhase::Wake));
377    }
378
379    #[test]
380    fn scheduler_phase_unknown_agent() {
381        let sched = HeartbeatScheduler::new();
382        assert_eq!(sched.phase("unknown"), None);
383    }
384
385    #[test]
386    fn scheduler_advance_phase_unknown_agent() {
387        let mut sched = HeartbeatScheduler::new();
388        assert_eq!(sched.advance_phase("unknown"), None);
389    }
390
391    #[test]
392    fn scheduler_agent_ids() {
393        let mut sched = HeartbeatScheduler::new();
394        sched.register(HeartbeatConfig::new("a1", Duration::from_secs(1)));
395        sched.register(HeartbeatConfig::new("a2", Duration::from_secs(2)));
396        let mut ids = sched.agent_ids();
397        ids.sort();
398        assert_eq!(ids, vec!["a1", "a2"]);
399    }
400
401    #[test]
402    fn scheduler_tick_count() {
403        let mut sched = HeartbeatScheduler::new();
404        sched.register(HeartbeatConfig::new("a1", Duration::from_secs(1)));
405
406        assert_eq!(sched.tick_count("a1"), Some(0));
407        sched.tick(Duration::from_secs(1));
408        assert_eq!(sched.tick_count("a1"), Some(1));
409        sched.tick(Duration::from_secs(1));
410        assert_eq!(sched.tick_count("a1"), Some(2));
411    }
412
413    #[test]
414    fn scheduler_register_replaces() {
415        let mut sched = HeartbeatScheduler::new();
416        sched.register(HeartbeatConfig::new("a1", Duration::from_secs(5)));
417        sched.tick(Duration::from_secs(5));
418        assert_eq!(sched.tick_count("a1"), Some(1));
419
420        // Re-register resets state
421        sched.register(HeartbeatConfig::new("a1", Duration::from_secs(10)));
422        assert_eq!(sched.tick_count("a1"), Some(0));
423        assert_eq!(sched.phase("a1"), Some(HeartbeatPhase::Sleep));
424    }
425
426    #[test]
427    fn heartbeat_config_serde_roundtrip() {
428        let cfg = HeartbeatConfig::new("a1", Duration::from_secs(30));
429        let json = serde_json::to_string(&cfg).unwrap();
430        let restored: HeartbeatConfig = serde_json::from_str(&json).unwrap();
431        assert_eq!(restored.agent_id, "a1");
432        assert_eq!(restored.interval, Duration::from_secs(30));
433        assert!(restored.enabled);
434    }
435}