1use 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
13pub const STALE_THRESHOLD: Duration = Duration::from_secs(24 * 60 * 60);
15
16#[derive(Debug, thiserror::Error)]
18pub enum RecoverError {
19 #[error("no state to recover — project is idle")]
21 NothingToRecover,
22 #[error("{0}")]
24 Io(#[from] std::io::Error),
25 #[error("{0}")]
27 Workflow(#[from] WorkflowError),
28}
29
30#[derive(Debug)]
32pub struct RecoveryStatus {
33 pub state: State,
35 pub agent_running: bool,
37 pub is_stale: bool,
39 pub age: String,
41 pub lock_held: Option<String>,
43}
44
45pub 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
74pub 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 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
127pub 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
147pub 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 if let Some(pid) = agent_pid_for(state)
160 && crate::agent::agent_running(pid)
161 {
162 return false;
163 }
164
165 true
166}
167
168fn 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
175fn 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
185pub 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 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 const DEAD_PID: u32 = 0x7FFF_FFFE;
232
233 #[test]
234 fn fresh_state_is_not_stale() {
235 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 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 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 #[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 #[test]
335 fn clean_keeps_phase_with_live_agent() {
336 let dir = tempfile::tempdir().unwrap();
337 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 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 #[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 #[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 #[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}