mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
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:
parent
c8b9436678
commit
047c2c4fef
4 changed files with 200 additions and 17 deletions
|
|
@ -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"))?;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue