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