Skip to main content

batty_cli/team/
checkpoint.rs

1//! Progress checkpoint files for agent restart context.
2//!
3//! Before restarting a stalled or context-exhausted agent, the daemon writes a
4//! checkpoint file to `.batty/progress/<role>.md` containing the agent's current
5//! task context. This file is included in the restart prompt so the agent can
6//! resume with full awareness of what it was doing.
7
8use anyhow::Result;
9use std::path::{Path, PathBuf};
10
11/// Information captured in a progress checkpoint.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct Checkpoint {
14    pub role: String,
15    pub task_id: u32,
16    pub task_title: String,
17    pub task_description: String,
18    pub branch: Option<String>,
19    pub last_commit: Option<String>,
20    pub test_summary: Option<String>,
21    pub timestamp: String,
22}
23
24/// Returns the progress directory path: `<project_root>/.batty/progress/`.
25pub fn progress_dir(project_root: &Path) -> PathBuf {
26    project_root.join(".batty").join("progress")
27}
28
29/// Returns the checkpoint file path for a given role.
30pub fn checkpoint_path(project_root: &Path, role: &str) -> PathBuf {
31    progress_dir(project_root).join(format!("{role}.md"))
32}
33
34/// Write a progress checkpoint file for the given role.
35///
36/// Creates the `.batty/progress/` directory if it doesn't exist.
37pub fn write_checkpoint(project_root: &Path, checkpoint: &Checkpoint) -> Result<()> {
38    let dir = progress_dir(project_root);
39    std::fs::create_dir_all(&dir)?;
40    let path = dir.join(format!("{}.md", checkpoint.role));
41    let content = format_checkpoint(checkpoint);
42    std::fs::write(&path, content)?;
43    Ok(())
44}
45
46/// Read a checkpoint file for the given role, if it exists.
47pub fn read_checkpoint(project_root: &Path, role: &str) -> Option<String> {
48    let path = checkpoint_path(project_root, role);
49    std::fs::read_to_string(&path).ok()
50}
51
52/// Remove the checkpoint file for the given role. No-op if it doesn't exist.
53pub fn remove_checkpoint(project_root: &Path, role: &str) {
54    let path = checkpoint_path(project_root, role);
55    let _ = std::fs::remove_file(&path);
56}
57
58/// Gather checkpoint information from the worktree and task.
59pub fn gather_checkpoint(project_root: &Path, role: &str, task: &crate::task::Task) -> Checkpoint {
60    let worktree_dir = project_root.join(".batty").join("worktrees").join(role);
61
62    let branch = task
63        .branch
64        .clone()
65        .or_else(|| git_current_branch(&worktree_dir));
66
67    let last_commit = git_last_commit(&worktree_dir);
68    let test_summary = last_test_output(&worktree_dir);
69
70    let timestamp = chrono_timestamp();
71
72    Checkpoint {
73        role: role.to_string(),
74        task_id: task.id,
75        task_title: task.title.clone(),
76        task_description: task.description.clone(),
77        branch,
78        last_commit,
79        test_summary,
80        timestamp,
81    }
82}
83
84/// Format a checkpoint as Markdown content.
85fn format_checkpoint(cp: &Checkpoint) -> String {
86    let mut out = String::new();
87    out.push_str(&format!("# Progress Checkpoint: {}\n\n", cp.role));
88    out.push_str(&format!(
89        "**Task:** #{} — {}\n\n",
90        cp.task_id, cp.task_title
91    ));
92    out.push_str(&format!("**Timestamp:** {}\n\n", cp.timestamp));
93
94    if let Some(branch) = &cp.branch {
95        out.push_str(&format!("**Branch:** {branch}\n\n"));
96    }
97
98    if let Some(commit) = &cp.last_commit {
99        out.push_str(&format!("**Last commit:** {commit}\n\n"));
100    }
101
102    out.push_str("## Task Description\n\n");
103    out.push_str(&cp.task_description);
104    out.push('\n');
105
106    if let Some(tests) = &cp.test_summary {
107        out.push_str("\n## Last Test Output\n\n");
108        out.push_str(tests);
109        out.push('\n');
110    }
111
112    out
113}
114
115/// Get the current branch name in a worktree directory.
116fn git_current_branch(worktree_dir: &Path) -> Option<String> {
117    if !worktree_dir.exists() {
118        return None;
119    }
120    let output = std::process::Command::new("git")
121        .args(["rev-parse", "--abbrev-ref", "HEAD"])
122        .current_dir(worktree_dir)
123        .output()
124        .ok()?;
125    if output.status.success() {
126        let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
127        if branch.is_empty() || branch == "HEAD" {
128            None
129        } else {
130            Some(branch)
131        }
132    } else {
133        None
134    }
135}
136
137/// Get the last commit hash and message in a worktree directory.
138fn git_last_commit(worktree_dir: &Path) -> Option<String> {
139    if !worktree_dir.exists() {
140        return None;
141    }
142    let output = std::process::Command::new("git")
143        .args(["log", "-1", "--oneline"])
144        .current_dir(worktree_dir)
145        .output()
146        .ok()?;
147    if output.status.success() {
148        let line = String::from_utf8_lossy(&output.stdout).trim().to_string();
149        if line.is_empty() { None } else { Some(line) }
150    } else {
151        None
152    }
153}
154
155/// Try to read the last cargo test output from common locations.
156/// Returns None if no recent test output is found.
157fn last_test_output(worktree_dir: &Path) -> Option<String> {
158    // Check for a batty-managed test output file
159    let test_output_path = worktree_dir.join(".batty_test_output");
160    if test_output_path.exists() {
161        if let Ok(content) = std::fs::read_to_string(&test_output_path) {
162            if !content.is_empty() {
163                // Truncate to last 50 lines to keep checkpoint manageable
164                let lines: Vec<&str> = content.lines().collect();
165                let start = lines.len().saturating_sub(50);
166                return Some(lines[start..].join("\n"));
167            }
168        }
169    }
170    None
171}
172
173/// Generate an ISO-8601 timestamp string.
174fn chrono_timestamp() -> String {
175    use std::time::SystemTime;
176    let now = SystemTime::now()
177        .duration_since(SystemTime::UNIX_EPOCH)
178        .unwrap_or_default();
179    // Format as a simple UTC timestamp
180    let secs = now.as_secs();
181    let hours = (secs / 3600) % 24;
182    let minutes = (secs / 60) % 60;
183    let seconds = secs % 60;
184    let days_since_epoch = secs / 86400;
185    // Simple date calculation from epoch days
186    let (year, month, day) = epoch_days_to_date(days_since_epoch);
187    format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
188}
189
190/// Convert days since Unix epoch to (year, month, day).
191fn epoch_days_to_date(days: u64) -> (u64, u64, u64) {
192    // Algorithm from https://howardhinnant.github.io/date_algorithms.html
193    let z = days + 719468;
194    let era = z / 146097;
195    let doe = z - era * 146097;
196    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
197    let y = yoe + era * 400;
198    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
199    let mp = (5 * doy + 2) / 153;
200    let d = doy - (153 * mp + 2) / 5 + 1;
201    let m = if mp < 10 { mp + 3 } else { mp - 9 };
202    let y = if m <= 2 { y + 1 } else { y };
203    (y, m, d)
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use std::fs;
210
211    fn make_task(id: u32, title: &str, description: &str) -> crate::task::Task {
212        crate::task::Task {
213            id,
214            title: title.to_string(),
215            status: "in-progress".to_string(),
216            priority: "high".to_string(),
217            claimed_by: None,
218            blocked: None,
219            tags: vec![],
220            depends_on: vec![],
221            review_owner: None,
222            blocked_on: None,
223            worktree_path: None,
224            branch: Some("eng-1-2/42".to_string()),
225            commit: None,
226            artifacts: vec![],
227            next_action: None,
228            scheduled_for: None,
229            cron_schedule: None,
230            cron_last_run: None,
231            completed: None,
232            description: description.to_string(),
233            batty_config: None,
234            source_path: PathBuf::from("/tmp/fake.md"),
235        }
236    }
237
238    #[test]
239    fn write_and_read_checkpoint() {
240        let tmp = tempfile::tempdir().unwrap();
241        let root = tmp.path();
242        let cp = Checkpoint {
243            role: "eng-1-1".to_string(),
244            task_id: 42,
245            task_title: "Fix the widget".to_string(),
246            task_description: "Widget is broken, needs fixing.".to_string(),
247            branch: Some("eng-1-1/42".to_string()),
248            last_commit: Some("abc1234 fix widget rendering".to_string()),
249            test_summary: Some("test result: ok. 10 passed".to_string()),
250            timestamp: "2026-03-22T10:00:00Z".to_string(),
251        };
252
253        write_checkpoint(root, &cp).unwrap();
254
255        let content = read_checkpoint(root, "eng-1-1").unwrap();
256        assert!(content.contains("# Progress Checkpoint: eng-1-1"));
257        assert!(content.contains("**Task:** #42 — Fix the widget"));
258        assert!(content.contains("**Branch:** eng-1-1/42"));
259        assert!(content.contains("**Last commit:** abc1234 fix widget rendering"));
260        assert!(content.contains("Widget is broken, needs fixing."));
261        assert!(content.contains("test result: ok. 10 passed"));
262        assert!(content.contains("**Timestamp:** 2026-03-22T10:00:00Z"));
263    }
264
265    #[test]
266    fn read_checkpoint_returns_none_when_missing() {
267        let tmp = tempfile::tempdir().unwrap();
268        assert!(read_checkpoint(tmp.path(), "eng-nonexistent").is_none());
269    }
270
271    #[test]
272    fn remove_checkpoint_deletes_file() {
273        let tmp = tempfile::tempdir().unwrap();
274        let root = tmp.path();
275        let cp = Checkpoint {
276            role: "eng-1-1".to_string(),
277            task_id: 1,
278            task_title: "t".to_string(),
279            task_description: "d".to_string(),
280            branch: None,
281            last_commit: None,
282            test_summary: None,
283            timestamp: "2026-01-01T00:00:00Z".to_string(),
284        };
285        write_checkpoint(root, &cp).unwrap();
286        assert!(checkpoint_path(root, "eng-1-1").exists());
287
288        remove_checkpoint(root, "eng-1-1");
289        assert!(!checkpoint_path(root, "eng-1-1").exists());
290    }
291
292    #[test]
293    fn remove_checkpoint_noop_when_missing() {
294        let tmp = tempfile::tempdir().unwrap();
295        // Should not panic
296        remove_checkpoint(tmp.path(), "eng-nonexistent");
297    }
298
299    #[test]
300    fn checkpoint_creates_progress_directory() {
301        let tmp = tempfile::tempdir().unwrap();
302        let root = tmp.path();
303        let dir = progress_dir(root);
304        assert!(!dir.exists());
305
306        let cp = Checkpoint {
307            role: "eng-1-1".to_string(),
308            task_id: 1,
309            task_title: "t".to_string(),
310            task_description: "d".to_string(),
311            branch: None,
312            last_commit: None,
313            test_summary: None,
314            timestamp: "2026-01-01T00:00:00Z".to_string(),
315        };
316        write_checkpoint(root, &cp).unwrap();
317        assert!(dir.exists());
318    }
319
320    #[test]
321    fn format_checkpoint_without_optional_fields() {
322        let cp = Checkpoint {
323            role: "eng-1-1".to_string(),
324            task_id: 99,
325            task_title: "Minimal task".to_string(),
326            task_description: "Do the thing.".to_string(),
327            branch: None,
328            last_commit: None,
329            test_summary: None,
330            timestamp: "2026-03-22T12:00:00Z".to_string(),
331        };
332        let content = format_checkpoint(&cp);
333        assert!(content.contains("# Progress Checkpoint: eng-1-1"));
334        assert!(content.contains("**Task:** #99 — Minimal task"));
335        assert!(!content.contains("**Branch:**"));
336        assert!(!content.contains("**Last commit:**"));
337        assert!(!content.contains("## Last Test Output"));
338    }
339
340    #[test]
341    fn gather_checkpoint_uses_task_branch() {
342        let tmp = tempfile::tempdir().unwrap();
343        let task = make_task(42, "Test task", "Test description");
344        let cp = gather_checkpoint(tmp.path(), "eng-1-2", &task);
345        assert_eq!(cp.task_id, 42);
346        assert_eq!(cp.task_title, "Test task");
347        assert_eq!(cp.task_description, "Test description");
348        assert_eq!(cp.branch, Some("eng-1-2/42".to_string()));
349        assert_eq!(cp.role, "eng-1-2");
350        assert!(!cp.timestamp.is_empty());
351    }
352
353    #[test]
354    fn last_test_output_reads_batty_test_file() {
355        let tmp = tempfile::tempdir().unwrap();
356        let worktree = tmp.path();
357        let test_file = worktree.join(".batty_test_output");
358        fs::write(&test_file, "test result: ok. 5 passed; 0 failed").unwrap();
359
360        let summary = last_test_output(worktree);
361        assert!(summary.is_some());
362        assert!(summary.unwrap().contains("5 passed"));
363    }
364
365    #[test]
366    fn last_test_output_returns_none_when_no_file() {
367        let tmp = tempfile::tempdir().unwrap();
368        assert!(last_test_output(tmp.path()).is_none());
369    }
370
371    #[test]
372    fn last_test_output_truncates_long_output() {
373        let tmp = tempfile::tempdir().unwrap();
374        let test_file = tmp.path().join(".batty_test_output");
375        // Write 100 lines — should truncate to last 50
376        let lines: Vec<String> = (0..100).map(|i| format!("line {i}")).collect();
377        fs::write(&test_file, lines.join("\n")).unwrap();
378
379        let summary = last_test_output(tmp.path()).unwrap();
380        let result_lines: Vec<&str> = summary.lines().collect();
381        assert_eq!(result_lines.len(), 50);
382        assert!(result_lines[0].contains("line 50"));
383        assert!(result_lines[49].contains("line 99"));
384    }
385
386    #[test]
387    fn epoch_days_to_date_known_values() {
388        // 2026-03-22 is day 20534 from epoch (1970-01-01)
389        let (y, m, d) = epoch_days_to_date(0);
390        assert_eq!((y, m, d), (1970, 1, 1));
391
392        // 2000-01-01 = day 10957
393        let (y, m, d) = epoch_days_to_date(10957);
394        assert_eq!((y, m, d), (2000, 1, 1));
395    }
396
397    #[test]
398    fn checkpoint_path_correct() {
399        let root = Path::new("/project");
400        assert_eq!(
401            checkpoint_path(root, "eng-1-1"),
402            PathBuf::from("/project/.batty/progress/eng-1-1.md")
403        );
404    }
405
406    #[test]
407    fn overwrite_existing_checkpoint() {
408        let tmp = tempfile::tempdir().unwrap();
409        let root = tmp.path();
410
411        let cp1 = Checkpoint {
412            role: "eng-1-1".to_string(),
413            task_id: 1,
414            task_title: "First".to_string(),
415            task_description: "First task".to_string(),
416            branch: None,
417            last_commit: None,
418            test_summary: None,
419            timestamp: "2026-01-01T00:00:00Z".to_string(),
420        };
421        write_checkpoint(root, &cp1).unwrap();
422
423        let cp2 = Checkpoint {
424            role: "eng-1-1".to_string(),
425            task_id: 2,
426            task_title: "Second".to_string(),
427            task_description: "Second task".to_string(),
428            branch: Some("eng-1-1/2".to_string()),
429            last_commit: None,
430            test_summary: None,
431            timestamp: "2026-01-02T00:00:00Z".to_string(),
432        };
433        write_checkpoint(root, &cp2).unwrap();
434
435        let content = read_checkpoint(root, "eng-1-1").unwrap();
436        assert!(content.contains("**Task:** #2 — Second"));
437        assert!(!content.contains("First"));
438    }
439
440    // --- Error path and recovery tests (Task #265) ---
441
442    #[test]
443    fn write_checkpoint_to_readonly_dir_fails() {
444        #[cfg(unix)]
445        {
446            use std::os::unix::fs::PermissionsExt;
447            let tmp = tempfile::tempdir().unwrap();
448            let readonly = tmp.path().join("readonly_root");
449            fs::create_dir(&readonly).unwrap();
450            // Create .batty dir but make it readonly
451            let batty_dir = readonly.join(".batty");
452            fs::create_dir(&batty_dir).unwrap();
453            fs::set_permissions(&batty_dir, fs::Permissions::from_mode(0o444)).unwrap();
454
455            let cp = Checkpoint {
456                role: "eng-1-1".to_string(),
457                task_id: 1,
458                task_title: "t".to_string(),
459                task_description: "d".to_string(),
460                branch: None,
461                last_commit: None,
462                test_summary: None,
463                timestamp: "2026-01-01T00:00:00Z".to_string(),
464            };
465            let result = write_checkpoint(&readonly, &cp);
466            assert!(result.is_err());
467
468            // Restore permissions for cleanup
469            fs::set_permissions(&batty_dir, fs::Permissions::from_mode(0o755)).unwrap();
470        }
471    }
472
473    #[test]
474    fn git_current_branch_returns_none_for_nonexistent_dir() {
475        let result = git_current_branch(Path::new("/tmp/__batty_no_dir_here__"));
476        assert!(result.is_none());
477    }
478
479    #[test]
480    fn git_current_branch_returns_none_for_non_git_dir() {
481        let tmp = tempfile::tempdir().unwrap();
482        let result = git_current_branch(tmp.path());
483        assert!(result.is_none());
484    }
485
486    #[test]
487    fn git_last_commit_returns_none_for_nonexistent_dir() {
488        let result = git_last_commit(Path::new("/tmp/__batty_no_dir_here__"));
489        assert!(result.is_none());
490    }
491
492    #[test]
493    fn git_last_commit_returns_none_for_non_git_dir() {
494        let tmp = tempfile::tempdir().unwrap();
495        let result = git_last_commit(tmp.path());
496        assert!(result.is_none());
497    }
498
499    #[test]
500    fn last_test_output_returns_none_for_empty_file() {
501        let tmp = tempfile::tempdir().unwrap();
502        let test_file = tmp.path().join(".batty_test_output");
503        fs::write(&test_file, "").unwrap();
504        assert!(last_test_output(tmp.path()).is_none());
505    }
506
507    #[test]
508    fn chrono_timestamp_returns_valid_format() {
509        let ts = chrono_timestamp();
510        // Should match ISO-8601 pattern: YYYY-MM-DDTHH:MM:SSZ
511        assert!(ts.ends_with('Z'));
512        assert!(ts.contains('T'));
513        assert_eq!(ts.len(), 20); // "2026-03-22T10:00:00Z" is 20 chars
514    }
515}