Skip to main content

apollo/
autonomous.rs

1//! Autonomous coding mode.
2//!
3//! 24/7 workspace-driven loop: reads TODO.md (or any task ledger),
4//! runs the agent on pending items, validates changes with tests,
5//! and optionally commits/pushes only on success.
6//!
7//! Port from hermes-rs `autonomous.rs`.
8//!
9//! pontytail: single-workspace, sequential. Parallel workspace support
10//! per Hermes-RS pattern can be added when multi-repo management is needed.
11
12use std::path::{Path, PathBuf};
13use std::time::Duration;
14
15use serde::{Deserialize, Serialize};
16
17use crate::agent::NullChannel;
18use crate::channels::IncomingMessage;
19
20/// Configuration for autonomous mode.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct AutonomousConfig {
23    /// How often to run a check cycle (seconds)
24    pub interval_secs: u64,
25    /// Path to task ledger (default: TODO.md)
26    pub todo_path: String,
27    /// Path to write status file
28    pub status_path: String,
29    /// Test command (empty = skip tests)
30    pub test_command: String,
31    /// Remote to push to (empty = no push)
32    pub git_remote: String,
33    /// Branch to push to
34    pub git_branch: String,
35}
36
37impl Default for AutonomousConfig {
38    fn default() -> Self {
39        Self {
40            interval_secs: 300,
41            todo_path: "TODO.md".to_string(),
42            status_path: "autonomous-status.toml".to_string(),
43            test_command: "cargo test --workspace".to_string(),
44            git_remote: "origin".to_string(),
45            git_branch: "main".to_string(),
46        }
47    }
48}
49
50/// State of the autonomous loop.
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
52#[serde(rename_all = "snake_case")]
53pub enum AutonomousState {
54    Idle,
55    Running,
56    Failed,
57    Paused,
58    Succeeded,
59}
60
61/// Status persisted to disk across restarts.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct AutonomousStatus {
64    pub state: AutonomousState,
65    pub current_task: Option<String>,
66    pub last_run: Option<String>,
67    pub last_result: Option<String>,
68    pub consecutive_failures: usize,
69    pub paused: bool,
70}
71
72impl AutonomousStatus {
73    fn new() -> Self {
74        Self {
75            state: AutonomousState::Idle,
76            current_task: None,
77            last_run: None,
78            last_result: None,
79            consecutive_failures: 0,
80            paused: false,
81        }
82    }
83}
84
85/// The autonomous loop controller.
86pub struct AutonomousLoop {
87    config: AutonomousConfig,
88    workspace: PathBuf,
89    status: AutonomousStatus,
90    status_path: PathBuf,
91}
92
93impl AutonomousLoop {
94    /// Create a new autonomous loop for a given workspace.
95    pub fn new(config: AutonomousConfig, workspace: PathBuf) -> Self {
96        let status_path = workspace.join(&config.status_path);
97        let status = Self::load_status(&status_path);
98        Self {
99            config,
100            workspace,
101            status,
102            status_path,
103        }
104    }
105
106    fn load_status(path: &Path) -> AutonomousStatus {
107        std::fs::read_to_string(path)
108            .ok()
109            .and_then(|s| toml::from_str(&s).ok())
110            .unwrap_or_else(AutonomousStatus::new)
111    }
112
113    fn save_status_to_file(status: &AutonomousStatus, path: &Path) {
114        match toml::to_string_pretty(status) {
115            Ok(content) => {
116                if let Err(e) = std::fs::write(path, &content) {
117                    tracing::warn!("[autonomous] failed to save status: {}", e);
118                }
119            }
120            Err(e) => tracing::warn!("[autonomous] failed to serialize status: {}", e),
121        }
122    }
123
124    pub fn start_fresh(&mut self) {
125        self.status = AutonomousStatus::new();
126        Self::save_status_to_file(&self.status, &self.status_path);
127    }
128
129    pub fn resume(&mut self) {
130        self.status.paused = false;
131        if self.status.state == AutonomousState::Paused {
132            self.status.state = AutonomousState::Idle;
133        }
134        Self::save_status_to_file(&self.status, &self.status_path);
135    }
136
137    /// Start the autonomous loop. Runs indefinitely.
138    pub async fn run(self, agent: std::sync::Arc<crate::agent::AgentRunner>) {
139        let config = self.config.clone();
140        let workspace = self.workspace.clone();
141        let mut status = self.status;
142
143        tracing::info!(
144            "[autonomous] starting (interval={}s, workspace={:?})",
145            config.interval_secs,
146            workspace
147        );
148
149        loop {
150            tokio::time::sleep(Duration::from_secs(config.interval_secs)).await;
151
152            if status.paused || status.state == AutonomousState::Paused {
153                tracing::debug!("[autonomous] paused, skipping");
154                continue;
155            }
156
157            // Read the task ledger
158            let todo_path = workspace.join(&config.todo_path);
159            let todo_content = match std::fs::read_to_string(&todo_path) {
160                Ok(c) => c.trim().to_string(),
161                Err(e) => {
162                    tracing::debug!("[autonomous] no TODO.md ({}), skipping", e);
163                    status.state = AutonomousState::Idle;
164                    Self::save_status_to_file(&status, &self.status_path);
165                    continue;
166                }
167            };
168
169            if todo_content.is_empty() || todo_content == "## Implemented\n\n## Pending\n" {
170                status.state = AutonomousState::Idle;
171                Self::save_status_to_file(&status, &self.status_path);
172                continue;
173            }
174
175            // Check workspace is clean before starting a new task
176            if status.state == AutonomousState::Idle
177                || status.state == AutonomousState::Succeeded
178                || status.state == AutonomousState::Failed
179            {
180                let workspace_changed = workspace_has_changes(&workspace).await;
181                if workspace_changed {
182                    tracing::info!("[autonomous] workspace has changes, stashing before new task");
183                    git_stash(&workspace).await;
184                }
185            }
186
187            status.state = AutonomousState::Running;
188            status.current_task = Some("processing TODO.md".into());
189            Self::save_status_to_file(&status, &self.status_path);
190
191            tracing::info!("[autonomous] running agent on TODO.md");
192            let instruction = format!(
193                "Working autonomously. Task ledger:\n\n{}\n\n\
194                 Read the TODO.md, pick the next pending item, implement it, \
195                 and validate with: {}",
196                todo_content, config.test_command
197            );
198
199            let null_ch = NullChannel::new("autonomous");
200            match agent
201                .handle_message(
202                    &IncomingMessage {
203                        id: uuid::Uuid::new_v4().to_string(),
204                        sender_id: "autonomous".into(),
205                        sender_name: Some("Autonomous".into()),
206                        chat_id: "autonomous".into(),
207                        text: instruction,
208                        is_group: false,
209                        reply_to: None,
210                        timestamp: chrono::Utc::now(),
211                    },
212                    &null_ch,
213                )
214                .await
215            {
216                Ok(response) => {
217                    status.last_result =
218                        Some(response.chars().take(500).collect::<String>() + "...");
219                    status.last_run = Some(chrono::Utc::now().to_rfc3339());
220
221                    // Run validation tests
222                    if !config.test_command.is_empty() {
223                        match run_test_command(&config.test_command, &workspace).await {
224                            Ok(true) => {
225                                tracing::info!("[autonomous] tests passed");
226                                status.consecutive_failures = 0;
227                                status.state = AutonomousState::Succeeded;
228
229                                // Git commit + push
230                                if !config.git_remote.is_empty() {
231                                    git_commit_and_push(
232                                        "autonomous: auto-commit",
233                                        &config.git_remote,
234                                        &config.git_branch,
235                                        &workspace,
236                                    )
237                                    .await;
238                                }
239                            }
240                            Ok(false) => {
241                                tracing::warn!("[autonomous] tests failed");
242                                status.consecutive_failures += 1;
243                                status.state = AutonomousState::Failed;
244
245                                if status.consecutive_failures >= 3 {
246                                    status.paused = true;
247                                    status.state = AutonomousState::Paused;
248                                    tracing::warn!(
249                                        "[autonomous] paused after {} consecutive failures",
250                                        status.consecutive_failures
251                                    );
252                                }
253                            }
254                            Err(e) => {
255                                tracing::error!("[autonomous] test error: {}", e);
256                                status.state = AutonomousState::Failed;
257                            }
258                        }
259                    } else {
260                        status.state = AutonomousState::Succeeded;
261                    }
262                }
263                Err(e) => {
264                    tracing::error!("[autonomous] agent error: {}", e);
265                    status.state = AutonomousState::Failed;
266                    status.consecutive_failures += 1;
267
268                    if status.consecutive_failures >= 3 {
269                        status.paused = true;
270                        tracing::warn!(
271                            "[autonomous] paused after {} consecutive failures",
272                            status.consecutive_failures
273                        );
274                    }
275                }
276            }
277
278            Self::save_status_to_file(&status, &self.status_path);
279        }
280    }
281}
282
283/// Run a test command, return true if it passes.
284async fn run_test_command(cmd: &str, cwd: &Path) -> anyhow::Result<bool> {
285    tracing::info!("[autonomous] running tests: {}", cmd);
286    let output = tokio::process::Command::new("sh")
287        .arg("-c")
288        .arg(cmd)
289        .current_dir(cwd)
290        .output()
291        .await?;
292
293    if output.status.success() {
294        Ok(true)
295    } else {
296        let stderr = String::from_utf8_lossy(&output.stderr);
297        tracing::warn!(
298            "[autonomous] tests failed:\n{}",
299            crate::text::truncate_chars(&stderr, 1000)
300        );
301        Ok(false)
302    }
303}
304
305/// Check if workspace has uncommitted changes.
306async fn workspace_has_changes(workspace: &Path) -> bool {
307    let output = tokio::process::Command::new("git")
308        .args(["status", "--porcelain"])
309        .current_dir(workspace)
310        .output()
311        .await;
312
313    match output {
314        Ok(out) => !out.stdout.is_empty(),
315        Err(_) => false,
316    }
317}
318
319/// Stash workspace changes.
320async fn git_stash(workspace: &Path) {
321    let _ = tokio::process::Command::new("git")
322        .args(["stash", "push", "-m", "autonomous: pre-task stash"])
323        .current_dir(workspace)
324        .output()
325        .await;
326}
327
328/// Commit all changes and push.
329async fn git_commit_and_push(message: &str, remote: &str, branch: &str, cwd: &Path) {
330    let _ = tokio::process::Command::new("git")
331        .args(["add", "-A"])
332        .current_dir(cwd)
333        .output()
334        .await;
335
336    let _ = tokio::process::Command::new("git")
337        .args(["commit", "-m", message])
338        .current_dir(cwd)
339        .output()
340        .await;
341
342    tracing::info!("[autonomous] pushing to {}/{}", remote, branch);
343    if let Ok(output) = tokio::process::Command::new("git")
344        .args(["push", remote, branch])
345        .current_dir(cwd)
346        .output()
347        .await
348    {
349        if output.status.success() {
350            tracing::info!("[autonomous] push successful");
351        } else {
352            let stderr = String::from_utf8_lossy(&output.stderr);
353            tracing::warn!(
354                "[autonomous] push failed: {}",
355                crate::text::truncate_chars(&stderr, 500)
356            );
357        }
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364    use tempfile::tempdir;
365
366    #[test]
367    fn test_status_persistence() {
368        let dir = tempdir().unwrap();
369        let config = AutonomousConfig {
370            status_path: "test-status.toml".into(),
371            ..AutonomousConfig::default()
372        };
373        let aloop = AutonomousLoop::new(config, dir.path().to_path_buf());
374        assert_eq!(aloop.status.state, AutonomousState::Idle);
375
376        // Save should create file
377        AutonomousLoop::save_status_to_file(&aloop.status, &dir.path().join("test-status.toml"));
378        let path = dir.path().join("test-status.toml");
379        assert!(path.exists());
380
381        // Test again
382        AutonomousLoop::save_status_to_file(&aloop.status, &dir.path().join("test-status.toml"));
383        assert!(path.exists());
384    }
385
386    #[test]
387    fn test_status_roundtrip() {
388        let dir = tempdir().unwrap();
389        let config = AutonomousConfig {
390            status_path: "test-status.toml".into(),
391            ..AutonomousConfig::default()
392        };
393
394        // First creation
395        let aloop = AutonomousLoop::new(config.clone(), dir.path().to_path_buf());
396        AutonomousLoop::save_status_to_file(&aloop.status, &dir.path().join("test-status.toml"));
397
398        // Second creation should load saved state
399        let aloop2 = AutonomousLoop::new(config, dir.path().to_path_buf());
400        assert_eq!(aloop2.status.state, aloop.status.state);
401    }
402}