Skip to main content

apollo/
heartbeat.rs

1//! Heartbeat system — periodic check-ins that read HEARTBEAT.md
2//! and trigger agent actions when tasks are present.
3
4use std::path::{Path, PathBuf};
5use tokio::sync::mpsc;
6
7use crate::channels::IncomingMessage;
8
9pub struct HeartbeatConfig {
10    pub interval_secs: u64,
11    pub quiet_start_hour: u32, // 23
12    pub quiet_end_hour: u32,   // 8
13    pub workspace: PathBuf,
14    /// Deliver synthetic heartbeat to this chat_id (else "heartbeat").
15    pub deliver_chat_id: Option<String>,
16}
17
18impl Default for HeartbeatConfig {
19    fn default() -> Self {
20        Self {
21            interval_secs: 1800, // 30 minutes
22            quiet_start_hour: 23,
23            quiet_end_hour: 8,
24            workspace: PathBuf::from("."),
25            deliver_chat_id: None,
26        }
27    }
28}
29
30/// Start the heartbeat background task.
31/// Sends synthetic IncomingMessages to the agent loop when HEARTBEAT.md has tasks.
32pub fn start_heartbeat(
33    config: HeartbeatConfig,
34    tx: mpsc::Sender<IncomingMessage>,
35) -> tokio::task::JoinHandle<()> {
36    tokio::spawn(async move {
37        let mut interval =
38            tokio::time::interval(tokio::time::Duration::from_secs(config.interval_secs));
39
40        // Skip first tick (immediate)
41        interval.tick().await;
42
43        loop {
44            interval.tick().await;
45
46            // Check quiet hours
47            let now = chrono::Local::now();
48            let hour = now.hour();
49            if hour >= config.quiet_start_hour || hour < config.quiet_end_hour {
50                tracing::debug!("Heartbeat: quiet hours ({:02}:00), skipping", hour);
51                continue;
52            }
53
54            // Read HEARTBEAT.md
55            let heartbeat_path = config.workspace.join("HEARTBEAT.md");
56            let content = match std::fs::read_to_string(&heartbeat_path) {
57                Ok(c) => c,
58                Err(_) => continue, // No file = skip
59            };
60
61            // Check if there are actual tasks (not just comments/empty)
62            let has_tasks = content.lines().any(|line| {
63                let trimmed = line.trim();
64                !trimmed.is_empty() && !trimmed.starts_with('#') && !trimmed.starts_with("//")
65            });
66
67            if !has_tasks {
68                tracing::debug!("Heartbeat: HEARTBEAT.md empty/comments only, skipping");
69                continue;
70            }
71
72            // Build heartbeat prompt
73            let prompt = format!(
74                "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. \
75                 Do not infer or repeat old tasks from prior chats. \
76                 If nothing needs attention, reply HEARTBEAT_OK.\n\n\
77                 Current HEARTBEAT.md:\n```\n{}\n```",
78                content
79            );
80
81            let chat_id = config
82                .deliver_chat_id
83                .clone()
84                .unwrap_or_else(|| "heartbeat".to_string());
85
86            let msg = IncomingMessage {
87                id: format!("heartbeat-{}", chrono::Utc::now().timestamp()),
88                sender_id: "system".to_string(),
89                sender_name: Some("heartbeat".to_string()),
90                chat_id,
91                text: prompt,
92                is_group: false,
93                reply_to: None,
94                timestamp: chrono::Utc::now(),
95            };
96
97            if tx.send(msg).await.is_err() {
98                tracing::warn!("Heartbeat: channel closed, stopping");
99                break;
100            }
101
102            update_heartbeat_state(&config.workspace);
103        }
104    })
105}
106
107fn update_heartbeat_state(workspace: &Path) {
108    let state_path = workspace.join("memory/heartbeat-state.json");
109    if let Some(parent) = state_path.parent() {
110        let _ = std::fs::create_dir_all(parent);
111    }
112
113    let now = chrono::Utc::now().timestamp();
114    let state = serde_json::json!({
115        "lastHeartbeat": now,
116        "lastChecks": {
117            "heartbeat": now
118        }
119    });
120
121    let _ = std::fs::write(
122        &state_path,
123        serde_json::to_string_pretty(&state).unwrap_or_default(),
124    );
125}
126
127use chrono::Timelike;