Skip to main content

devflow_core/
recover.rs

1//! State recovery and stale-state detection.
2//!
3//! `devflow recover` reads the existing state file, determines if the
4//! agent process is still running, and either reports status or
5//! offers to clean up / restart.
6
7use crate::state::State;
8use crate::workflow::{self, WorkflowError};
9use std::path::Path;
10use std::time::{Duration, SystemTime, UNIX_EPOCH};
11
12/// Maximum age before a state is considered stale (>24h).
13pub const STALE_THRESHOLD: Duration = Duration::from_secs(24 * 60 * 60);
14
15/// Errors produced by recover operations.
16#[derive(Debug, thiserror::Error)]
17pub enum RecoverError {
18    /// No state file exists — nothing to recover.
19    #[error("no state to recover — project is idle")]
20    NothingToRecover,
21    /// Filesystem operation failed.
22    #[error("{0}")]
23    Io(#[from] std::io::Error),
24    /// State loading failed.
25    #[error("{0}")]
26    Workflow(#[from] WorkflowError),
27}
28
29/// Result of inspecting an existing workflow state.
30#[derive(Debug)]
31pub struct RecoveryStatus {
32    /// The loaded workflow state.
33    pub state: State,
34    /// Whether the agent process is still running.
35    pub agent_running: bool,
36    /// Whether the state is considered stale (>24h without agent).
37    pub is_stale: bool,
38    /// Human-readable age of the state.
39    pub age: String,
40    /// Whether a lock file is present (shows holder PID).
41    pub lock_held: Option<String>,
42}
43
44/// Inspect every active phase state, producing one recovery status per phase
45/// (sorted by phase number). Errors with [`RecoverError::NothingToRecover`]
46/// when no phase has persisted state.
47pub fn inspect_all(project_root: &Path) -> Result<Vec<RecoveryStatus>, RecoverError> {
48    let states = workflow::list_states(project_root);
49    if states.is_empty() {
50        return Err(RecoverError::NothingToRecover);
51    }
52    Ok(states
53        .into_iter()
54        .map(|state| inspect_state(project_root, state))
55        .collect())
56}
57
58fn inspect_state(project_root: &Path, state: State) -> RecoveryStatus {
59    let agent_running = agent_pid_for(&state).is_some_and(crate::agent::agent_running);
60    let is_stale = is_stale_state(&state);
61    let age = format_age(state.started_at.as_str());
62    let lock_held = crate::lock::holder(project_root, state.phase).map(|(pid, _)| pid);
63
64    RecoveryStatus {
65        state,
66        agent_running,
67        is_stale,
68        age,
69        lock_held,
70    }
71}
72
73/// Clean up stale or abandoned workflow state.
74///
75/// 14-CR-01: only STALE phases are swept — a phase whose agent is still
76/// running, or whose state is simply fresh, is kept (with a warning naming
77/// the explicit `--phase` escape hatch), so cleaning one dead phase under
78/// `devflow parallel` can never orphan a healthy sibling. Also removes an
79/// unparsable legacy `state.json` (14-CR-04 — this reset is the one
80/// sanctioned place), lock files whose holder is dead (the sweep lives in
81/// [`crate::lock::remove_stale_locks`], which refuses to delete a live
82/// holder's lock), and cron-instruction records for phases that no longer
83/// have state — self-describing "auto-re-run this phase" records that must
84/// not survive an operator-driven reset. Returns warnings for anything kept
85/// or that could not be removed.
86pub fn clean(project_root: &Path) -> Result<Vec<String>, RecoverError> {
87    let mut warnings = Vec::new();
88    for state in workflow::list_states(project_root) {
89        let phase = state.phase;
90        if agent_pid_for(&state).is_some_and(crate::agent::agent_running) {
91            warnings.push(format!(
92                "kept phase {phase} — its agent is still running (clear explicitly with --phase {phase})"
93            ));
94            continue;
95        }
96        if !is_stale_state(&state) {
97            warnings.push(format!(
98                "kept phase {phase} — state is not stale yet (clear explicitly with --phase {phase})"
99            ));
100            continue;
101        }
102        workflow::clear_state(project_root, phase)?;
103    }
104    match workflow::remove_corrupt_legacy_state(project_root) {
105        Ok(true) => warnings.push("removed unparsable legacy state.json".into()),
106        Ok(false) => {}
107        Err(err) => warnings.push(format!("could not remove corrupt legacy state.json: {err}")),
108    }
109    warnings.append(&mut crate::lock::remove_stale_locks(project_root));
110    // Drop cron records only for phases without surviving state, so a kept
111    // phase's pending re-run record is preserved.
112    for instructions in crate::ship::list_cron_instructions(project_root) {
113        if workflow::state_path(project_root, instructions.phase).exists() {
114            continue;
115        }
116        if let Err(err) = crate::ship::delete_cron_instructions(project_root, instructions.phase) {
117            warnings.push(format!(
118                "could not remove cron-instructions for phase {}: {err}",
119                instructions.phase
120            ));
121        }
122    }
123    Ok(warnings)
124}
125
126/// Explicitly clean ONE phase, regardless of staleness — the operator's
127/// escape hatch for a wedged-but-fresh run. Clears its state and cron
128/// record; warns (but proceeds) when the recorded agent still looks alive.
129pub fn clean_phase(project_root: &Path, phase: u32) -> Result<Vec<String>, RecoverError> {
130    let mut warnings = Vec::new();
131    if let Ok(state) = workflow::load_state(project_root, phase)
132        && agent_pid_for(&state).is_some_and(crate::agent::agent_running)
133    {
134        warnings.push(format!(
135            "phase {phase}'s agent appears to still be running — cleared anyway (explicit --phase)"
136        ));
137    }
138    workflow::clear_state(project_root, phase)?;
139    if let Err(err) = crate::ship::delete_cron_instructions(project_root, phase) {
140        warnings.push(format!("could not remove cron-instructions: {err}"));
141    }
142    warnings.append(&mut crate::lock::remove_stale_locks(project_root));
143    Ok(warnings)
144}
145
146/// Check whether a state is stale: >24h old with no running agent.
147pub fn is_stale_state(state: &State) -> bool {
148    let age_secs = match state_age_secs(&state.started_at) {
149        Some(a) => a,
150        None => return false,
151    };
152
153    if age_secs < STALE_THRESHOLD.as_secs() {
154        return false;
155    }
156
157    // Only stale if the agent process is gone
158    if let Some(pid) = agent_pid_for(state)
159        && crate::agent::agent_running(pid)
160    {
161        return false;
162    }
163
164    true
165}
166
167/// Read the launched agent PID the monitor recorded for this state's phase, if
168/// the pid file is present and parseable.
169fn agent_pid_for(state: &State) -> Option<u32> {
170    let path = crate::agent_result::agent_pid_path(&state.project_root, state.phase);
171    std::fs::read_to_string(path).ok()?.trim().parse().ok()
172}
173
174/// Compute the age of a state's `started_at` timestamp in seconds.
175fn state_age_secs(started_at: &str) -> Option<u64> {
176    let started: u64 = started_at.parse().ok()?;
177    let now = SystemTime::now()
178        .duration_since(UNIX_EPOCH)
179        .unwrap_or_default()
180        .as_secs();
181    now.checked_sub(started)
182}
183
184/// Format a unix-seconds timestamp's age as a human-readable string
185/// ("5m ago"). Public since 14c: `devflow status` reuses it for elapsed
186/// time and event recency.
187pub fn format_age(started_at: &str) -> String {
188    match state_age_secs(started_at) {
189        Some(s) if s < 60 => format!("{s}s ago"),
190        Some(s) if s < 3600 => format!("{}m ago", s / 60),
191        Some(s) if s < 86400 => format!("{}h ago", s / 3600),
192        Some(s) => format!("{}d ago", s / 86400),
193        None => "unknown".into(),
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use crate::mode::Mode;
201    use crate::state::{AgentKind, State};
202
203    /// Build a state in `root` whose `started_at` is `age_secs` in the past,
204    /// optionally writing the monitor's agent-pid file with `agent_pid`.
205    fn state_aged(root: &Path, age_secs: u64, agent_pid: Option<u32>) -> State {
206        state_aged_phase(root, 1, age_secs, agent_pid)
207    }
208
209    fn state_aged_phase(root: &Path, phase: u32, age_secs: u64, agent_pid: Option<u32>) -> State {
210        let now = SystemTime::now()
211            .duration_since(UNIX_EPOCH)
212            .unwrap_or_default()
213            .as_secs();
214        let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
215        state.started_at = now.saturating_sub(age_secs).to_string();
216        if let Some(pid) = agent_pid {
217            let path = crate::agent_result::agent_pid_path(root, state.phase);
218            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
219            std::fs::write(path, pid.to_string()).unwrap();
220        }
221        state
222    }
223
224    /// A PID that is essentially certain not to map to a live process.
225    const DEAD_PID: u32 = 0x7FFF_FFFE;
226
227    #[test]
228    fn fresh_state_is_not_stale() {
229        // One hour old, well under the 24h threshold.
230        let dir = tempfile::tempdir().unwrap();
231        let state = state_aged(dir.path(), 3600, None);
232        assert!(!is_stale_state(&state));
233    }
234
235    #[test]
236    fn old_state_with_no_agent_is_stale() {
237        let dir = tempfile::tempdir().unwrap();
238        let state = state_aged(dir.path(), STALE_THRESHOLD.as_secs() + 60, None);
239        assert!(is_stale_state(&state));
240    }
241
242    #[test]
243    fn old_state_with_dead_agent_is_stale() {
244        let dir = tempfile::tempdir().unwrap();
245        let state = state_aged(dir.path(), STALE_THRESHOLD.as_secs() + 60, Some(DEAD_PID));
246        assert!(is_stale_state(&state));
247    }
248
249    #[test]
250    fn old_state_with_live_agent_is_not_stale() {
251        // Our own PID is guaranteed to be running.
252        let dir = tempfile::tempdir().unwrap();
253        let own_pid = std::process::id();
254        let state = state_aged(dir.path(), STALE_THRESHOLD.as_secs() + 60, Some(own_pid));
255        assert!(!is_stale_state(&state));
256    }
257
258    #[test]
259    fn unparseable_timestamp_is_never_stale() {
260        let dir = tempfile::tempdir().unwrap();
261        let mut state = State::new(1, AgentKind::Claude, Mode::Auto, dir.path().to_path_buf());
262        state.started_at = "not-a-number".into();
263        assert!(!is_stale_state(&state));
264        assert_eq!(state_age_secs(&state.started_at), None);
265    }
266
267    #[test]
268    fn state_age_secs_parses_epoch() {
269        let now = SystemTime::now()
270            .duration_since(UNIX_EPOCH)
271            .unwrap_or_default()
272            .as_secs();
273        let started = (now - 120).to_string();
274        let age = state_age_secs(&started).expect("age");
275        // Allow a small window for clock drift during the test.
276        assert!((118..=125).contains(&age), "unexpected age: {age}");
277    }
278
279    #[test]
280    fn format_age_buckets_by_magnitude() {
281        let now = SystemTime::now()
282            .duration_since(UNIX_EPOCH)
283            .unwrap_or_default()
284            .as_secs();
285        let ago = |secs: u64| format_age(&(now - secs).to_string());
286        assert!(ago(30).ends_with("s ago"));
287        assert!(ago(120).ends_with("m ago"));
288        assert!(ago(7200).ends_with("h ago"));
289        assert!(ago(2 * 86400).ends_with("d ago"));
290        assert_eq!(format_age("garbage"), "unknown");
291    }
292
293    #[test]
294    fn inspect_all_missing_state_reports_nothing_to_recover() {
295        let dir = std::env::temp_dir().join(format!("devflow-recover-{}", std::process::id()));
296        let _ = std::fs::remove_dir_all(&dir);
297        std::fs::create_dir_all(&dir).expect("create temp dir");
298        let err = inspect_all(&dir).expect_err("should have no state");
299        assert!(matches!(err, RecoverError::NothingToRecover));
300        let _ = std::fs::remove_dir_all(&dir);
301    }
302
303    /// 13-DEFERRED-CR-03 acceptance: recover must enumerate ALL active
304    /// phases, not just the last one started.
305    #[test]
306    fn inspect_all_enumerates_every_active_phase() {
307        let dir = tempfile::tempdir().unwrap();
308        workflow::save_state(&state_aged(dir.path(), 60, None)).unwrap();
309        let mut other = state_aged(dir.path(), 60, None);
310        other.phase = 2;
311        workflow::save_state(&other).unwrap();
312
313        let statuses = inspect_all(dir.path()).expect("two phases active");
314        assert_eq!(
315            statuses.iter().map(|s| s.state.phase).collect::<Vec<_>>(),
316            vec![1, 2]
317        );
318    }
319
320    /// 14-CR-01: `recover --clean` must never delete a phase whose agent is
321    /// still running — under `devflow parallel`, cleaning a stale phase must
322    /// not orphan a healthy sibling.
323    #[test]
324    fn clean_keeps_phase_with_live_agent() {
325        let dir = tempfile::tempdir().unwrap();
326        // Stale-aged but the recorded agent (our own pid) is alive.
327        let live = state_aged_phase(
328            dir.path(),
329            1,
330            STALE_THRESHOLD.as_secs() + 60,
331            Some(std::process::id()),
332        );
333        workflow::save_state(&live).unwrap();
334        // Genuinely stale sibling: old and dead.
335        let stale = state_aged_phase(
336            dir.path(),
337            2,
338            STALE_THRESHOLD.as_secs() + 60,
339            Some(DEAD_PID),
340        );
341        workflow::save_state(&stale).unwrap();
342
343        let warnings = clean(dir.path()).expect("clean");
344
345        let remaining: Vec<u32> = workflow::list_states(dir.path())
346            .iter()
347            .map(|s| s.phase)
348            .collect();
349        assert_eq!(remaining, vec![1], "live phase must survive, stale cleared");
350        assert!(
351            warnings.iter().any(|w| w.contains("phase 1")),
352            "keeping a live phase must be reported: {warnings:?}"
353        );
354    }
355
356    /// 14-CR-01: a fresh (not yet stale) phase is also kept — only stale
357    /// phases are swept implicitly; anything else needs explicit `--phase`.
358    #[test]
359    fn clean_keeps_fresh_phase() {
360        let dir = tempfile::tempdir().unwrap();
361        workflow::save_state(&state_aged_phase(dir.path(), 3, 60, None)).unwrap();
362
363        let warnings = clean(dir.path()).expect("clean");
364
365        assert_eq!(workflow::list_states(dir.path()).len(), 1);
366        assert!(warnings.iter().any(|w| w.contains("--phase 3")));
367    }
368
369    #[test]
370    fn clean_clears_stale_phase_state() {
371        let dir = tempfile::tempdir().unwrap();
372        workflow::save_state(&state_aged_phase(
373            dir.path(),
374            2,
375            STALE_THRESHOLD.as_secs() + 60,
376            Some(DEAD_PID),
377        ))
378        .unwrap();
379
380        clean(dir.path()).expect("clean");
381
382        assert!(workflow::list_states(dir.path()).is_empty());
383    }
384
385    /// 14-CR-04: a corrupt legacy `state.json` (old binary killed mid-write)
386    /// can never be migrated or matched by a per-phase clear — the operator
387    /// reset is the one sanctioned place to remove it.
388    #[test]
389    fn clean_removes_corrupt_legacy_state_json() {
390        let dir = tempfile::tempdir().unwrap();
391        let legacy = dir.path().join(".devflow/state.json");
392        std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
393        std::fs::write(&legacy, "{\"stage\":").unwrap();
394
395        clean(dir.path()).expect("clean");
396
397        assert!(
398            !legacy.exists(),
399            "recover --clean must remove an unparsable legacy state.json"
400        );
401    }
402
403    /// 14-CR-01: explicit `--phase` cleanup clears exactly that phase, even
404    /// when it is fresh, and leaves siblings alone.
405    #[test]
406    fn clean_phase_clears_only_the_named_phase() {
407        let dir = tempfile::tempdir().unwrap();
408        workflow::save_state(&state_aged_phase(dir.path(), 4, 60, None)).unwrap();
409        workflow::save_state(&state_aged_phase(dir.path(), 5, 60, None)).unwrap();
410
411        clean_phase(dir.path(), 4).expect("clean_phase");
412
413        let remaining: Vec<u32> = workflow::list_states(dir.path())
414            .iter()
415            .map(|s| s.phase)
416            .collect();
417        assert_eq!(remaining, vec![5]);
418    }
419}