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).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 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 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
283async 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
305async 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
319async 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
328async 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 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 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 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 let aloop2 = AutonomousLoop::new(config, dir.path().to_path_buf());
400 assert_eq!(aloop2.status.state, aloop.status.state);
401 }
402}