Skip to main content

vtcode_session_store/
progress.rs

1//! Durable, compaction-safe progress ledger.
2//!
3//! Long-horizon agent capability requires a persistent signal of *goal
4//! progress* that survives compaction, fork, and resume. The live conversation
5//! is never reloaded into context from disk, but the progress ledger is a tiny
6//! derived artifact (like `manifest.json`) that the harness can read on each
7//! turn to decide whether work is actually advancing toward completion.
8//!
9//! The ledger is stored under `<session_dir>/derived/progress.json` and
10//! overwritten on each update — it is a single mutable summary, not an
11//! append-only log, which keeps reads O(1) and cheap.
12
13use chrono::Utc;
14use serde::{Deserialize, Serialize};
15use std::path::Path;
16
17use crate::error::SessionStoreError;
18use crate::session_dir;
19
20/// Lifecycle status of a single milestone toward the session goal.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum MilestoneStatus {
24    /// Not yet started.
25    Pending,
26    /// Actively being worked on this/last turn.
27    InProgress,
28    /// Completed and verified.
29    Done,
30    /// Blocked — cannot proceed without external input or a replan.
31    Blocked,
32}
33
34impl MilestoneStatus {
35    /// Whether this status counts as forward progress toward completion.
36    #[must_use]
37    pub fn is_terminal(&self) -> bool {
38        matches!(self, MilestoneStatus::Done)
39    }
40}
41
42/// A single tracked milestone derived from the task tracker / plan.
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44pub struct Milestone {
45    /// Stable identifier (e.g. tracker item index or plan item id).
46    pub id: String,
47    /// Human-readable description.
48    pub description: String,
49    /// Current status.
50    pub status: MilestoneStatus,
51}
52
53/// Compact, durable progress signal for one session.
54///
55/// This is the harness's externalized memory of "are we getting closer to
56/// done?" It is intentionally small so it can be loaded every turn without
57/// touching the event log. Includes handoff metadata so cross-session
58/// continuity is explicit in the ledger itself.
59#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
60pub struct ProgressLedger {
61    /// Owning session id.
62    pub session_id: String,
63    /// The objective the agent is pursuing.
64    pub goal: String,
65    /// Tracked milestones; empty when the agent has no explicit tracker.
66    pub milestones: Vec<Milestone>,
67    /// Agent's confidence in eventual completion, `0.0..=1.0`.
68    pub confidence: f32,
69    /// RFC3339 timestamp of the first turn where no forward progress was
70    /// detected, or `None` if progress is currently being made.
71    pub stalled_since: Option<String>,
72    /// RFC3339 timestamp of the last ledger update.
73    pub updated_at: String,
74    /// The session id of the predecessor session that handed off to this one.
75    /// `None` for the first session in a chain.
76    #[serde(default)]
77    pub previous_session_id: Option<String>,
78    /// Summary communicated by the previous session at handoff time.
79    #[serde(default)]
80    pub handoff_summary: Option<String>,
81    /// Issues carried forward from the previous session.
82    #[serde(default)]
83    pub known_issues: Vec<String>,
84    /// Git commit hash at the time of handoff (the "checkpoint").
85    #[serde(default)]
86    pub git_checkpoint: Option<String>,
87}
88
89impl ProgressLedger {
90    /// Create a fresh ledger for a session with an initial goal.
91    #[must_use]
92    pub fn new(session_id: &str, goal: &str) -> Self {
93        let ts = Utc::now().to_rfc3339();
94        Self {
95            session_id: session_id.to_string(),
96            goal: goal.to_string(),
97            milestones: Vec::new(),
98            confidence: 1.0,
99            stalled_since: None,
100            updated_at: ts,
101            previous_session_id: None,
102            handoff_summary: None,
103            known_issues: Vec::new(),
104            git_checkpoint: None,
105        }
106    }
107
108    /// Fraction of milestones in a terminal (`Done`) state, `0.0..=1.0`.
109    /// Returns `1.0` when there are no milestones (nothing tracked yet).
110    #[must_use]
111    pub fn completion_ratio(&self) -> f32 {
112        if self.milestones.is_empty() {
113            return 1.0;
114        }
115        let done = self.milestones.iter().filter(|m| m.status.is_terminal()).count() as f32;
116        done / self.milestones.len() as f32
117    }
118
119    /// Whether every tracked milestone is complete (or none are tracked).
120    #[must_use]
121    pub fn is_complete(&self) -> bool {
122        self.completion_ratio() >= 1.0
123    }
124
125    /// Whether the ledger currently reports a stall.
126    #[must_use]
127    pub fn is_stalled(&self) -> bool {
128        self.stalled_since.is_some()
129    }
130
131    /// Record forward progress: clears any stall marker and refreshes the
132    /// timestamp. Confidence is nudged upward (bounded at 1.0).
133    pub fn note_advance(&mut self) {
134        self.stalled_since = None;
135        self.confidence = (self.confidence + 0.05).min(1.0);
136        self.updated_at = Utc::now().to_rfc3339();
137    }
138
139    /// Record a stall: sets `stalled_since` on first occurrence and refreshes
140    /// the timestamp. Confidence is nudged downward (bounded at `0.0`).
141    pub fn note_stall(&mut self) {
142        if self.stalled_since.is_none() {
143            self.stalled_since = Some(Utc::now().to_rfc3339());
144        }
145        self.confidence = (self.confidence - 0.1).max(0.0);
146        self.updated_at = Utc::now().to_rfc3339();
147    }
148
149    /// Replace the milestone set and refresh the timestamp.
150    pub fn set_milestones(&mut self, milestones: Vec<Milestone>) {
151        self.milestones = milestones;
152        self.updated_at = Utc::now().to_rfc3339();
153    }
154
155    /// Set the session goal and refresh the timestamp.
156    pub fn set_goal(&mut self, goal: &str) {
157        self.goal = goal.to_string();
158        self.updated_at = Utc::now().to_rfc3339();
159    }
160
161    /// Record handoff metadata from a previous session.
162    pub fn set_handoff(
163        &mut self,
164        previous_session_id: &str,
165        summary: &str,
166        git_checkpoint: Option<String>,
167    ) {
168        self.previous_session_id = Some(previous_session_id.to_string());
169        self.handoff_summary = Some(summary.to_string());
170        self.git_checkpoint = git_checkpoint;
171        self.updated_at = Utc::now().to_rfc3339();
172    }
173
174    /// Add a known issue carried forward from a previous session.
175    pub fn add_known_issue(&mut self, issue: &str) {
176        self.known_issues.push(issue.to_string());
177        self.updated_at = Utc::now().to_rfc3339();
178    }
179
180    /// Render a compact, human-readable progress summary for durable memory
181    /// (e.g. `<workspace>/memories/progress.md`). Survives compaction and gives
182    /// a resumed session an accurate picture of what is done. Includes handoff
183    /// metadata when present so the next session can orient from this alone.
184    #[must_use]
185    pub fn to_markdown(&self) -> String {
186        let mut out = String::new();
187        out.push_str("# Session Progress\n\n");
188        out.push_str(&format!("**Goal:** {}\n", self.goal));
189        out.push_str(&format!(
190            "**Completion:** {:.0}%\n",
191            (self.completion_ratio() * 100.0).round()
192        ));
193        out.push_str(&format!("**Confidence:** {:.2}\n", self.confidence));
194        if let Some(since) = &self.stalled_since {
195            out.push_str(&format!("**Stalled since:** {since}\n"));
196        }
197        out.push_str(&format!("**Updated:** {}\n\n", self.updated_at));
198
199        if let Some(prev) = &self.previous_session_id {
200            out.push_str(&format!("**Handed off from:** {prev}\n"));
201        }
202        if let Some(summary) = &self.handoff_summary {
203            out.push_str(&format!("**Handoff summary:** {summary}\n"));
204        }
205        if let Some(checkpoint) = &self.git_checkpoint {
206            out.push_str(&format!("**Git checkpoint:** `{checkpoint}`\n"));
207        }
208        if !self.known_issues.is_empty() {
209            out.push_str("\n## Known Issues\n\n");
210            for issue in &self.known_issues {
211                out.push_str(&format!("- {issue}\n"));
212            }
213        }
214
215        if self.milestones.is_empty() {
216            out.push_str("\n_No tracked milestones yet._\n");
217        } else {
218            out.push_str("\n## Milestones\n\n");
219            for m in &self.milestones {
220                let mark = match m.status {
221                    MilestoneStatus::Done => "[x]",
222                    MilestoneStatus::InProgress => "[~]",
223                    MilestoneStatus::Blocked => "[!]",
224                    MilestoneStatus::Pending => "[ ]",
225                };
226                out.push_str(&format!("{} {} — {}\n", mark, m.id, m.description));
227            }
228        }
229        out
230    }
231}
232
233/// Resolve the on-disk path of the progress ledger for a session.
234#[must_use]
235pub fn progress_path(workspace: &Path, session_id: &str) -> std::path::PathBuf {
236    session_dir(workspace, session_id)
237        .join(crate::DERIVED_DIR)
238        .join("progress.json")
239}
240
241/// Load the progress ledger for a session, if one has been persisted.
242///
243/// Returns `Ok(None)` when no ledger file exists yet (a fresh or pre-ledger
244/// session) rather than an error, so callers can treat absence as "no signal".
245pub fn load_progress(
246    workspace: &Path,
247    session_id: &str,
248) -> Result<Option<ProgressLedger>, SessionStoreError> {
249    let path = progress_path(workspace, session_id);
250    if !path.exists() {
251        return Ok(None);
252    }
253    let bytes = std::fs::read(&path).map_err(|e| SessionStoreError::io(path.clone(), e))?;
254    let ledger: ProgressLedger = serde_json::from_slice(&bytes)?;
255    Ok(Some(ledger))
256}
257
258/// Persist the progress ledger for a session, creating `derived/` if needed.
259pub fn save_progress(
260    workspace: &Path,
261    session_id: &str,
262    ledger: &ProgressLedger,
263) -> Result<(), SessionStoreError> {
264    let path = progress_path(workspace, session_id);
265    if let Some(parent) = path.parent() {
266        std::fs::create_dir_all(parent)
267            .map_err(|e| SessionStoreError::CreateDir { path: parent.to_path_buf(), source: e })?;
268    }
269    let bytes = serde_json::to_string_pretty(ledger)?;
270    std::fs::write(&path, bytes).map_err(|e| SessionStoreError::io(path, e))?;
271    Ok(())
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    fn sample_ledger() -> ProgressLedger {
279        let mut l = ProgressLedger::new("s1", "ship the feature");
280        l.set_milestones(vec![
281            Milestone {
282                id: "1".into(),
283                description: "design".into(),
284                status: MilestoneStatus::Done,
285            },
286            Milestone {
287                id: "2".into(),
288                description: "implement".into(),
289                status: MilestoneStatus::InProgress,
290            },
291            Milestone {
292                id: "3".into(),
293                description: "verify".into(),
294                status: MilestoneStatus::Pending,
295            },
296        ]);
297        l
298    }
299
300    #[test]
301    fn completion_ratio_reflects_terminal_milestones() {
302        let l = sample_ledger();
303        assert!((l.completion_ratio() - 1.0 / 3.0).abs() < f32::EPSILON);
304        assert!(!l.is_complete());
305    }
306
307    #[test]
308    fn empty_ledger_is_complete() {
309        let l = ProgressLedger::new("s", "goal");
310        assert!(l.is_complete());
311        assert!((l.completion_ratio() - 1.0).abs() < f32::EPSILON);
312    }
313
314    #[test]
315    fn advance_clears_stall_and_bumps_confidence() {
316        let mut l = sample_ledger();
317        l.note_stall();
318        assert!(l.is_stalled());
319        let before = l.confidence;
320        l.note_advance();
321        assert!(!l.is_stalled());
322        assert!(l.confidence >= before);
323    }
324
325    #[test]
326    fn persistence_round_trips() {
327        let tmp = std::env::temp_dir().join(format!("vtcode-prog-{}", std::process::id()));
328        let ws = tmp.join("ws");
329        std::fs::create_dir_all(&ws).unwrap();
330        let mut l = sample_ledger();
331        l.note_stall();
332        save_progress(&ws, "s1", &l).unwrap();
333        let loaded = load_progress(&ws, "s1").unwrap().expect("ledger present");
334        assert_eq!(loaded, l);
335        assert!(loaded.is_stalled());
336        // Absent ledger reads as None, not an error.
337        assert!(load_progress(&ws, "absent").unwrap().is_none());
338        let _ = std::fs::remove_dir_all(&tmp);
339    }
340
341    #[test]
342    fn handoff_metadata_defaults_to_none() {
343        let l = ProgressLedger::new("s1", "goal");
344        assert!(l.previous_session_id.is_none());
345        assert!(l.handoff_summary.is_none());
346        assert!(l.known_issues.is_empty());
347        assert!(l.git_checkpoint.is_none());
348    }
349
350    #[test]
351    fn set_handoff_records_metadata() {
352        let mut l = ProgressLedger::new("s2", "goal");
353        l.set_handoff("s1", "implemented login", Some("abc123".to_string()));
354        assert_eq!(l.previous_session_id.as_deref(), Some("s1"));
355        assert_eq!(l.handoff_summary.as_deref(), Some("implemented login"));
356        assert_eq!(l.git_checkpoint.as_deref(), Some("abc123"));
357    }
358
359    #[test]
360    fn add_known_issue_accumulates() {
361        let mut l = ProgressLedger::new("s3", "goal");
362        l.add_known_issue("rate limiting missing");
363        l.add_known_issue("no error handling for timeouts");
364        assert_eq!(l.known_issues.len(), 2);
365        assert_eq!(l.known_issues[0], "rate limiting missing");
366    }
367
368    #[test]
369    fn handoff_metadata_survives_persistence() {
370        let tmp = std::env::temp_dir().join(format!("vtcode-prog-{}", std::process::id()));
371        let ws = tmp.join("ws");
372        std::fs::create_dir_all(&ws).unwrap();
373
374        let mut l = sample_ledger();
375        l.set_handoff("prev-session", "built auth", Some("def456".to_string()));
376        l.add_known_issue("tests are flaky");
377
378        save_progress(&ws, "s4", &l).unwrap();
379        let loaded = load_progress(&ws, "s4").unwrap().expect("present");
380        assert_eq!(loaded.previous_session_id.as_deref(), Some("prev-session"));
381        assert_eq!(loaded.handoff_summary.as_deref(), Some("built auth"));
382        assert_eq!(loaded.git_checkpoint.as_deref(), Some("def456"));
383        assert_eq!(loaded.known_issues, vec!["tests are flaky"]);
384
385        let _ = std::fs::remove_dir_all(&tmp);
386    }
387
388    #[test]
389    fn to_markdown_includes_handoff_metadata() {
390        let mut l = ProgressLedger::new("s5", "build feature");
391        l.set_handoff("s4", "implemented core", Some("abc123".to_string()));
392        l.add_known_issue("missing error handling");
393
394        let md = l.to_markdown();
395        assert!(md.contains("Handed off from:** s4"));
396        assert!(md.contains("Handoff summary:** implemented core"));
397        assert!(md.contains("Git checkpoint:** `abc123`"));
398        assert!(md.contains("- missing error handling"));
399    }
400
401    #[test]
402    fn to_markdown_omits_handoff_when_absent() {
403        let l = ProgressLedger::new("s6", "goal");
404        let md = l.to_markdown();
405        assert!(!md.contains("Handed off from"));
406        assert!(!md.contains("Handoff summary"));
407        assert!(!md.contains("Git checkpoint"));
408        assert!(!md.contains("Known Issues"));
409    }
410}