From 4c6b846fb942e11fc9f1ad51fa3416ca116fc0cb Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 26 Mar 2026 13:08:36 -0400 Subject: [PATCH] fix(resume): validate checkpoint before cleanup and clear progress.jsonl Two issues in the resume cleanup logic: 1. progress.jsonl was not in the stale artifact list, so attach and logs would replay the previous attempt's events before the new run. Added it to the cleanup list. 2. Cleanup ran before validating the checkpoint was parseable. A crash during the original run can leave a truncated checkpoint.json that passes exists() but fails to parse. We now load and parse the checkpoint first; if it's corrupt we bail with the old conclusion and failure evidence intact. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/crates/fabro-cli/src/commands/resume.rs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/lib/crates/fabro-cli/src/commands/resume.rs b/lib/crates/fabro-cli/src/commands/resume.rs index 8183621ec..dbd449817 100644 --- a/lib/crates/fabro-cli/src/commands/resume.rs +++ b/lib/crates/fabro-cli/src/commands/resume.rs @@ -1,7 +1,7 @@ use anyhow::bail; use clap::Args; use fabro_util::terminal::Styles; -use fabro_workflows::records::RunRecord; +use fabro_workflows::records::{Checkpoint, RunRecord}; use fabro_workflows::run_status::RunStatus; #[derive(Debug, Args)] @@ -29,9 +29,18 @@ pub async fn resume_command(args: ResumeArgs, styles: &'static Styles) -> anyhow } let run_id = RunRecord::load(&run_dir)?.run_id; - if !run_dir.join("checkpoint.json").exists() { - bail!("no checkpoint found — nothing to resume"); - } + // Validate checkpoint is parseable before touching any state. + // A crash during the original run can leave a truncated file; + // we must not destroy the old conclusion/failure evidence and + // only then discover the checkpoint is corrupt. + let cp_path = run_dir.join("checkpoint.json"); + Checkpoint::load(&cp_path).map_err(|e| { + if cp_path.exists() { + anyhow::anyhow!("checkpoint.json is corrupt — cannot resume: {e}") + } else { + anyhow::anyhow!("no checkpoint found — nothing to resume") + } + })?; // Guard against resuming a live run if is_pid_alive(&run_dir.join("run.pid")) { @@ -48,6 +57,7 @@ pub async fn resume_command(args: ResumeArgs, styles: &'static Styles) -> anyhow "interview_request.claim", "detach.log", "run.pid", + "progress.jsonl", ] { let _ = std::fs::remove_file(run_dir.join(name)); }