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