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