Add status.txt for explicit run lifecycle tracking

Runs were invisible in `fabro ps` during sandbox initialization because
manifest.json isn't written until engine.run(). status.txt is written
immediately at run creation and updated at lifecycle transitions
(starting → running → concluded), replacing fragile inference logic.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-15 16:12:52 -04:00
parent c8b9436678
commit 047c2c4fef
No known key found for this signature in database
4 changed files with 200 additions and 17 deletions

View file

@ -219,6 +219,7 @@ fn detach_run(args: fabro_workflows::cli::RunArgs) -> Result<()> {
});
std::fs::create_dir_all(&run_dir)?;
std::fs::write(run_dir.join("id.txt"), &run_id)?;
fabro_workflows::cli::runs::write_status_file(&run_dir, "starting");
std::fs::File::create(run_dir.join("progress.jsonl"))?;
let log_file = std::fs::File::create(run_dir.join("detach.log"))?;

View file

@ -467,6 +467,14 @@ pub async fn run_command(
.context("Failed to activate per-run log")?;
tokio::fs::write(run_dir.join("graph.fabro"), &source).await?;
tokio::fs::write(run_dir.join("run.pid"), std::process::id().to_string()).await?;
super::runs::write_status_file(&run_dir, "starting");
// Safety net: mark as concluded if we exit before engine.run() (e.g. sandbox init failure)
let status_run_dir = run_dir.clone();
let status_guard = scopeguard::guard((), move |()| {
super::runs::write_status_file(&status_run_dir, "concluded");
});
if workflow_path.extension().is_some_and(|ext| ext == "toml") {
if let Ok(toml_contents) = tokio::fs::read(workflow_path).await {
tokio::fs::write(run_dir.join("run.toml"), toml_contents).await?;
@ -1202,6 +1210,9 @@ pub async fn run_command(
workflow_slug: workflow_slug.clone(),
};
// Defuse the status guard — engine.run() will write "running" and conclusion handles "concluded"
scopeguard::ScopeGuard::into_inner(status_guard);
let run_start = Instant::now();
let engine_result = if let Some(ref checkpoint_path) = args.resume {
let checkpoint = Checkpoint::load(checkpoint_path)?;
@ -1270,6 +1281,7 @@ pub async fn run_command(
total_retries,
};
let _ = conclusion.save(&run_dir.join("conclusion.json"));
super::runs::write_status_file(&run_dir, "concluded");
}
// Auto-derive retro (always, cheap) and optionally run retro agent

View file

@ -170,7 +170,7 @@ pub fn scan_runs(base: &Path) -> Result<Vec<RunInfo>> {
is_orphan: false,
});
} else {
// Orphan directory — no manifest.json
// No manifest.json — check for status.txt (starting run) vs true orphan
let mtime_dt = entry
.metadata()
.ok()
@ -182,22 +182,44 @@ pub fn scan_runs(base: &Path) -> Result<Vec<RunInfo>> {
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| dir_name.clone());
runs.push(RunInfo {
run_id,
dir_name,
workflow_name: "[no manifest]".to_string(),
workflow_slug: None,
status: RunStatus::Unknown,
start_time: mtime,
labels: HashMap::new(),
duration_ms: None,
total_cost: None,
host_repo_path: None,
start_time_dt: mtime_dt,
end_time: None,
path,
is_orphan: true,
});
if read_status_file(&path).is_some() {
// Has status.txt → run is initializing, not an orphan
let si = read_status(&path);
runs.push(RunInfo {
run_id,
dir_name,
workflow_name: "[starting]".to_string(),
workflow_slug: None,
status: si.status,
start_time: mtime,
labels: HashMap::new(),
duration_ms: si.duration_ms,
total_cost: si.total_cost,
host_repo_path: None,
start_time_dt: mtime_dt,
end_time: si.end_time,
path,
is_orphan: false,
});
} else {
// True orphan — no manifest, no status.txt
runs.push(RunInfo {
run_id,
dir_name,
workflow_name: "[no manifest]".to_string(),
workflow_slug: None,
status: RunStatus::Unknown,
start_time: mtime,
labels: HashMap::new(),
duration_ms: None,
total_cost: None,
host_repo_path: None,
start_time_dt: mtime_dt,
end_time: None,
path,
is_orphan: true,
});
}
}
}
@ -213,7 +235,20 @@ struct StatusInfo {
total_cost: Option<f64>,
}
/// Write the run lifecycle status to `status.txt` (best-effort).
pub fn write_status_file(run_dir: &Path, status: &str) {
let _ = std::fs::write(run_dir.join("status.txt"), status);
}
fn read_status_file(run_dir: &Path) -> Option<String> {
std::fs::read_to_string(run_dir.join("status.txt"))
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn read_status(run_dir: &Path) -> StatusInfo {
// 1. conclusion.json is authoritative for concluded runs
if let Ok(conclusion) = crate::conclusion::Conclusion::load(&run_dir.join("conclusion.json")) {
return StatusInfo {
status: RunStatus::Concluded(conclusion.status),
@ -222,6 +257,31 @@ fn read_status(run_dir: &Path) -> StatusInfo {
total_cost: conclusion.total_cost,
};
}
// 2. status.txt — explicit lifecycle tracking
if let Some(status_str) = read_status_file(run_dir) {
return match status_str.as_str() {
"starting" | "running" => StatusInfo {
status: RunStatus::Running,
end_time: None,
duration_ms: None,
total_cost: None,
},
// concluded without conclusion.json → treat as failed
"concluded" => StatusInfo {
status: RunStatus::Concluded(crate::outcome::StageStatus::Fail),
end_time: None,
duration_ms: None,
total_cost: None,
},
_ => StatusInfo {
status: RunStatus::Unknown,
end_time: None,
duration_ms: None,
total_cost: None,
},
};
}
// 3. Legacy fallback: run.pid exists → Running
if run_dir.join("run.pid").exists() {
return StatusInfo {
status: RunStatus::Running,
@ -2138,4 +2198,113 @@ mod tests {
"10-day-old run should be pruned with 7d threshold"
);
}
// === status.txt tests ===
#[test]
fn read_status_starting_maps_to_running() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
fs::write(dir.join("status.txt"), "starting").unwrap();
let si = read_status(dir);
assert_eq!(si.status, RunStatus::Running);
}
#[test]
fn read_status_running_maps_to_running() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
fs::write(dir.join("status.txt"), "running").unwrap();
let si = read_status(dir);
assert_eq!(si.status, RunStatus::Running);
}
#[test]
fn read_status_concluded_without_conclusion_json_maps_to_fail() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
fs::write(dir.join("status.txt"), "concluded").unwrap();
let si = read_status(dir);
assert_eq!(
si.status,
RunStatus::Concluded(crate::outcome::StageStatus::Fail)
);
}
#[test]
fn read_status_conclusion_json_takes_priority_over_status_txt() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
fs::write(dir.join("status.txt"), "running").unwrap();
fs::write(
dir.join("conclusion.json"),
serde_json::to_string_pretty(&serde_json::json!({
"timestamp": "2026-01-01T12:01:00Z",
"status": "success",
"duration_ms": 60000
}))
.unwrap(),
)
.unwrap();
let si = read_status(dir);
assert_eq!(
si.status,
RunStatus::Concluded(crate::outcome::StageStatus::Success)
);
}
#[test]
fn scan_runs_status_txt_without_manifest_is_not_orphan() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path();
let dir = base.join("20260301-STARTING");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("id.txt"), "starting-run-id").unwrap();
fs::write(dir.join("status.txt"), "starting").unwrap();
let runs = scan_runs(base).unwrap();
assert_eq!(runs.len(), 1);
assert!(!runs[0].is_orphan);
assert_eq!(runs[0].status, RunStatus::Running);
assert_eq!(runs[0].workflow_name, "[starting]");
}
#[test]
fn scan_runs_no_status_txt_no_manifest_is_orphan() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path();
let dir = base.join("20260301-ORPHAN");
fs::create_dir_all(&dir).unwrap();
let runs = scan_runs(base).unwrap();
assert_eq!(runs.len(), 1);
assert!(runs[0].is_orphan);
assert_eq!(runs[0].status, RunStatus::Unknown);
}
#[test]
fn filter_runs_running_only_includes_starting_runs() {
let runs = vec![RunInfo {
run_id: "starting-1".into(),
dir_name: "d1".into(),
workflow_name: "[starting]".into(),
workflow_slug: None,
status: RunStatus::Running,
start_time: "2026-01-01T00:00:00Z".into(),
labels: HashMap::new(),
duration_ms: None,
total_cost: None,
host_repo_path: None,
start_time_dt: None,
end_time: None,
path: PathBuf::from("/tmp/d1"),
is_orphan: false,
}];
let filtered = filter_runs(&runs, None, None, &[], false, StatusFilter::RunningOnly);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].run_id, "starting-1");
}
}

View file

@ -1251,6 +1251,7 @@ impl WorkflowRunEngine {
// Write manifest.json (spec 5.6)
let manifest = write_manifest(&config.run_dir, graph, config);
let _ = std::fs::write(config.run_dir.join("status.txt"), "running");
// Initialize metadata branch for git-native checkpoint storage (best-effort)
if let (Some(_), Some(ref repo_path)) = (&config.meta_branch, &config.host_repo_path) {