1use std::path::{Path, PathBuf};
13use std::time::Duration;
14
15use serde::{Deserialize, Serialize};
16
17use crate::agent::NullChannel;
18use crate::channels::IncomingMessage;
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct AutonomousConfig {
23 pub interval_secs: u64,
25 pub todo_path: String,
27 pub status_path: String,
29 pub test_command: String,
31 pub git_remote: String,
33 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#[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#[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
85pub struct AutonomousLoop {
87 config: AutonomousConfig,
88 workspace: PathBuf,
89 status: AutonomousStatus,
90 status_path: PathBuf,
91}
92
93impl AutonomousLoop {
94 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 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 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 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 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 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
282fn 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
303fn 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
316fn 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
324fn 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 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 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 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 let aloop2 = AutonomousLoop::new(config, dir.path().to_path_buf());
393 assert_eq!(aloop2.status.state, aloop.status.state);
394 }
395}