Skip to main content

zeroclaw_runtime/heartbeat/
engine.rs

1use crate::observability::{Observer, ObserverEvent};
2use anyhow::Result;
3use chrono::{DateTime, Utc};
4use parking_lot::Mutex as ParkingMutex;
5use serde::{Deserialize, Serialize};
6use std::fmt;
7use std::path::Path;
8use std::sync::Arc;
9use tokio::time::{self, Duration};
10use zeroclaw_config::schema::HeartbeatConfig;
11
12// ── Structured task types ────────────────────────────────────────
13
14/// Priority level for a heartbeat task.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
16#[serde(rename_all = "lowercase")]
17pub enum TaskPriority {
18    Low,
19    Medium,
20    High,
21}
22
23impl fmt::Display for TaskPriority {
24    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25        match self {
26            Self::Low => write!(f, "low"),
27            Self::Medium => write!(f, "medium"),
28            Self::High => write!(f, "high"),
29        }
30    }
31}
32
33/// Status of a heartbeat task.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "lowercase")]
36pub enum TaskStatus {
37    Active,
38    Paused,
39    Completed,
40}
41
42impl fmt::Display for TaskStatus {
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        match self {
45            Self::Active => write!(f, "active"),
46            Self::Paused => write!(f, "paused"),
47            Self::Completed => write!(f, "completed"),
48        }
49    }
50}
51
52/// A structured heartbeat task with priority and status metadata.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct HeartbeatTask {
55    pub text: String,
56    pub priority: TaskPriority,
57    pub status: TaskStatus,
58}
59
60impl HeartbeatTask {
61    pub fn is_runnable(&self) -> bool {
62        self.status == TaskStatus::Active
63    }
64}
65
66impl fmt::Display for HeartbeatTask {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        write!(f, "[{}] {}", self.priority, self.text)
69    }
70}
71
72// ── Health Metrics ───────────────────────────────────────────────
73
74/// Live health metrics for the heartbeat subsystem.
75///
76/// Shared via `Arc<ParkingMutex<>>` between the heartbeat worker,
77/// deadman watcher, and API consumers.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct HeartbeatMetrics {
80    /// Monotonic uptime since the heartbeat loop started.
81    pub uptime_secs: u64,
82    /// Consecutive successful ticks (resets on failure).
83    pub consecutive_successes: u64,
84    /// Consecutive failed ticks (resets on success).
85    pub consecutive_failures: u64,
86    /// Timestamp of the most recent tick (UTC RFC 3339).
87    pub last_tick_at: Option<DateTime<Utc>>,
88    /// Exponential moving average of tick durations in milliseconds.
89    pub avg_tick_duration_ms: f64,
90    /// Total number of ticks executed since startup.
91    pub total_ticks: u64,
92}
93
94impl Default for HeartbeatMetrics {
95    fn default() -> Self {
96        Self {
97            uptime_secs: 0,
98            consecutive_successes: 0,
99            consecutive_failures: 0,
100            last_tick_at: None,
101            avg_tick_duration_ms: 0.0,
102            total_ticks: 0,
103        }
104    }
105}
106
107impl HeartbeatMetrics {
108    /// Record a successful tick with the given duration.
109    pub fn record_success(&mut self, duration_ms: f64) {
110        self.consecutive_successes += 1;
111        self.consecutive_failures = 0;
112        self.last_tick_at = Some(Utc::now());
113        self.total_ticks += 1;
114        self.update_avg_duration(duration_ms);
115    }
116
117    /// Record a failed tick with the given duration.
118    pub fn record_failure(&mut self, duration_ms: f64) {
119        self.consecutive_failures += 1;
120        self.consecutive_successes = 0;
121        self.last_tick_at = Some(Utc::now());
122        self.total_ticks += 1;
123        self.update_avg_duration(duration_ms);
124    }
125
126    fn update_avg_duration(&mut self, duration_ms: f64) {
127        const ALPHA: f64 = 0.3; // EMA smoothing factor
128        if self.total_ticks == 1 {
129            self.avg_tick_duration_ms = duration_ms;
130        } else {
131            self.avg_tick_duration_ms =
132                ALPHA * duration_ms + (1.0 - ALPHA) * self.avg_tick_duration_ms;
133        }
134    }
135}
136
137/// Compute the adaptive interval for the next heartbeat tick.
138///
139/// Strategy:
140/// - On failures: exponential back-off `base * 2^failures` capped at `max_interval`.
141/// - When high-priority tasks are present: use `min_interval` for faster reaction.
142/// - Otherwise: use `base_interval`.
143pub fn compute_adaptive_interval(
144    base_minutes: u32,
145    min_minutes: u32,
146    max_minutes: u32,
147    consecutive_failures: u64,
148    has_high_priority_tasks: bool,
149) -> u32 {
150    if consecutive_failures > 0 {
151        let backoff = base_minutes.saturating_mul(
152            1u32.checked_shl(consecutive_failures.min(10) as u32)
153                .unwrap_or(u32::MAX),
154        );
155        return backoff.min(max_minutes).max(min_minutes);
156    }
157
158    if has_high_priority_tasks {
159        return min_minutes.max(5); // never go below 5 minutes
160    }
161
162    base_minutes.clamp(min_minutes, max_minutes)
163}
164
165// ── Engine ───────────────────────────────────────────────────────
166
167/// Heartbeat engine — reads HEARTBEAT.md and executes tasks periodically
168pub struct HeartbeatEngine {
169    config: HeartbeatConfig,
170    workspace_dir: std::path::PathBuf,
171    observer: Arc<dyn Observer>,
172    metrics: Arc<ParkingMutex<HeartbeatMetrics>>,
173}
174
175impl HeartbeatEngine {
176    pub fn new(
177        config: HeartbeatConfig,
178        workspace_dir: std::path::PathBuf,
179        observer: Arc<dyn Observer>,
180    ) -> Self {
181        Self {
182            config,
183            workspace_dir,
184            observer,
185            metrics: Arc::new(ParkingMutex::new(HeartbeatMetrics::default())),
186        }
187    }
188
189    /// Get a shared handle to the live heartbeat metrics.
190    pub fn metrics(&self) -> Arc<ParkingMutex<HeartbeatMetrics>> {
191        Arc::clone(&self.metrics)
192    }
193
194    /// Start the heartbeat loop (runs until cancelled)
195    pub async fn run(&self) -> Result<()> {
196        if !self.config.enabled {
197            ::zeroclaw_log::record!(
198                INFO,
199                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
200                "Heartbeat disabled"
201            );
202            return Ok(());
203        }
204
205        let interval_mins = self.config.interval_minutes.max(1);
206        ::zeroclaw_log::record!(
207            INFO,
208            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
209            &format!("💓 Heartbeat started: every {} minutes", interval_mins)
210        );
211
212        let mut interval = time::interval(Duration::from_secs(u64::from(interval_mins) * 60));
213
214        loop {
215            interval.tick().await;
216            self.observer.record_event(&ObserverEvent::HeartbeatTick);
217
218            match self.tick().await {
219                Ok(tasks) => {
220                    if tasks > 0 {
221                        ::zeroclaw_log::record!(
222                            INFO,
223                            ::zeroclaw_log::Event::new(
224                                module_path!(),
225                                ::zeroclaw_log::Action::Note
226                            ),
227                            &format!("💓 Heartbeat: processed {} tasks", tasks)
228                        );
229                    }
230                }
231                Err(e) => {
232                    ::zeroclaw_log::record!(
233                        WARN,
234                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
235                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
236                        &format!("💓 Heartbeat error: {}", e)
237                    );
238                    self.observer.record_event(&ObserverEvent::Error {
239                        component: "heartbeat".into(),
240                        message: e.to_string(),
241                    });
242                }
243            }
244        }
245    }
246
247    /// Single heartbeat tick — read HEARTBEAT.md and return task count
248    async fn tick(&self) -> Result<usize> {
249        Ok(self.collect_tasks().await?.len())
250    }
251
252    /// Read HEARTBEAT.md and return all parsed structured tasks.
253    pub async fn collect_tasks(&self) -> Result<Vec<HeartbeatTask>> {
254        let heartbeat_path = self.workspace_dir.join("HEARTBEAT.md");
255        if !heartbeat_path.exists() {
256            return Ok(Vec::new());
257        }
258        let content = tokio::fs::read_to_string(&heartbeat_path).await?;
259        Ok(Self::parse_tasks(&content))
260    }
261
262    /// Collect only runnable (active) tasks, sorted by priority (high first).
263    pub async fn collect_runnable_tasks(&self) -> Result<Vec<HeartbeatTask>> {
264        let mut tasks: Vec<HeartbeatTask> = self
265            .collect_tasks()
266            .await?
267            .into_iter()
268            .filter(HeartbeatTask::is_runnable)
269            .collect();
270        // Sort by priority descending (High > Medium > Low)
271        tasks.sort_by_key(|task| std::cmp::Reverse(task.priority));
272        Ok(tasks)
273    }
274
275    /// Parse tasks from HEARTBEAT.md with structured metadata support.
276    ///
277    /// Supports both legacy flat format and new structured format:
278    ///
279    /// Legacy:
280    ///   `- Check email`  →  medium priority, active status
281    ///
282    /// Structured:
283    ///   `- [high] Check email`           →  high priority, active
284    ///   `- [low|paused] Review old PRs`  →  low priority, paused
285    ///   `- [completed] Old task`         →  medium priority, completed
286    fn parse_tasks(content: &str) -> Vec<HeartbeatTask> {
287        content
288            .lines()
289            .filter_map(|line| {
290                let trimmed = line.trim();
291                let text = trimmed.strip_prefix("- ")?;
292                if text.is_empty() {
293                    return None;
294                }
295                Some(Self::parse_task_line(text))
296            })
297            .collect()
298    }
299
300    /// Parse a single task line into a structured `HeartbeatTask`.
301    ///
302    /// Format: `[priority|status] task text` or just `task text`.
303    fn parse_task_line(text: &str) -> HeartbeatTask {
304        if let Some(rest) = text.strip_prefix('[')
305            && let Some((meta, task_text)) = rest.split_once(']')
306        {
307            let task_text = task_text.trim();
308            if !task_text.is_empty() {
309                let (priority, status) = Self::parse_meta(meta);
310                return HeartbeatTask {
311                    text: task_text.to_string(),
312                    priority,
313                    status,
314                };
315            }
316        }
317        // No metadata — default to medium/active
318        HeartbeatTask {
319            text: text.to_string(),
320            priority: TaskPriority::Medium,
321            status: TaskStatus::Active,
322        }
323    }
324
325    /// Parse metadata tags like `high`, `low|paused`, `completed`.
326    fn parse_meta(meta: &str) -> (TaskPriority, TaskStatus) {
327        let mut priority = TaskPriority::Medium;
328        let mut status = TaskStatus::Active;
329
330        for part in meta.split('|') {
331            match part.trim().to_ascii_lowercase().as_str() {
332                "high" => priority = TaskPriority::High,
333                "medium" | "med" => priority = TaskPriority::Medium,
334                "low" => priority = TaskPriority::Low,
335                "active" => status = TaskStatus::Active,
336                "paused" | "pause" => status = TaskStatus::Paused,
337                "completed" | "complete" | "done" => status = TaskStatus::Completed,
338                _ => {}
339            }
340        }
341
342        (priority, status)
343    }
344
345    /// Build the Phase 1 LLM decision prompt for two-phase heartbeat.
346    pub fn build_decision_prompt(tasks: &[HeartbeatTask]) -> String {
347        let now = chrono::Utc::now();
348        let mut prompt = format!(
349            "You are a heartbeat scheduler. Review the following periodic tasks and decide \
350             whether any should be executed right now.\n\n\
351             Current time: {} UTC ({})\n\n\
352             Consider:\n\
353             - Task priority (high tasks are more urgent)\n\
354             - Whether the task is time-sensitive or can wait\n\
355             - Whether running the task now would provide value\n\n\
356             Tasks:\n",
357            now.format("%Y-%m-%d %H:%M:%S"),
358            now.format("%A"),
359        );
360
361        for (i, task) in tasks.iter().enumerate() {
362            use std::fmt::Write;
363            let _ = writeln!(prompt, "{}. [{}] {}", i + 1, task.priority, task.text);
364        }
365
366        prompt.push_str(
367            "\nRespond with ONLY one of:\n\
368             - `run: 1,2,3` (comma-separated task numbers to execute)\n\
369             - `skip` (nothing needs to run right now)\n\n\
370             Be conservative — skip if tasks are routine and not time-sensitive.",
371        );
372
373        prompt
374    }
375
376    /// Parse the Phase 1 LLM decision response.
377    ///
378    /// Returns indices of tasks to run, or empty vec if skipped.
379    pub fn parse_decision_response(response: &str, task_count: usize) -> Vec<usize> {
380        let trimmed = response.trim().to_ascii_lowercase();
381
382        if trimmed == "skip" || trimmed.starts_with("skip") {
383            return Vec::new();
384        }
385
386        // Look for "run: 1,2,3" pattern
387        let numbers_part = if let Some(after_run) = trimmed.strip_prefix("run:") {
388            after_run.trim()
389        } else if let Some(after_run) = trimmed.strip_prefix("run ") {
390            after_run.trim()
391        } else {
392            // Try to parse as bare numbers
393            trimmed.as_str()
394        };
395
396        numbers_part
397            .split(',')
398            .filter_map(|s| {
399                let n: usize = s.trim().parse().ok()?;
400                if n >= 1 && n <= task_count {
401                    Some(n - 1) // Convert to 0-indexed
402                } else {
403                    None
404                }
405            })
406            .collect()
407    }
408
409    /// Create a default HEARTBEAT.md if it doesn't exist
410    pub async fn ensure_heartbeat_file(workspace_dir: &Path) -> Result<()> {
411        let path = workspace_dir.join("HEARTBEAT.md");
412        if !path.exists() {
413            let default = "# Periodic Tasks\n\n\
414                           # Add tasks below (one per line, starting with `- `)\n\
415                           # The agent will check this file on each heartbeat tick.\n\
416                           #\n\
417                           # Format: - [priority|status] Task description\n\
418                           #   priority: high, medium (default), low\n\
419                           #   status:   active (default), paused, completed\n\
420                           #\n\
421                           # Examples:\n\
422                           # - [high] Check my email for important messages\n\
423                           # - Review my calendar for upcoming events\n\
424                           # - [low|paused] Check the weather forecast\n";
425            tokio::fs::write(&path, default).await?;
426        }
427        Ok(())
428    }
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    #[test]
436    fn parse_tasks_basic() {
437        let content = "# Tasks\n\n- Check email\n- Review calendar\nNot a task\n- Third task";
438        let tasks = HeartbeatEngine::parse_tasks(content);
439        assert_eq!(tasks.len(), 3);
440        assert_eq!(tasks[0].text, "Check email");
441        assert_eq!(tasks[0].priority, TaskPriority::Medium);
442        assert_eq!(tasks[0].status, TaskStatus::Active);
443    }
444
445    #[test]
446    fn parse_tasks_empty_content() {
447        assert!(HeartbeatEngine::parse_tasks("").is_empty());
448    }
449
450    #[test]
451    fn parse_tasks_only_comments() {
452        let tasks = HeartbeatEngine::parse_tasks("# No tasks here\n\nJust comments\n# Another");
453        assert!(tasks.is_empty());
454    }
455
456    #[test]
457    fn parse_tasks_with_leading_whitespace() {
458        let content = "  - Indented task\n\t- Tab indented";
459        let tasks = HeartbeatEngine::parse_tasks(content);
460        assert_eq!(tasks.len(), 2);
461        assert_eq!(tasks[0].text, "Indented task");
462        assert_eq!(tasks[1].text, "Tab indented");
463    }
464
465    #[test]
466    fn parse_tasks_dash_without_space_ignored() {
467        let content = "- Real task\n-\n- Another";
468        let tasks = HeartbeatEngine::parse_tasks(content);
469        assert_eq!(tasks.len(), 2);
470        assert_eq!(tasks[0].text, "Real task");
471        assert_eq!(tasks[1].text, "Another");
472    }
473
474    #[test]
475    fn parse_tasks_trailing_space_bullet_trimmed_to_dash() {
476        let content = "- ";
477        let tasks = HeartbeatEngine::parse_tasks(content);
478        assert_eq!(tasks.len(), 0);
479    }
480
481    #[test]
482    fn parse_tasks_bullet_with_content_after_spaces() {
483        let content = "- hello  ";
484        let tasks = HeartbeatEngine::parse_tasks(content);
485        assert_eq!(tasks.len(), 1);
486        assert_eq!(tasks[0].text, "hello");
487    }
488
489    #[test]
490    fn parse_tasks_unicode() {
491        let content = "- Check email 📧\n- Review calendar 📅\n- 日本語タスク";
492        let tasks = HeartbeatEngine::parse_tasks(content);
493        assert_eq!(tasks.len(), 3);
494        assert!(tasks[0].text.contains('📧'));
495        assert!(tasks[2].text.contains("日本語"));
496    }
497
498    #[test]
499    fn parse_tasks_mixed_markdown() {
500        let content = "# Periodic Tasks\n\n## Quick\n- Task A\n\n## Long\n- Task B\n\n* Not a dash bullet\n1. Not numbered";
501        let tasks = HeartbeatEngine::parse_tasks(content);
502        assert_eq!(tasks.len(), 2);
503        assert_eq!(tasks[0].text, "Task A");
504        assert_eq!(tasks[1].text, "Task B");
505    }
506
507    #[test]
508    fn parse_tasks_single_task() {
509        let tasks = HeartbeatEngine::parse_tasks("- Only one");
510        assert_eq!(tasks.len(), 1);
511        assert_eq!(tasks[0].text, "Only one");
512    }
513
514    #[test]
515    fn parse_tasks_many_tasks() {
516        let content: String = (0..100).fold(String::new(), |mut s, i| {
517            use std::fmt::Write;
518            let _ = writeln!(s, "- Task {i}");
519            s
520        });
521        let tasks = HeartbeatEngine::parse_tasks(&content);
522        assert_eq!(tasks.len(), 100);
523        assert_eq!(tasks[99].text, "Task 99");
524    }
525
526    // ── Structured task parsing tests ────────────────────────────
527
528    #[test]
529    fn parse_task_with_high_priority() {
530        let content = "- [high] Urgent email check";
531        let tasks = HeartbeatEngine::parse_tasks(content);
532        assert_eq!(tasks.len(), 1);
533        assert_eq!(tasks[0].text, "Urgent email check");
534        assert_eq!(tasks[0].priority, TaskPriority::High);
535        assert_eq!(tasks[0].status, TaskStatus::Active);
536    }
537
538    #[test]
539    fn parse_task_with_low_paused() {
540        let content = "- [low|paused] Review old PRs";
541        let tasks = HeartbeatEngine::parse_tasks(content);
542        assert_eq!(tasks.len(), 1);
543        assert_eq!(tasks[0].text, "Review old PRs");
544        assert_eq!(tasks[0].priority, TaskPriority::Low);
545        assert_eq!(tasks[0].status, TaskStatus::Paused);
546    }
547
548    #[test]
549    fn parse_task_completed() {
550        let content = "- [completed] Old task";
551        let tasks = HeartbeatEngine::parse_tasks(content);
552        assert_eq!(tasks.len(), 1);
553        assert_eq!(tasks[0].priority, TaskPriority::Medium);
554        assert_eq!(tasks[0].status, TaskStatus::Completed);
555    }
556
557    #[test]
558    fn parse_task_without_metadata_defaults() {
559        let content = "- Plain task";
560        let tasks = HeartbeatEngine::parse_tasks(content);
561        assert_eq!(tasks.len(), 1);
562        assert_eq!(tasks[0].text, "Plain task");
563        assert_eq!(tasks[0].priority, TaskPriority::Medium);
564        assert_eq!(tasks[0].status, TaskStatus::Active);
565    }
566
567    #[test]
568    fn parse_mixed_structured_and_legacy() {
569        let content = "- [high] Urgent\n- Normal task\n- [low|paused] Later";
570        let tasks = HeartbeatEngine::parse_tasks(content);
571        assert_eq!(tasks.len(), 3);
572        assert_eq!(tasks[0].priority, TaskPriority::High);
573        assert_eq!(tasks[1].priority, TaskPriority::Medium);
574        assert_eq!(tasks[2].priority, TaskPriority::Low);
575        assert_eq!(tasks[2].status, TaskStatus::Paused);
576    }
577
578    #[test]
579    fn runnable_filters_paused_and_completed() {
580        let content = "- [high] Active\n- [low|paused] Paused\n- [completed] Done";
581        let tasks = HeartbeatEngine::parse_tasks(content);
582        let runnable: Vec<_> = tasks
583            .into_iter()
584            .filter(HeartbeatTask::is_runnable)
585            .collect();
586        assert_eq!(runnable.len(), 1);
587        assert_eq!(runnable[0].text, "Active");
588    }
589
590    // ── Two-phase decision tests ────────────────────────────────
591
592    #[test]
593    fn decision_prompt_includes_all_tasks() {
594        let tasks = vec![
595            HeartbeatTask {
596                text: "Check email".into(),
597                priority: TaskPriority::High,
598                status: TaskStatus::Active,
599            },
600            HeartbeatTask {
601                text: "Review calendar".into(),
602                priority: TaskPriority::Medium,
603                status: TaskStatus::Active,
604            },
605        ];
606        let prompt = HeartbeatEngine::build_decision_prompt(&tasks);
607        assert!(prompt.contains("1. [high] Check email"));
608        assert!(prompt.contains("2. [medium] Review calendar"));
609        assert!(prompt.contains("skip"));
610        assert!(prompt.contains("run:"));
611        assert!(
612            prompt.contains("Current time:"),
613            "prompt must include current datetime for time-sensitive decisions"
614        );
615    }
616
617    #[test]
618    fn parse_decision_skip() {
619        let indices = HeartbeatEngine::parse_decision_response("skip", 3);
620        assert!(indices.is_empty());
621    }
622
623    #[test]
624    fn parse_decision_skip_with_reason() {
625        let indices =
626            HeartbeatEngine::parse_decision_response("skip — nothing urgent right now", 3);
627        assert!(indices.is_empty());
628    }
629
630    #[test]
631    fn parse_decision_run_single() {
632        let indices = HeartbeatEngine::parse_decision_response("run: 1", 3);
633        assert_eq!(indices, vec![0]);
634    }
635
636    #[test]
637    fn parse_decision_run_multiple() {
638        let indices = HeartbeatEngine::parse_decision_response("run: 1, 3", 3);
639        assert_eq!(indices, vec![0, 2]);
640    }
641
642    #[test]
643    fn parse_decision_run_out_of_range_ignored() {
644        let indices = HeartbeatEngine::parse_decision_response("run: 1, 5, 2", 3);
645        assert_eq!(indices, vec![0, 1]);
646    }
647
648    #[test]
649    fn parse_decision_run_zero_ignored() {
650        let indices = HeartbeatEngine::parse_decision_response("run: 0, 1", 3);
651        assert_eq!(indices, vec![0]);
652    }
653
654    // ── Task display ────────────────────────────────────────────
655
656    #[test]
657    fn task_display_format() {
658        let task = HeartbeatTask {
659            text: "Check email".into(),
660            priority: TaskPriority::High,
661            status: TaskStatus::Active,
662        };
663        assert_eq!(format!("{task}"), "[high] Check email");
664    }
665
666    #[test]
667    fn priority_ordering() {
668        assert!(TaskPriority::High > TaskPriority::Medium);
669        assert!(TaskPriority::Medium > TaskPriority::Low);
670    }
671
672    // ── Async tests ─────────────────────────────────────────────
673
674    #[tokio::test]
675    async fn ensure_heartbeat_file_creates_file() {
676        let dir = std::env::temp_dir().join("zeroclaw_test_heartbeat");
677        let _ = tokio::fs::remove_dir_all(&dir).await;
678        tokio::fs::create_dir_all(&dir).await.unwrap();
679
680        HeartbeatEngine::ensure_heartbeat_file(&dir).await.unwrap();
681
682        let path = dir.join("HEARTBEAT.md");
683        assert!(path.exists());
684        let content = tokio::fs::read_to_string(&path).await.unwrap();
685        assert!(content.contains("Periodic Tasks"));
686        assert!(content.contains("[high]"));
687
688        let _ = tokio::fs::remove_dir_all(&dir).await;
689    }
690
691    #[tokio::test]
692    async fn ensure_heartbeat_file_does_not_overwrite() {
693        let dir = std::env::temp_dir().join("zeroclaw_test_heartbeat_no_overwrite");
694        let _ = tokio::fs::remove_dir_all(&dir).await;
695        tokio::fs::create_dir_all(&dir).await.unwrap();
696
697        let path = dir.join("HEARTBEAT.md");
698        tokio::fs::write(&path, "- My custom task").await.unwrap();
699
700        HeartbeatEngine::ensure_heartbeat_file(&dir).await.unwrap();
701
702        let content = tokio::fs::read_to_string(&path).await.unwrap();
703        assert_eq!(content, "- My custom task");
704
705        let _ = tokio::fs::remove_dir_all(&dir).await;
706    }
707
708    #[tokio::test]
709    async fn tick_returns_zero_when_no_file() {
710        let dir = std::env::temp_dir().join("zeroclaw_test_tick_no_file");
711        let _ = tokio::fs::remove_dir_all(&dir).await;
712        tokio::fs::create_dir_all(&dir).await.unwrap();
713
714        let observer: Arc<dyn Observer> = Arc::new(crate::observability::NoopObserver);
715        let engine = HeartbeatEngine::new(
716            HeartbeatConfig {
717                enabled: true,
718                interval_minutes: 30,
719                ..HeartbeatConfig::default()
720            },
721            dir.clone(),
722            observer,
723        );
724        let count = engine.tick().await.unwrap();
725        assert_eq!(count, 0);
726
727        let _ = tokio::fs::remove_dir_all(&dir).await;
728    }
729
730    #[tokio::test]
731    async fn tick_counts_tasks_from_file() {
732        let dir = std::env::temp_dir().join("zeroclaw_test_tick_count");
733        let _ = tokio::fs::remove_dir_all(&dir).await;
734        tokio::fs::create_dir_all(&dir).await.unwrap();
735
736        tokio::fs::write(dir.join("HEARTBEAT.md"), "- A\n- B\n- C")
737            .await
738            .unwrap();
739
740        let observer: Arc<dyn Observer> = Arc::new(crate::observability::NoopObserver);
741        let engine = HeartbeatEngine::new(
742            HeartbeatConfig {
743                enabled: true,
744                interval_minutes: 30,
745                ..HeartbeatConfig::default()
746            },
747            dir.clone(),
748            observer,
749        );
750        let count = engine.tick().await.unwrap();
751        assert_eq!(count, 3);
752
753        let _ = tokio::fs::remove_dir_all(&dir).await;
754    }
755
756    #[tokio::test]
757    async fn run_returns_immediately_when_disabled() {
758        let observer: Arc<dyn Observer> = Arc::new(crate::observability::NoopObserver);
759        let engine = HeartbeatEngine::new(
760            HeartbeatConfig {
761                enabled: false,
762                interval_minutes: 30,
763                ..HeartbeatConfig::default()
764            },
765            std::env::temp_dir(),
766            observer,
767        );
768        // Should return Ok immediately, not loop forever
769        let result = engine.run().await;
770        assert!(result.is_ok());
771    }
772
773    #[tokio::test]
774    async fn collect_runnable_tasks_sorts_by_priority() {
775        let dir = std::env::temp_dir().join("zeroclaw_test_runnable_sort");
776        let _ = tokio::fs::remove_dir_all(&dir).await;
777        tokio::fs::create_dir_all(&dir).await.unwrap();
778
779        tokio::fs::write(
780            dir.join("HEARTBEAT.md"),
781            "- [low] Low task\n- [high] High task\n- Medium task\n- [low|paused] Skip me",
782        )
783        .await
784        .unwrap();
785
786        let observer: Arc<dyn Observer> = Arc::new(crate::observability::NoopObserver);
787        let engine = HeartbeatEngine::new(
788            HeartbeatConfig {
789                enabled: true,
790                interval_minutes: 30,
791                ..HeartbeatConfig::default()
792            },
793            dir.clone(),
794            observer,
795        );
796
797        let tasks = engine.collect_runnable_tasks().await.unwrap();
798        assert_eq!(tasks.len(), 3); // paused one excluded
799        assert_eq!(tasks[0].priority, TaskPriority::High);
800        assert_eq!(tasks[1].priority, TaskPriority::Medium);
801        assert_eq!(tasks[2].priority, TaskPriority::Low);
802
803        let _ = tokio::fs::remove_dir_all(&dir).await;
804    }
805
806    // ── HeartbeatMetrics tests ───────────────────────────────────
807
808    #[test]
809    fn metrics_record_success_updates_fields() {
810        let mut m = HeartbeatMetrics::default();
811        m.record_success(100.0);
812        assert_eq!(m.consecutive_successes, 1);
813        assert_eq!(m.consecutive_failures, 0);
814        assert_eq!(m.total_ticks, 1);
815        assert!(m.last_tick_at.is_some());
816        assert!((m.avg_tick_duration_ms - 100.0).abs() < f64::EPSILON);
817    }
818
819    #[test]
820    fn metrics_record_failure_resets_successes() {
821        let mut m = HeartbeatMetrics::default();
822        m.record_success(50.0);
823        m.record_success(50.0);
824        m.record_failure(200.0);
825        assert_eq!(m.consecutive_successes, 0);
826        assert_eq!(m.consecutive_failures, 1);
827        assert_eq!(m.total_ticks, 3);
828    }
829
830    #[test]
831    fn metrics_ema_smoothing() {
832        let mut m = HeartbeatMetrics::default();
833        m.record_success(100.0);
834        assert!((m.avg_tick_duration_ms - 100.0).abs() < f64::EPSILON);
835        m.record_success(200.0);
836        // EMA: 0.3 * 200 + 0.7 * 100 = 130
837        assert!((m.avg_tick_duration_ms - 130.0).abs() < f64::EPSILON);
838    }
839
840    // ── Adaptive interval tests ─────────────────────────────────
841
842    #[test]
843    fn adaptive_uses_base_when_no_failures() {
844        let result = compute_adaptive_interval(30, 5, 120, 0, false);
845        assert_eq!(result, 30);
846    }
847
848    #[test]
849    fn adaptive_uses_min_for_high_priority() {
850        let result = compute_adaptive_interval(30, 5, 120, 0, true);
851        assert_eq!(result, 5);
852    }
853
854    #[test]
855    fn adaptive_backs_off_on_failures() {
856        // 1 failure: 30 * 2 = 60
857        assert_eq!(compute_adaptive_interval(30, 5, 120, 1, false), 60);
858        // 2 failures: 30 * 4 = 120 (capped at max)
859        assert_eq!(compute_adaptive_interval(30, 5, 120, 2, false), 120);
860        // 3 failures: 30 * 8 = 240 → capped at 120
861        assert_eq!(compute_adaptive_interval(30, 5, 120, 3, false), 120);
862    }
863
864    #[test]
865    fn adaptive_backoff_respects_min() {
866        // Even with failures, must be >= min
867        assert!(compute_adaptive_interval(5, 10, 120, 0, false) >= 10);
868    }
869
870    // ── Engine metrics accessor ─────────────────────────────────
871
872    #[test]
873    fn engine_exposes_shared_metrics() {
874        let observer: Arc<dyn Observer> = Arc::new(crate::observability::NoopObserver);
875        let engine =
876            HeartbeatEngine::new(HeartbeatConfig::default(), std::env::temp_dir(), observer);
877        let metrics = engine.metrics();
878        assert_eq!(metrics.lock().total_ticks, 0);
879    }
880}