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#[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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct HeartbeatMetrics {
80 pub uptime_secs: u64,
82 pub consecutive_successes: u64,
84 pub consecutive_failures: u64,
86 pub last_tick_at: Option<DateTime<Utc>>,
88 pub avg_tick_duration_ms: f64,
90 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 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 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; 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
137pub 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); }
161
162 base_minutes.clamp(min_minutes, max_minutes)
163}
164
165pub 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 pub fn metrics(&self) -> Arc<ParkingMutex<HeartbeatMetrics>> {
191 Arc::clone(&self.metrics)
192 }
193
194 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 async fn tick(&self) -> Result<usize> {
249 Ok(self.collect_tasks().await?.len())
250 }
251
252 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 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 tasks.sort_by_key(|task| std::cmp::Reverse(task.priority));
272 Ok(tasks)
273 }
274
275 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 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 HeartbeatTask {
319 text: text.to_string(),
320 priority: TaskPriority::Medium,
321 status: TaskStatus::Active,
322 }
323 }
324
325 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 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 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 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 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) } else {
403 None
404 }
405 })
406 .collect()
407 }
408
409 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 #[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 #[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 #[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 #[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 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); 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 #[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 assert!((m.avg_tick_duration_ms - 130.0).abs() < f64::EPSILON);
838 }
839
840 #[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 assert_eq!(compute_adaptive_interval(30, 5, 120, 1, false), 60);
858 assert_eq!(compute_adaptive_interval(30, 5, 120, 2, false), 120);
860 assert_eq!(compute_adaptive_interval(30, 5, 120, 3, false), 120);
862 }
863
864 #[test]
865 fn adaptive_backoff_respects_min() {
866 assert!(compute_adaptive_interval(5, 10, 120, 0, false) >= 10);
868 }
869
870 #[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}