From a176bea0821ce000924ecd0eb7d257b4efcf57e9 Mon Sep 17 00:00:00 2001
From: "brynary-fabro[bot]"
<265161896+brynary-fabro[bot]@users.noreply.github.com>
Date: Thu, 19 Mar 2026 22:48:38 -0400
Subject: [PATCH] `fabro wait` subcommand (#112)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This PR adds a `fabro wait` subcommand that blocks until a workflow run
reaches a terminal state and exits with a code reflecting the outcome —
analogous to `docker wait`. The command accepts a run ID prefix or
workflow name, polls `status.json` at a configurable interval
(defaulting to 1 second), and exits 0 on success or 1 on failure/dead.
An optional `--timeout` flag causes the command to bail with an error
message if the deadline is exceeded before the run completes.
The implementation reuses existing infrastructure throughout:
`resolve_run()` for run ID/name resolution, `RunStatusRecord::load()`
and `RunStatus::is_terminal()` for polling, `Conclusion::load()` for
retrieving duration and cost after completion, and `Styles` for colored
terminal output. Human-readable status is written to stderr (preserving
stdout for data), while `--json` mode writes structured conclusion data
to stdout. Missing status files are treated as `Dead` to handle orphaned
runs gracefully. No new dependencies were required.
The change is registered in all three necessary locations:
`commands/mod.rs`, the `Command` enum in `main.rs`, the command name
mapping, and the dispatch match arm. Unit tests cover JSON output across
all terminal states (with and without conclusion data), the
human-readable output path, immediate-terminal poll behavior, and the
missing-file fallback to `Dead`.
### Fabro Details
Ran 9 stages in 12m 13s for $2.81
| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 0s | – | 0 |
| preflight_compile | 1m 12s | – | 0 |
| preflight_lint | 13s | – | 0 |
| implement | 4m 23s | $1.47 | 0 |
| simplify_opus | 4m 22s | $1.34 | 0 |
| simplify_gpt | 0s | – | 0 |
| verify | 1m 21s | – | 0 |
| fmt | 1s | – | 0 |
| **Total** | **12m 13s** | **$2.81** | **0** |
Ran ImplementAndSimplify.fabro (12 nodes and 15
edges)
```dot
digraph ImplementAndSimplify {
graph [
goal="Implement and simplify",
model_stylesheet="
* { backend: api; model: claude-opus-4-6;}
"
]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo clippy -q --workspace -- -D warnings 2>&1", max_retries=0]
fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD."]
simplify_opus [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
simplify_gpt [label="Simplify (GPT-54)", prompt="@prompts/simplify.md", model="gpt-54"]
verify [label="Verify", shape=parallelogram, script="cargo clippy -q --workspace -- -D warnings 2>&1 && cargo nextest run --cargo-quiet --workspace --status-level fail 2>&1", goal_gate=true, retry_target="fixup"]
fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all clippy lint warnings and test failures.", max_visits=3]
fmt [label="Format", shape=parallelogram, script="cargo fmt --all 2>&1", max_retries=0]
start -> toolchain
toolchain -> preflight_compile [condition="outcome=success"]
toolchain -> exit
preflight_compile -> preflight_lint [condition="outcome=success"]
preflight_compile -> exit
preflight_lint -> implement [condition="outcome=success"]
preflight_lint -> fix_lints
fix_lints -> preflight_lint
implement -> simplify_opus -> simplify_gpt -> verify
verify -> fmt [condition="outcome=success"]
verify -> fixup
fixup -> verify
fmt -> exit
}
```
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro
---
lib/crates/fabro-cli/src/commands/mod.rs | 1 +
lib/crates/fabro-cli/src/commands/wait.rs | 242 ++++++++++++++++++++++
lib/crates/fabro-cli/src/main.rs | 7 +
3 files changed, 250 insertions(+)
create mode 100644 lib/crates/fabro-cli/src/commands/wait.rs
diff --git a/lib/crates/fabro-cli/src/commands/mod.rs b/lib/crates/fabro-cli/src/commands/mod.rs
index d7cf9c682..6c4535f57 100644
--- a/lib/crates/fabro-cli/src/commands/mod.rs
+++ b/lib/crates/fabro-cli/src/commands/mod.rs
@@ -17,4 +17,5 @@ pub mod secret;
pub(crate) mod shared;
pub mod ssh;
pub mod validate;
+pub mod wait;
pub mod workflow;
diff --git a/lib/crates/fabro-cli/src/commands/wait.rs b/lib/crates/fabro-cli/src/commands/wait.rs
new file mode 100644
index 000000000..a6054f1b2
--- /dev/null
+++ b/lib/crates/fabro-cli/src/commands/wait.rs
@@ -0,0 +1,242 @@
+use std::io::Write;
+
+use anyhow::{bail, Result};
+use clap::Args;
+use fabro_util::terminal::Styles;
+use fabro_workflows::run_status::{RunStatus, RunStatusRecord};
+use tracing::info;
+
+use super::shared::format_duration_ms;
+
+#[derive(Args)]
+pub struct WaitArgs {
+ /// Run ID prefix or workflow name (most recent run)
+ pub run: String,
+
+ /// Maximum time to wait in seconds
+ #[arg(long, value_name = "SECONDS")]
+ pub timeout: Option,
+
+ /// Poll interval in milliseconds
+ #[arg(long, value_name = "MS", default_value = "1000")]
+ pub interval: u64,
+
+ /// Output conclusion as JSON
+ #[arg(long)]
+ pub json: bool,
+}
+
+pub fn run(args: WaitArgs, styles: &Styles) -> Result<()> {
+ let base = fabro_workflows::run_lookup::default_runs_base();
+ let run_info = fabro_workflows::run_lookup::resolve_run(&base, &args.run)?;
+
+ info!(run_id = %run_info.run_id, "Waiting for run to complete");
+
+ let status_path = run_info.path.join("status.json");
+ let deadline = args
+ .timeout
+ .map(|secs| std::time::Instant::now() + std::time::Duration::from_secs(secs));
+ let interval = std::time::Duration::from_millis(args.interval);
+
+ let final_status = loop {
+ let status = match RunStatusRecord::load(&status_path) {
+ Ok(record) => record.status,
+ Err(_) => RunStatus::Dead,
+ };
+
+ if status.is_terminal() {
+ break status;
+ }
+
+ if let Some(dl) = deadline {
+ let now = std::time::Instant::now();
+ if now >= dl {
+ bail!(
+ "Timed out after {}s waiting for run '{}'",
+ args.timeout.unwrap(),
+ run_info.run_id
+ );
+ }
+ std::thread::sleep(interval.min(dl - now));
+ } else {
+ std::thread::sleep(interval);
+ }
+ };
+
+ let conclusion_path = run_info.path.join("conclusion.json");
+ let conclusion = fabro_workflows::conclusion::Conclusion::load(&conclusion_path).ok();
+
+ if args.json {
+ let json_value = build_json_output(final_status, &run_info.run_id, conclusion.as_ref());
+ let mut out = std::io::stdout().lock();
+ serde_json::to_writer_pretty(&mut out, &json_value)?;
+ writeln!(out)?;
+ } else {
+ print_human_output(final_status, &run_info.run_id, conclusion.as_ref(), styles);
+ }
+
+ if final_status == RunStatus::Succeeded {
+ Ok(())
+ } else {
+ std::process::exit(1);
+ }
+}
+
+fn build_json_output(
+ status: RunStatus,
+ run_id: &str,
+ conclusion: Option<&fabro_workflows::conclusion::Conclusion>,
+) -> serde_json::Value {
+ let mut value = serde_json::json!({
+ "run_id": run_id,
+ "status": status.to_string(),
+ });
+ if let Some(c) = conclusion {
+ value["duration_ms"] = c.duration_ms.into();
+ if let Some(cost) = c.total_cost {
+ value["total_cost"] = cost.into();
+ }
+ }
+ value
+}
+
+fn print_human_output(
+ status: RunStatus,
+ run_id: &str,
+ conclusion: Option<&fabro_workflows::conclusion::Conclusion>,
+ styles: &Styles,
+) {
+ let (style, label) = match status {
+ RunStatus::Succeeded => (&styles.bold_green, "Succeeded"),
+ RunStatus::Failed => (&styles.bold_red, "Failed"),
+ RunStatus::Dead => (&styles.bold_red, "Dead"),
+ // Poll loop only breaks on is_terminal() which is Succeeded | Failed | Dead
+ _ => unreachable!(),
+ };
+ let status_display = style.apply_to(label);
+
+ let details = match conclusion {
+ Some(c) => {
+ let duration = format_duration_ms(c.duration_ms);
+ let cost = c
+ .total_cost
+ .map(|v| format!(" ${v:.2}"))
+ .unwrap_or_default();
+ format!(" {duration}{cost}")
+ }
+ None => String::new(),
+ };
+
+ eprintln!(
+ "{} {}{details}",
+ status_display,
+ styles.dim.apply_to(run_id),
+ );
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use fabro_workflows::conclusion::Conclusion;
+ use fabro_workflows::outcome::StageStatus;
+
+ fn no_color_styles() -> Styles {
+ Styles::new(false)
+ }
+
+ #[test]
+ fn json_output_succeeded_with_conclusion() {
+ let conclusion = Conclusion {
+ timestamp: chrono::Utc::now(),
+ status: StageStatus::Success,
+ duration_ms: 12345,
+ failure_reason: None,
+ final_git_commit_sha: None,
+ stages: vec![],
+ total_cost: Some(0.42),
+ total_retries: 0,
+ };
+ let json = build_json_output(RunStatus::Succeeded, "ABC123", Some(&conclusion));
+ assert_eq!(json["run_id"], "ABC123");
+ assert_eq!(json["status"], "succeeded");
+ assert_eq!(json["duration_ms"], 12345);
+ assert!((json["total_cost"].as_f64().unwrap() - 0.42).abs() < f64::EPSILON);
+ }
+
+ #[test]
+ fn json_output_failed_without_conclusion() {
+ let json = build_json_output(RunStatus::Failed, "DEF456", None);
+ assert_eq!(json["run_id"], "DEF456");
+ assert_eq!(json["status"], "failed");
+ assert!(json.get("duration_ms").is_none());
+ assert!(json.get("total_cost").is_none());
+ }
+
+ #[test]
+ fn json_output_dead_status() {
+ let json = build_json_output(RunStatus::Dead, "GHI789", None);
+ assert_eq!(json["status"], "dead");
+ }
+
+ #[test]
+ fn json_output_no_cost_when_none() {
+ let conclusion = Conclusion {
+ timestamp: chrono::Utc::now(),
+ status: StageStatus::Fail,
+ duration_ms: 500,
+ failure_reason: Some("error".into()),
+ final_git_commit_sha: None,
+ stages: vec![],
+ total_cost: None,
+ total_retries: 0,
+ };
+ let json = build_json_output(RunStatus::Failed, "JKL012", Some(&conclusion));
+ assert!(json.get("total_cost").is_none());
+ assert_eq!(json["duration_ms"], 500);
+ }
+
+ #[test]
+ fn human_output_succeeded() {
+ let styles = no_color_styles();
+ let conclusion = Conclusion {
+ timestamp: chrono::Utc::now(),
+ status: StageStatus::Success,
+ duration_ms: 8000,
+ failure_reason: None,
+ final_git_commit_sha: None,
+ stages: vec![],
+ total_cost: Some(0.15),
+ total_retries: 0,
+ };
+ // Just verify no panic; actual stderr output is hard to capture
+ print_human_output(RunStatus::Succeeded, "ABC123", Some(&conclusion), &styles);
+ }
+
+ #[test]
+ fn human_output_failed_no_conclusion() {
+ let styles = no_color_styles();
+ print_human_output(RunStatus::Failed, "DEF456", None, &styles);
+ }
+
+ #[test]
+ fn poll_terminal_immediately() {
+ let dir = tempfile::tempdir().unwrap();
+ let status_path = dir.path().join("status.json");
+ let record = RunStatusRecord::new(RunStatus::Succeeded, None);
+ record.save(&status_path).unwrap();
+
+ // Simulate what the poll loop does
+ let status = RunStatusRecord::load(&status_path).unwrap().status;
+ assert!(status.is_terminal());
+ assert_eq!(status, RunStatus::Succeeded);
+ }
+
+ #[test]
+ fn missing_status_treated_as_dead() {
+ let status = match RunStatusRecord::load(std::path::Path::new("/nonexistent/status.json")) {
+ Ok(record) => record.status,
+ Err(_) => RunStatus::Dead,
+ };
+ assert_eq!(status, RunStatus::Dead);
+ }
+}
diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs
index 91659c72b..f43707989 100644
--- a/lib/crates/fabro-cli/src/main.rs
+++ b/lib/crates/fabro-cli/src/main.rs
@@ -149,6 +149,8 @@ enum Command {
Rewind(commands::rewind::RewindArgs),
/// Fork a workflow run from an earlier checkpoint into a new run
Fork(commands::fork::ForkArgs),
+ /// Block until a workflow run completes
+ Wait(commands::wait::WaitArgs),
/// Workflow operations
Workflow {
#[command(subcommand)]
@@ -496,6 +498,7 @@ async fn main_inner() -> (String, Result<()>) {
},
Command::Rewind(_) => "rewind",
Command::Fork(_) => "fork",
+ Command::Wait(_) => "wait",
Command::Workflow { command } => match command {
WorkflowCommand::List(_) => "workflow list",
WorkflowCommand::Create(_) => "workflow create",
@@ -886,6 +889,10 @@ async fn main_inner() -> (String, Result<()>) {
let styles = fabro_util::terminal::Styles::detect_stderr();
commands::fork::run(&args, &styles)?;
}
+ Command::Wait(args) => {
+ let styles = fabro_util::terminal::Styles::detect_stderr();
+ commands::wait::run(args, &styles)?;
+ }
Command::Workflow { command } => match command {
WorkflowCommand::List(args) => {
commands::workflow::list_command(&args)?;