Skip to main content

vtcode_core/
loop_state.rs

1//! Loop run state persistence for loop-engineering workflows.
2//!
3//! A loop is a long-lived scheduler that invokes the vtcode harness repeatedly.
4//! `LoopRunState` captures the durable state a loop scheduler reads on resume:
5//! current step index, last artifact path, and status.
6//!
7//! State is persisted as JSON under `{workspace}/.vtcode/state/loop-{id}.json`.
8
9use anyhow::{Context, Result};
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use std::fs;
13use std::path::{Path, PathBuf};
14use std::sync::atomic::{AtomicU64, Ordering};
15
16static WRITE_COUNTER: AtomicU64 = AtomicU64::new(1);
17
18const STATE_DIR_NAME: &str = "state";
19
20// ─── Loop Run State ──────────────────────────────────────────────────────────
21
22/// Durable state for a single loop run. The loop scheduler reads this on
23/// resume to know where execution left off.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct LoopRunState {
26    /// Unique identifier for this loop run.
27    pub loop_id: String,
28    /// Zero-based index of the current step.
29    pub step_index: u32,
30    /// Path to the last artifact produced by the loop (e.g., a diff, a report).
31    pub last_artifact_path: Option<PathBuf>,
32    /// Current lifecycle status.
33    pub status: LoopStatus,
34    /// When the loop run started.
35    pub started_at: DateTime<Utc>,
36    /// When the loop state was last persisted.
37    pub updated_at: DateTime<Utc>,
38}
39
40/// Lifecycle status of a loop run.
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
42#[serde(rename_all = "snake_case")]
43pub enum LoopStatus {
44    /// The loop is actively running.
45    Running,
46    /// The loop completed all steps successfully.
47    Completed,
48    /// The loop failed and cannot resume.
49    Failed,
50    /// The loop was paused and can be resumed.
51    Paused,
52}
53
54impl LoopRunState {
55    /// Create a new loop run state with the given identifier.
56    pub fn new(loop_id: impl Into<String>) -> Self {
57        let now = Utc::now();
58        Self {
59            loop_id: loop_id.into(),
60            step_index: 0,
61            last_artifact_path: None,
62            status: LoopStatus::Running,
63            started_at: now,
64            updated_at: now,
65        }
66    }
67
68    /// Advance to the next step and update the timestamp.
69    pub fn advance_step(&mut self) {
70        self.step_index = self.step_index.saturating_add(1);
71        self.updated_at = Utc::now();
72    }
73
74    /// Mark the loop as completed.
75    pub fn mark_completed(&mut self) {
76        self.status = LoopStatus::Completed;
77        self.updated_at = Utc::now();
78    }
79
80    /// Mark the loop as failed.
81    pub fn mark_failed(&mut self) {
82        self.status = LoopStatus::Failed;
83        self.updated_at = Utc::now();
84    }
85
86    /// Mark the loop as paused.
87    pub fn mark_paused(&mut self) {
88        self.status = LoopStatus::Paused;
89        self.updated_at = Utc::now();
90    }
91
92    /// Returns true if the loop can be resumed.
93    pub fn is_resumable(&self) -> bool {
94        matches!(self.status, LoopStatus::Paused | LoopStatus::Running)
95    }
96}
97
98// ─── Persistence ─────────────────────────────────────────────────────────────
99
100/// Resolve the `.vtcode/state/` directory for a workspace.
101pub fn state_dir(workspace_root: &Path) -> PathBuf {
102    workspace_root.join(".vtcode").join(STATE_DIR_NAME)
103}
104
105/// Resolve the path for a specific loop state file.
106pub fn loop_state_path(workspace_root: &Path, loop_id: &str) -> PathBuf {
107    state_dir(workspace_root).join(format!("loop-{loop_id}.json"))
108}
109
110/// Save loop run state to disk using atomic write (temp file + rename).
111pub fn save_loop_state(workspace_root: &Path, state: &LoopRunState) -> Result<PathBuf> {
112    let dir = state_dir(workspace_root);
113    fs::create_dir_all(&dir).with_context(|| format!("Failed to create state directory {}", dir.display()))?;
114
115    let path = loop_state_path(workspace_root, &state.loop_id);
116    let serialized = serde_json::to_vec_pretty(state).context("Failed to serialize loop run state")?;
117
118    atomic_write(&path, &serialized)?;
119    Ok(path)
120}
121
122/// Load loop run state from disk.
123pub fn load_loop_state(workspace_root: &Path, loop_id: &str) -> Result<Option<LoopRunState>> {
124    let path = loop_state_path(workspace_root, loop_id);
125    if !path.exists() {
126        return Ok(None);
127    }
128    let raw = fs::read_to_string(&path).with_context(|| format!("Failed to read loop state {}", path.display()))?;
129    let state: LoopRunState =
130        serde_json::from_str(&raw).with_context(|| format!("Failed to parse {}", path.display()))?;
131    Ok(Some(state))
132}
133
134/// Delete a loop state file from disk.
135pub fn delete_loop_state(workspace_root: &Path, loop_id: &str) -> Result<bool> {
136    let path = loop_state_path(workspace_root, loop_id);
137    if path.exists() {
138        fs::remove_file(&path).with_context(|| format!("Failed to delete loop state {}", path.display()))?;
139        Ok(true)
140    } else {
141        Ok(false)
142    }
143}
144
145/// List all loop state files in the state directory.
146pub fn list_loop_states(workspace_root: &Path) -> Result<Vec<LoopRunState>> {
147    let dir = state_dir(workspace_root);
148    if !dir.exists() {
149        return Ok(Vec::new());
150    }
151    let mut states = Vec::new();
152    for entry in fs::read_dir(&dir).with_context(|| format!("Failed to read state directory {}", dir.display()))? {
153        let entry = entry?;
154        let path = entry.path();
155        if path
156            .file_name()
157            .and_then(|n| n.to_str())
158            .is_some_and(|n| n.starts_with("loop-") && n.ends_with(".json"))
159        {
160            let raw = fs::read_to_string(&path).with_context(|| format!("Failed to read {}", path.display()))?;
161            match serde_json::from_str::<LoopRunState>(&raw) {
162                Ok(state) => states.push(state),
163                Err(e) => {
164                    tracing::warn!(path = %path.display(), error = %e, "Skipping malformed loop state file");
165                    continue;
166                }
167            }
168        }
169    }
170    states.sort_by_key(|a| std::cmp::Reverse(a.updated_at));
171    Ok(states)
172}
173
174/// Atomic write using temp file + rename, matching the pattern from
175/// `scheduler/mod.rs:1381`.
176fn atomic_write(path: &Path, content: &[u8]) -> Result<()> {
177    if let Some(parent) = path.parent() {
178        fs::create_dir_all(parent).with_context(|| format!("Failed to create {}", parent.display()))?;
179    }
180
181    let temp_name = format!(
182        ".{}.tmp-{}",
183        path.file_name().and_then(|n| n.to_str()).unwrap_or("loop-state"),
184        WRITE_COUNTER.fetch_add(1, Ordering::Relaxed)
185    );
186    let temp_path = path.with_file_name(temp_name);
187    fs::write(&temp_path, content).with_context(|| format!("Failed to write {}", temp_path.display()))?;
188    fs::rename(&temp_path, path).with_context(|| format!("Failed to replace {}", path.display()))?;
189    Ok(())
190}
191
192// ─── Tests ───────────────────────────────────────────────────────────────────
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use tempfile::TempDir;
198
199    #[test]
200    fn loop_run_state_new_has_correct_defaults() {
201        let state = LoopRunState::new("test-loop");
202        assert_eq!(state.loop_id, "test-loop");
203        assert_eq!(state.step_index, 0);
204        assert_eq!(state.status, LoopStatus::Running);
205        assert!(state.last_artifact_path.is_none());
206    }
207
208    #[test]
209    fn loop_run_state_advance_step_increments() {
210        let mut state = LoopRunState::new("test");
211        assert_eq!(state.step_index, 0);
212        state.advance_step();
213        assert_eq!(state.step_index, 1);
214        state.advance_step();
215        assert_eq!(state.step_index, 2);
216    }
217
218    #[test]
219    fn loop_run_state_status_transitions() {
220        let mut state = LoopRunState::new("test");
221        assert_eq!(state.status, LoopStatus::Running);
222        assert!(state.is_resumable());
223
224        state.mark_paused();
225        assert_eq!(state.status, LoopStatus::Paused);
226        assert!(state.is_resumable());
227
228        state.mark_completed();
229        assert_eq!(state.status, LoopStatus::Completed);
230        assert!(!state.is_resumable());
231
232        let mut state2 = LoopRunState::new("test2");
233        state2.mark_failed();
234        assert_eq!(state2.status, LoopStatus::Failed);
235        assert!(!state2.is_resumable());
236    }
237
238    #[test]
239    fn loop_state_round_trip_persistence() {
240        let tmp = TempDir::new().expect("temp dir");
241        let mut state = LoopRunState::new("round-trip-test");
242        state.advance_step();
243        state.last_artifact_path = Some(PathBuf::from("/tmp/artifact.txt"));
244
245        let path = save_loop_state(tmp.path(), &state).expect("save");
246        assert!(path.exists());
247
248        let loaded = load_loop_state(tmp.path(), "round-trip-test")
249            .expect("load")
250            .expect("should exist");
251        assert_eq!(loaded.loop_id, "round-trip-test");
252        assert_eq!(loaded.step_index, 1);
253        assert!(loaded.last_artifact_path.is_some());
254        assert_eq!(loaded.status, LoopStatus::Running);
255    }
256
257    #[test]
258    fn load_loop_state_returns_none_for_missing() {
259        let tmp = TempDir::new().expect("temp dir");
260        let result = load_loop_state(tmp.path(), "nonexistent").expect("ok");
261        assert!(result.is_none());
262    }
263
264    #[test]
265    fn delete_loop_state_removes_file() {
266        let tmp = TempDir::new().expect("temp dir");
267        let state = LoopRunState::new("delete-me");
268        save_loop_state(tmp.path(), &state).expect("save");
269
270        let deleted = delete_loop_state(tmp.path(), "delete-me").expect("delete");
271        assert!(deleted);
272
273        let loaded = load_loop_state(tmp.path(), "delete-me").expect("load");
274        assert!(loaded.is_none());
275    }
276
277    #[test]
278    fn list_loop_states_returns_sorted_by_updated_at() {
279        let tmp = TempDir::new().expect("temp dir");
280
281        let mut state1 = LoopRunState::new("loop-1");
282        state1.updated_at = Utc::now() - chrono::Duration::hours(1);
283        save_loop_state(tmp.path(), &state1).expect("save");
284
285        let mut state2 = LoopRunState::new("loop-2");
286        state2.updated_at = Utc::now();
287        save_loop_state(tmp.path(), &state2).expect("save");
288
289        let states = list_loop_states(tmp.path()).expect("list");
290        assert_eq!(states.len(), 2);
291        // Most recent first
292        assert_eq!(states[0].loop_id, "loop-2");
293        assert_eq!(states[1].loop_id, "loop-1");
294    }
295
296    #[test]
297    fn loop_state_serializes_status_variants() {
298        for status in [
299            LoopStatus::Running,
300            LoopStatus::Completed,
301            LoopStatus::Failed,
302            LoopStatus::Paused,
303        ] {
304            let state = LoopRunState {
305                loop_id: "serde-test".to_string(),
306                step_index: 0,
307                last_artifact_path: None,
308                status: status.clone(),
309                started_at: Utc::now(),
310                updated_at: Utc::now(),
311            };
312            let json = serde_json::to_string(&state).expect("serialize");
313            let deserialized: LoopRunState = serde_json::from_str(&json).expect("deserialize");
314            assert_eq!(deserialized.status, status);
315        }
316    }
317}