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
116            .milestones
117            .iter()
118            .filter(|m| m.status.is_terminal())
119            .count() as f32;
120        done / self.milestones.len() as f32
121    }
122
123    /// Whether every tracked milestone is complete (or none are tracked).
124    #[must_use]
125    pub fn is_complete(&self) -> bool {
126        self.completion_ratio() >= 1.0
127    }
128
129    /// Whether the ledger currently reports a stall.
130    #[must_use]
131    pub fn is_stalled(&self) -> bool {
132        self.stalled_since.is_some()
133    }
134
135    /// Record forward progress: clears any stall marker and refreshes the
136    /// timestamp. Confidence is nudged upward (bounded at 1.0).
137    pub fn note_advance(&mut self) {
138        self.stalled_since = None;
139        self.confidence = (self.confidence + 0.05).min(1.0);
140        self.updated_at = Utc::now().to_rfc3339();
141    }
142
143    /// Record a stall: sets `stalled_since` on first occurrence and refreshes
144    /// the timestamp. Confidence is nudged downward (bounded at `0.0`).
145    pub fn note_stall(&mut self) {
146        if self.stalled_since.is_none() {
147            self.stalled_since = Some(Utc::now().to_rfc3339());
148        }
149        self.confidence = (self.confidence - 0.1).max(0.0);
150        self.updated_at = Utc::now().to_rfc3339();
151    }
152
153    /// Replace the milestone set and refresh the timestamp.
154    pub fn set_milestones(&mut self, milestones: Vec<Milestone>) {
155        self.milestones = milestones;
156        self.updated_at = Utc::now().to_rfc3339();
157    }
158
159    /// Set the session goal and refresh the timestamp.
160    pub fn set_goal(&mut self, goal: &str) {
161        self.goal = goal.to_string();
162        self.updated_at = Utc::now().to_rfc3339();
163    }
164
165    /// Record handoff metadata from a previous session.
166    pub fn set_handoff(
167        &mut self,
168        previous_session_id: &str,
169        summary: &str,
170        git_checkpoint: Option<String>,
171    ) {
172        self.previous_session_id = Some(previous_session_id.to_string());
173        self.handoff_summary = Some(summary.to_string());
174        self.git_checkpoint = git_checkpoint;
175        self.updated_at = Utc::now().to_rfc3339();
176    }
177
178    /// Add a known issue carried forward from a previous session.
179    pub fn add_known_issue(&mut self, issue: &str) {
180        self.known_issues.push(issue.to_string());
181        self.updated_at = Utc::now().to_rfc3339();
182    }
183
184    /// Render a compact, human-readable progress summary for durable memory
185    /// (e.g. `<workspace>/memories/progress.md`). Survives compaction and gives
186    /// a resumed session an accurate picture of what is done. Includes handoff
187    /// metadata when present so the next session can orient from this alone.
188    #[must_use]
189    pub fn to_markdown(&self) -> String {
190        let mut out = String::new();
191        out.push_str("# Session Progress\n\n");
192        out.push_str(&format!("**Goal:** {}\n", self.goal));
193        out.push_str(&format!(
194            "**Completion:** {:.0}%\n",
195            (self.completion_ratio() * 100.0).round()
196        ));
197        out.push_str(&format!("**Confidence:** {:.2}\n", self.confidence));
198        if let Some(since) = &self.stalled_since {
199            out.push_str(&format!("**Stalled since:** {since}\n"));
200        }
201        out.push_str(&format!("**Updated:** {}\n\n", self.updated_at));
202
203        if let Some(prev) = &self.previous_session_id {
204            out.push_str(&format!("**Handed off from:** {prev}\n"));
205        }
206        if let Some(summary) = &self.handoff_summary {
207            out.push_str(&format!("**Handoff summary:** {summary}\n"));
208        }
209        if let Some(checkpoint) = &self.git_checkpoint {
210            out.push_str(&format!("**Git checkpoint:** `{checkpoint}`\n"));
211        }
212        if !self.known_issues.is_empty() {
213            out.push_str("\n## Known Issues\n\n");
214            for issue in &self.known_issues {
215                out.push_str(&format!("- {issue}\n"));
216            }
217        }
218
219        if self.milestones.is_empty() {
220            out.push_str("\n_No tracked milestones yet._\n");
221        } else {
222            out.push_str("\n## Milestones\n\n");
223            for m in &self.milestones {
224                let mark = match m.status {
225                    MilestoneStatus::Done => "[x]",
226                    MilestoneStatus::InProgress => "[~]",
227                    MilestoneStatus::Blocked => "[!]",
228                    MilestoneStatus::Pending => "[ ]",
229                };
230                out.push_str(&format!("{} {} — {}\n", mark, m.id, m.description));
231            }
232        }
233        out
234    }
235}
236
237/// Resolve the on-disk path of the progress ledger for a session.
238#[must_use]
239pub fn progress_path(workspace: &Path, session_id: &str) -> std::path::PathBuf {
240    session_dir(workspace, session_id)
241        .join(crate::DERIVED_DIR)
242        .join("progress.json")
243}
244
245/// Load the progress ledger for a session, if one has been persisted.
246///
247/// Returns `Ok(None)` when no ledger file exists yet (a fresh or pre-ledger
248/// session) rather than an error, so callers can treat absence as "no signal".
249pub fn load_progress(
250    workspace: &Path,
251    session_id: &str,
252) -> Result<Option<ProgressLedger>, SessionStoreError> {
253    let path = progress_path(workspace, session_id);
254    if !path.exists() {
255        return Ok(None);
256    }
257    let bytes = std::fs::read(&path).map_err(|e| SessionStoreError::io(path.clone(), e))?;
258    let ledger: ProgressLedger = serde_json::from_slice(&bytes)?;
259    Ok(Some(ledger))
260}
261
262/// Persist the progress ledger for a session, creating `derived/` if needed.
263pub fn save_progress(
264    workspace: &Path,
265    session_id: &str,
266    ledger: &ProgressLedger,
267) -> Result<(), SessionStoreError> {
268    let path = progress_path(workspace, session_id);
269    if let Some(parent) = path.parent() {
270        std::fs::create_dir_all(parent).map_err(|e| SessionStoreError::CreateDir {
271            path: parent.to_path_buf(),
272            source: e,
273        })?;
274    }
275    let bytes = serde_json::to_string_pretty(ledger)?;
276    std::fs::write(&path, bytes).map_err(|e| SessionStoreError::io(path, e))?;
277    Ok(())
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    fn sample_ledger() -> ProgressLedger {
285        let mut l = ProgressLedger::new("s1", "ship the feature");
286        l.set_milestones(vec![
287            Milestone {
288                id: "1".into(),
289                description: "design".into(),
290                status: MilestoneStatus::Done,
291            },
292            Milestone {
293                id: "2".into(),
294                description: "implement".into(),
295                status: MilestoneStatus::InProgress,
296            },
297            Milestone {
298                id: "3".into(),
299                description: "verify".into(),
300                status: MilestoneStatus::Pending,
301            },
302        ]);
303        l
304    }
305
306    #[test]
307    fn completion_ratio_reflects_terminal_milestones() {
308        let l = sample_ledger();
309        assert!((l.completion_ratio() - 1.0 / 3.0).abs() < f32::EPSILON);
310        assert!(!l.is_complete());
311    }
312
313    #[test]
314    fn empty_ledger_is_complete() {
315        let l = ProgressLedger::new("s", "goal");
316        assert!(l.is_complete());
317        assert!((l.completion_ratio() - 1.0).abs() < f32::EPSILON);
318    }
319
320    #[test]
321    fn advance_clears_stall_and_bumps_confidence() {
322        let mut l = sample_ledger();
323        l.note_stall();
324        assert!(l.is_stalled());
325        let before = l.confidence;
326        l.note_advance();
327        assert!(!l.is_stalled());
328        assert!(l.confidence >= before);
329    }
330
331    #[test]
332    fn persistence_round_trips() {
333        let tmp = std::env::temp_dir().join(format!("vtcode-prog-{}", std::process::id()));
334        let ws = tmp.join("ws");
335        std::fs::create_dir_all(&ws).unwrap();
336        let mut l = sample_ledger();
337        l.note_stall();
338        save_progress(&ws, "s1", &l).unwrap();
339        let loaded = load_progress(&ws, "s1").unwrap().expect("ledger present");
340        assert_eq!(loaded, l);
341        assert!(loaded.is_stalled());
342        // Absent ledger reads as None, not an error.
343        assert!(load_progress(&ws, "absent").unwrap().is_none());
344        let _ = std::fs::remove_dir_all(&tmp);
345    }
346
347    #[test]
348    fn handoff_metadata_defaults_to_none() {
349        let l = ProgressLedger::new("s1", "goal");
350        assert!(l.previous_session_id.is_none());
351        assert!(l.handoff_summary.is_none());
352        assert!(l.known_issues.is_empty());
353        assert!(l.git_checkpoint.is_none());
354    }
355
356    #[test]
357    fn set_handoff_records_metadata() {
358        let mut l = ProgressLedger::new("s2", "goal");
359        l.set_handoff("s1", "implemented login", Some("abc123".to_string()));
360        assert_eq!(l.previous_session_id.as_deref(), Some("s1"));
361        assert_eq!(l.handoff_summary.as_deref(), Some("implemented login"));
362        assert_eq!(l.git_checkpoint.as_deref(), Some("abc123"));
363    }
364
365    #[test]
366    fn add_known_issue_accumulates() {
367        let mut l = ProgressLedger::new("s3", "goal");
368        l.add_known_issue("rate limiting missing");
369        l.add_known_issue("no error handling for timeouts");
370        assert_eq!(l.known_issues.len(), 2);
371        assert_eq!(l.known_issues[0], "rate limiting missing");
372    }
373
374    #[test]
375    fn handoff_metadata_survives_persistence() {
376        let tmp = std::env::temp_dir().join(format!("vtcode-prog-{}", std::process::id()));
377        let ws = tmp.join("ws");
378        std::fs::create_dir_all(&ws).unwrap();
379
380        let mut l = sample_ledger();
381        l.set_handoff("prev-session", "built auth", Some("def456".to_string()));
382        l.add_known_issue("tests are flaky");
383
384        save_progress(&ws, "s4", &l).unwrap();
385        let loaded = load_progress(&ws, "s4").unwrap().expect("present");
386        assert_eq!(loaded.previous_session_id.as_deref(), Some("prev-session"));
387        assert_eq!(loaded.handoff_summary.as_deref(), Some("built auth"));
388        assert_eq!(loaded.git_checkpoint.as_deref(), Some("def456"));
389        assert_eq!(loaded.known_issues, vec!["tests are flaky"]);
390
391        let _ = std::fs::remove_dir_all(&tmp);
392    }
393
394    #[test]
395    fn to_markdown_includes_handoff_metadata() {
396        let mut l = ProgressLedger::new("s5", "build feature");
397        l.set_handoff("s4", "implemented core", Some("abc123".to_string()));
398        l.add_known_issue("missing error handling");
399
400        let md = l.to_markdown();
401        assert!(md.contains("Handed off from:** s4"));
402        assert!(md.contains("Handoff summary:** implemented core"));
403        assert!(md.contains("Git checkpoint:** `abc123`"));
404        assert!(md.contains("- missing error handling"));
405    }
406
407    #[test]
408    fn to_markdown_omits_handoff_when_absent() {
409        let l = ProgressLedger::new("s6", "goal");
410        let md = l.to_markdown();
411        assert!(!md.contains("Handed off from"));
412        assert!(!md.contains("Handoff summary"));
413        assert!(!md.contains("Git checkpoint"));
414        assert!(!md.contains("Known Issues"));
415    }
416}