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);
181                if workspace_changed {
182                    tracing::info!("[autonomous] workspace has changes, stashing before new task");
183                    git_stash(&workspace);
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) {
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                                }
238                            }
239                            Ok(false) => {
240                                tracing::warn!("[autonomous] tests failed");
241                                status.consecutive_failures += 1;
242                                status.state = AutonomousState::Failed;
243
244                                if status.consecutive_failures >= 3 {
245                                    status.paused = true;
246                                    status.state = AutonomousState::Paused;
247                                    tracing::warn!(
248                                        "[autonomous] paused after {} consecutive failures",
249                                        status.consecutive_failures
250                                    );
251                                }
252                            }
253                            Err(e) => {
254                                tracing::error!("[autonomous] test error: {}", e);
255                                status.state = AutonomousState::Failed;
256                            }
257                        }
258                    } else {
259                        status.state = AutonomousState::Succeeded;
260                    }
261                }
262                Err(e) => {
263                    tracing::error!("[autonomous] agent error: {}", e);
264                    status.state = AutonomousState::Failed;
265                    status.consecutive_failures += 1;
266
267                    if status.consecutive_failures >= 3 {
268                        status.paused = true;
269                        tracing::warn!(
270                            "[autonomous] paused after {} consecutive failures",
271                            status.consecutive_failures
272                        );
273                    }
274                }
275            }
276
277            Self::save_status_to_file(&status, &self.status_path);
278        }
279    }
280}
281
282/// Run a test command, return true if it passes.
283fn run_test_command(cmd: &str, cwd: &Path) -> anyhow::Result<bool> {
284    tracing::info!("[autonomous] running tests: {}", cmd);
285    let output = std::process::Command::new("sh")
286        .arg("-c")
287        .arg(cmd)
288        .current_dir(cwd)
289        .output()?;
290
291    if output.status.success() {
292        Ok(true)
293    } else {
294        let stderr = String::from_utf8_lossy(&output.stderr);
295        tracing::warn!(
296            "[autonomous] tests failed:\n{}",
297            &stderr[..stderr.len().min(1000)]
298        );
299        Ok(false)
300    }
301}
302
303/// Check if workspace has uncommitted changes.
304fn workspace_has_changes(workspace: &Path) -> bool {
305    let output = std::process::Command::new("git")
306        .args(["status", "--porcelain"])
307        .current_dir(workspace)
308        .output();
309
310    match output {
311        Ok(out) => !out.stdout.is_empty(),
312        Err(_) => false,
313    }
314}
315
316/// Stash workspace changes.
317fn git_stash(workspace: &Path) {
318    let _ = std::process::Command::new("git")
319        .args(["stash", "push", "-m", "autonomous: pre-task stash"])
320        .current_dir(workspace)
321        .output();
322}
323
324/// Commit all changes and push.
325fn git_commit_and_push(message: &str, remote: &str, branch: &str, cwd: &Path) {
326    let _ = std::process::Command::new("git")
327        .args(["add", "-A"])
328        .current_dir(cwd)
329        .output();
330
331    let _ = std::process::Command::new("git")
332        .args(["commit", "-m", message])
333        .current_dir(cwd)
334        .output();
335
336    tracing::info!("[autonomous] pushing to {}/{}", remote, branch);
337    if let Ok(output) = std::process::Command::new("git")
338        .args(["push", remote, branch])
339        .current_dir(cwd)
340        .output()
341    {
342        if output.status.success() {
343            tracing::info!("[autonomous] push successful");
344        } else {
345            let stderr = String::from_utf8_lossy(&output.stderr);
346            tracing::warn!(
347                "[autonomous] push failed: {}",
348                &stderr[..stderr.len().min(500)]
349            );
350        }
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use tempfile::tempdir;
358
359    #[test]
360    fn test_status_persistence() {
361        let dir = tempdir().unwrap();
362        let config = AutonomousConfig {
363            status_path: "test-status.toml".into(),
364            ..AutonomousConfig::default()
365        };
366        let aloop = AutonomousLoop::new(config, dir.path().to_path_buf());
367        assert_eq!(aloop.status.state, AutonomousState::Idle);
368
369        // Save should create file
370        AutonomousLoop::save_status_to_file(&aloop.status, &dir.path().join("test-status.toml"));
371        let path = dir.path().join("test-status.toml");
372        assert!(path.exists());
373
374        // Test again
375        AutonomousLoop::save_status_to_file(&aloop.status, &dir.path().join("test-status.toml"));
376        assert!(path.exists());
377    }
378
379    #[test]
380    fn test_status_roundtrip() {
381        let dir = tempdir().unwrap();
382        let config = AutonomousConfig {
383            status_path: "test-status.toml".into(),
384            ..AutonomousConfig::default()
385        };
386
387        // First creation
388        let aloop = AutonomousLoop::new(config.clone(), dir.path().to_path_buf());
389        AutonomousLoop::save_status_to_file(&aloop.status, &dir.path().join("test-status.toml"));
390
391        // Second creation should load saved state
392        let aloop2 = AutonomousLoop::new(config, dir.path().to_path_buf());
393        assert_eq!(aloop2.status.state, aloop.status.state);
394    }
395}