Skip to main content

skilllite_agent/
run_checkpoint.rs

1//! A13: Run mode checkpoint — save/restore state for long-running tasks.
2//!
3//! Enables `skilllite run --resume` to continue from where a previous run left off.
4
5use anyhow::Result;
6use serde::{Deserialize, Serialize};
7use std::path::Path;
8
9use crate::types::{ChatMessage, Task};
10
11/// Checkpoint state for run mode. Persisted to ~/.skilllite/chat/run_checkpoints/latest.json
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct RunCheckpoint {
14    pub run_id: String,
15    pub goal: String,
16    pub workspace: String,
17    pub task_plan: Vec<Task>,
18    pub messages: Vec<ChatMessage>,
19    pub updated_at: String,
20}
21
22impl RunCheckpoint {
23    pub fn new(
24        goal: String,
25        workspace: String,
26        task_plan: Vec<Task>,
27        messages: Vec<ChatMessage>,
28    ) -> Self {
29        Self {
30            run_id: uuid::Uuid::new_v4().to_string(),
31            goal,
32            workspace,
33            task_plan,
34            messages,
35            updated_at: chrono::Utc::now().to_rfc3339(),
36        }
37    }
38
39    /// Update with new state (preserves run_id).
40    pub fn update(&mut self, task_plan: Vec<Task>, messages: Vec<ChatMessage>) {
41        self.task_plan = task_plan;
42        self.messages = messages;
43        self.updated_at = chrono::Utc::now().to_rfc3339();
44    }
45}
46
47const CHECKPOINT_DIR: &str = "run_checkpoints";
48const CHECKPOINT_FILE: &str = "latest.json";
49
50/// Save checkpoint to chat_root/run_checkpoints/latest.json.
51pub fn save_checkpoint(chat_root: &Path, checkpoint: &RunCheckpoint) -> Result<()> {
52    let dir = chat_root.join(CHECKPOINT_DIR);
53    skilllite_fs::create_dir_all(&dir)?;
54    let path = dir.join(CHECKPOINT_FILE);
55    let content = serde_json::to_string_pretty(checkpoint)?;
56    skilllite_fs::atomic_write(&path, &content)?;
57    tracing::debug!("Run checkpoint saved to {}", path.display());
58    Ok(())
59}
60
61/// Load checkpoint from chat_root/run_checkpoints/latest.json.
62/// Returns None if no checkpoint exists or file is invalid.
63pub fn load_checkpoint(chat_root: &Path) -> Result<Option<RunCheckpoint>> {
64    let path = chat_root.join(CHECKPOINT_DIR).join(CHECKPOINT_FILE);
65    if !path.exists() {
66        return Ok(None);
67    }
68    let content = skilllite_fs::read_file(&path)?;
69    let checkpoint: RunCheckpoint = serde_json::from_str(&content)?;
70    Ok(Some(checkpoint))
71}
72
73/// Build continuation message for resume. Injects context so the agent continues from checkpoint.
74pub fn build_resume_message(checkpoint: &RunCheckpoint) -> String {
75    let completed: Vec<String> = checkpoint
76        .task_plan
77        .iter()
78        .filter(|t| t.completed)
79        .map(|t| format!("  - [完成] {}", t.description))
80        .collect();
81    let remaining: Vec<String> = checkpoint
82        .task_plan
83        .iter()
84        .filter(|t| !t.completed)
85        .map(|t| format!("  - [待做] {}", t.description))
86        .collect();
87
88    let msg = format!(
89        "[断点续跑] 继续执行以下目标:\n\n{}\n\n已完成:\n{}\n\n待完成:\n{}\n\n请从下一个待完成任务继续执行。",
90        checkpoint.goal,
91        if completed.is_empty() {
92            "  (无)".to_string()
93        } else {
94            completed.join("\n")
95        },
96        if remaining.is_empty() {
97            "  (无 — 请确认目标是否已完成)".to_string()
98        } else {
99            remaining.join("\n")
100        }
101    );
102    msg
103}