From 3680a57a25d969593ae3c7c3bb199228ea788073 Mon Sep 17 00:00:00 2001
From: "brynary-fabro[bot]"
<265161896+brynary-fabro[bot]@users.noreply.github.com>
Date: Fri, 20 Mar 2026 14:08:54 -0400
Subject: [PATCH] Decompose `fabro run` into `create` / `start` / `attach`
(#116)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This PR decomposes `fabro run` into three composable primitives —
`create`, `start`, and `attach` — following the Docker-style lifecycle
model. Previously, `fabro run` performed everything in a single
monolithic function, and `--detach` was implemented by reconstructing
CLI argv to spawn a child process, which was brittle and hard to extend.
The new architecture cleanly separates concerns: `fabro create`
allocates the run directory and persists a `RunSpec` struct to
`spec.json`; `fabro start` spawns a detached `_run_engine` process (a
hidden internal command that reads `spec.json`) via `setsid`; and `fabro
attach` tails `progress.jsonl` with live rendering and handles
file-based interview IPC. `fabro run` is now a composition of these
three primitives, and `fabro run --detach` simply skips the attach step.
The main rendering work lives in a new `handle_json_line()` method on
`ProgressUI` that parses JSONL envelopes and dispatches to the same
internal rendering methods already used by the in-process event handler.
This preserves 100% rendering fidelity without duplicating
spinner/stage/tool-call logic — the attach loop just feeds file lines
into the same code paths. File-based interview IPC is handled in the
attach loop itself: it watches for `interview_request.json`, prompts the
user via `ConsoleInterviewer`, and writes `interview_response.json` back
for the engine to consume. The `hide_bars`/`show_bars` methods
previously private to `ProgressAwareInterviewer` are promoted to public
methods on `ProgressUI` and reused in both the attach loop and the
existing in-process interviewer.
The old `detach_run()` function in `main.rs`, which reconstructed argv
by string-scanning `std::env::args()`, is deleted entirely and replaced
by the `create` + `start` composition. New tests cover the
`handle_json_line` dispatch paths (stage started/completed, tool calls,
retro events, invalid input) and the CLI argument parsing for the new
command variants.
### Fabro Details
Ran 9 stages in 30m 55s for $8.55
| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 0s | – | 0 |
| preflight_compile | 1m 11s | – | 0 |
| preflight_lint | 12s | – | 0 |
| implement | 18m 15s | $5.28 | 0 |
| simplify_opus | 10m 31s | $3.27 | 0 |
| simplify_gpt | 0s | – | 0 |
| verify | 17s | – | 0 |
| fmt | 1s | – | 0 |
| **Total** | **30m 55s** | **$8.55** | **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
Co-authored-by: Bryan Helmkamp
Co-authored-by: Claude Opus 4.6 (1M context)
---
Cargo.lock | 1 +
lib/crates/fabro-cli/src/commands/attach.rs | 221 +++++++
lib/crates/fabro-cli/src/commands/create.rs | 92 +++
lib/crates/fabro-cli/src/commands/mod.rs | 5 +-
lib/crates/fabro-cli/src/commands/run.rs | 172 ++++--
.../fabro-cli/src/commands/run_progress.rs | 563 +++++++++++++++++-
lib/crates/fabro-cli/src/commands/start.rs | 113 ++++
lib/crates/fabro-cli/src/main.rs | 272 ++++++---
lib/crates/fabro-cli/tests/cli.rs | 247 ++++++++
lib/crates/fabro-interview/Cargo.toml | 1 +
lib/crates/fabro-interview/src/file.rs | 164 +++++
lib/crates/fabro-interview/src/lib.rs | 2 +
lib/crates/fabro-workflows/src/lib.rs | 1 +
lib/crates/fabro-workflows/src/run_spec.rs | 89 +++
14 files changed, 1768 insertions(+), 175 deletions(-)
create mode 100644 lib/crates/fabro-cli/src/commands/attach.rs
create mode 100644 lib/crates/fabro-cli/src/commands/create.rs
create mode 100644 lib/crates/fabro-cli/src/commands/start.rs
create mode 100644 lib/crates/fabro-interview/src/file.rs
create mode 100644 lib/crates/fabro-workflows/src/run_spec.rs
diff --git a/Cargo.lock b/Cargo.lock
index d14944a41..7f91c51ce 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1514,6 +1514,7 @@ dependencies = [
"serde_json",
"tempfile",
"tokio",
+ "tracing",
]
[[package]]
diff --git a/lib/crates/fabro-cli/src/commands/attach.rs b/lib/crates/fabro-cli/src/commands/attach.rs
new file mode 100644
index 000000000..f500b513f
--- /dev/null
+++ b/lib/crates/fabro-cli/src/commands/attach.rs
@@ -0,0 +1,221 @@
+use std::io::{BufRead, BufReader, IsTerminal};
+use std::path::Path;
+use std::process::ExitCode;
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::Arc;
+
+use anyhow::{bail, Result};
+
+use fabro_interview::ConsoleInterviewer;
+use fabro_util::terminal::Styles;
+
+use super::run_progress;
+
+/// Attach to a running (or finished) workflow run, rendering progress live.
+///
+/// Returns exit code 0 for success/partial_success, 1 otherwise.
+pub async fn attach_run(
+ run_dir: &Path,
+ kill_on_detach: bool,
+ styles: &'static Styles,
+) -> Result {
+ let progress_path = run_dir.join("progress.jsonl");
+ let conclusion_path = run_dir.join("conclusion.json");
+ let interview_request_path = run_dir.join("interview_request.json");
+ let interview_response_path = run_dir.join("interview_response.json");
+ let pid_path = run_dir.join("run.pid");
+
+ let is_tty = std::io::stderr().is_terminal();
+ let verbose = fabro_workflows::run_spec::RunSpec::load(run_dir)
+ .map(|spec| spec.verbose)
+ .unwrap_or(false);
+ let mut progress_ui = run_progress::ProgressUI::new(is_tty, verbose);
+
+ // Install Ctrl+C handler
+ let cancelled = Arc::new(AtomicBool::new(false));
+ {
+ let cancelled = Arc::clone(&cancelled);
+ tokio::spawn(async move {
+ let _ = tokio::signal::ctrl_c().await;
+ cancelled.store(true, Ordering::Relaxed);
+ });
+ }
+
+ // Wait for progress.jsonl to appear
+ let mut wait_count = 0;
+ while !progress_path.exists() {
+ tokio::time::sleep(std::time::Duration::from_millis(100)).await;
+ wait_count += 1;
+ if wait_count > 100 {
+ bail!(
+ "Timed out waiting for progress.jsonl to appear in {}",
+ run_dir.display()
+ );
+ }
+ if cancelled.load(Ordering::Relaxed) {
+ return Ok(ExitCode::from(0));
+ }
+ }
+
+ let file = std::fs::File::open(&progress_path)?;
+ let mut reader = BufReader::new(file);
+ let mut line = String::new();
+ let mut cached_pid: Option = None;
+
+ loop {
+ if cancelled.load(Ordering::Relaxed) {
+ if kill_on_detach {
+ // Kill the engine process
+ kill_engine(&pid_path);
+ // Wait briefly for conclusion
+ for _ in 0..20 {
+ if conclusion_path.exists() {
+ break;
+ }
+ tokio::time::sleep(std::time::Duration::from_millis(100)).await;
+ }
+ } else {
+ eprintln!("Detached from run (engine continues in background)");
+ }
+ break;
+ }
+
+ // Read new lines from progress.jsonl
+ loop {
+ line.clear();
+ let bytes_read = reader.read_line(&mut line)?;
+ if bytes_read == 0 {
+ break;
+ }
+ let trimmed = line.trim();
+ if !trimmed.is_empty() {
+ progress_ui.handle_json_line(trimmed);
+ }
+ }
+
+ // Check for interview request
+ if interview_request_path.exists() {
+ if let Ok(request_data) = std::fs::read_to_string(&interview_request_path) {
+ // Delete the request file immediately to prevent re-prompting
+ let _ = std::fs::remove_file(&interview_request_path);
+
+ if let Ok(question) =
+ serde_json::from_str::(&request_data)
+ {
+ // Hide progress bars during interview
+ progress_ui.hide_bars();
+
+ // Prompt user via ConsoleInterviewer
+ let interviewer = ConsoleInterviewer::new(styles);
+ let answer = fabro_interview::Interviewer::ask(&interviewer, question).await;
+
+ // Write response
+ if let Ok(response_json) = serde_json::to_string_pretty(&answer) {
+ let _ = std::fs::write(&interview_response_path, response_json);
+ }
+
+ // Show progress bars again
+ progress_ui.show_bars();
+ }
+ }
+ }
+
+ // Check if run is complete
+ if conclusion_path.exists() {
+ // Drain any remaining lines
+ drain_remaining(&mut reader, &mut line, &mut progress_ui);
+ break;
+ }
+
+ // Check if engine process is still alive (cache PID after first read)
+ let engine_alive = match cached_pid {
+ Some(pid) => process_alive(pid),
+ None => {
+ if let Ok(pid_str) = std::fs::read_to_string(&pid_path) {
+ if let Ok(pid) = pid_str.trim().parse::() {
+ cached_pid = Some(pid);
+ process_alive(pid)
+ } else {
+ true
+ }
+ } else {
+ true // no PID file yet, assume alive
+ }
+ }
+ };
+ if !engine_alive && !conclusion_path.exists() {
+ drain_remaining(&mut reader, &mut line, &mut progress_ui);
+ break;
+ }
+
+ tokio::time::sleep(std::time::Duration::from_millis(100)).await;
+ }
+
+ // Finish progress bars
+ progress_ui.finish();
+
+ // Determine exit code from conclusion
+ if conclusion_path.exists() {
+ match fabro_workflows::conclusion::Conclusion::load(&conclusion_path) {
+ Ok(conclusion) => {
+ let success = matches!(
+ conclusion.status,
+ fabro_workflows::outcome::StageStatus::Success
+ | fabro_workflows::outcome::StageStatus::PartialSuccess
+ );
+ Ok(if success {
+ ExitCode::from(0)
+ } else {
+ ExitCode::from(1)
+ })
+ }
+ Err(_) => Ok(ExitCode::from(1)),
+ }
+ } else {
+ Ok(ExitCode::from(1))
+ }
+}
+
+fn drain_remaining(
+ reader: &mut BufReader,
+ line: &mut String,
+ progress_ui: &mut run_progress::ProgressUI,
+) {
+ loop {
+ line.clear();
+ match reader.read_line(line) {
+ Ok(0) => break,
+ Ok(_) => {
+ let trimmed = line.trim();
+ if !trimmed.is_empty() {
+ progress_ui.handle_json_line(trimmed);
+ }
+ }
+ Err(_) => break,
+ }
+ }
+}
+
+fn kill_engine(pid_path: &Path) {
+ if let Ok(pid_str) = std::fs::read_to_string(pid_path) {
+ if let Ok(pid) = pid_str.trim().parse::() {
+ #[cfg(unix)]
+ unsafe {
+ libc::kill(pid, libc::SIGTERM);
+ }
+ let _ = pid;
+ }
+ }
+}
+
+fn process_alive(pid: u32) -> bool {
+ #[cfg(unix)]
+ {
+ unsafe { libc::kill(pid as i32, 0) == 0 }
+ }
+ #[cfg(not(unix))]
+ {
+ let _ = pid;
+ true
+ }
+}
diff --git a/lib/crates/fabro-cli/src/commands/create.rs b/lib/crates/fabro-cli/src/commands/create.rs
new file mode 100644
index 000000000..a226f2c0e
--- /dev/null
+++ b/lib/crates/fabro-cli/src/commands/create.rs
@@ -0,0 +1,92 @@
+use std::path::PathBuf;
+
+use chrono::Local;
+use fabro_config::run::RunDefaults;
+use fabro_workflows::run_spec::RunSpec;
+
+use super::run::{prepare_workflow, RunArgs};
+use fabro_util::terminal::Styles;
+
+/// Create a workflow run: allocate run directory, persist spec, return (run_id, run_dir).
+///
+/// This does NOT execute the workflow — it only prepares the run directory.
+pub async fn create_run(
+ args: &RunArgs,
+ run_defaults: RunDefaults,
+ styles: &Styles,
+) -> anyhow::Result<(String, PathBuf)> {
+ let workflow_path = args
+ .workflow
+ .as_ref()
+ .ok_or_else(|| anyhow::anyhow!("--workflow is required"))?;
+
+ let prep = prepare_workflow(args, run_defaults, styles)?;
+
+ let goal = prep.graph.goal();
+
+ // Create run directory
+ let run_id = ulid::Ulid::new().to_string();
+ let run_dir = args.run_dir.clone().unwrap_or_else(|| {
+ if args.dry_run {
+ std::env::temp_dir().join("fabro-dry-run").join(&run_id)
+ } else {
+ let base = dirs::home_dir()
+ .expect("could not determine home directory")
+ .join(".fabro")
+ .join("runs");
+ base.join(format!("{}-{}", Local::now().format("%Y%m%d"), run_id))
+ }
+ });
+ tokio::fs::create_dir_all(&run_dir).await?;
+
+ // Write essential files
+ tokio::fs::write(run_dir.join("graph.fabro"), &prep.source).await?;
+ tokio::fs::write(run_dir.join("id.txt"), &run_id).await?;
+ std::fs::File::create(run_dir.join("progress.jsonl"))?;
+ fabro_workflows::run_status::write_run_status(
+ &run_dir,
+ fabro_workflows::run_status::RunStatus::Submitted,
+ None,
+ );
+
+ // Save TOML config alongside the run if present
+ 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?;
+ }
+ }
+
+ // Build and save RunSpec
+ let working_directory = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
+ let spec = RunSpec {
+ run_id: run_id.clone(),
+ workflow_path: std::fs::canonicalize(workflow_path).unwrap_or(workflow_path.clone()),
+ dot_source: prep.source,
+ working_directory,
+ goal: if goal.is_empty() {
+ None
+ } else {
+ Some(goal.to_string())
+ },
+ model: prep.model,
+ provider: prep.provider,
+ sandbox_provider: prep.sandbox_provider.to_string(),
+ labels: args
+ .label
+ .iter()
+ .filter_map(|s| s.split_once('='))
+ .map(|(k, v)| (k.to_string(), v.to_string()))
+ .collect(),
+ verbose: args.verbose,
+ no_retro: args.no_retro,
+ ssh: args.ssh,
+ preserve_sandbox: args.preserve_sandbox,
+ dry_run: args.dry_run,
+ auto_approve: args.auto_approve,
+ resume: args.resume.clone(),
+ run_branch: args.run_branch.clone(),
+ };
+ spec.save(&run_dir)?;
+
+ Ok((run_id, run_dir))
+}
diff --git a/lib/crates/fabro-cli/src/commands/mod.rs b/lib/crates/fabro-cli/src/commands/mod.rs
index 6c4535f57..64dea84b1 100644
--- a/lib/crates/fabro-cli/src/commands/mod.rs
+++ b/lib/crates/fabro-cli/src/commands/mod.rs
@@ -1,5 +1,7 @@
pub mod asset;
+pub mod attach;
pub mod cp;
+pub mod create;
pub mod diff;
pub mod fork;
pub mod graph;
@@ -11,11 +13,12 @@ pub mod preview;
pub mod provider;
pub mod rewind;
pub mod run;
-mod run_progress;
+pub(crate) mod run_progress;
pub mod runs;
pub mod secret;
pub(crate) mod shared;
pub mod ssh;
+pub mod start;
pub mod validate;
pub mod wait;
pub mod workflow;
diff --git a/lib/crates/fabro-cli/src/commands/run.rs b/lib/crates/fabro-cli/src/commands/run.rs
index 8bfa9d51a..a5ba4dc05 100644
--- a/lib/crates/fabro-cli/src/commands/run.rs
+++ b/lib/crates/fabro-cli/src/commands/run.rs
@@ -10,7 +10,7 @@ use clap::{Args, ValueEnum};
use fabro_agent::{DockerSandbox, DockerSandboxConfig, LocalSandbox, Sandbox};
use fabro_config::run::{RunDefaults, WorkflowRunConfig};
use fabro_config::{project as project_config, run as run_config, sandbox as sandbox_config};
-use fabro_interview::{AutoApproveInterviewer, ConsoleInterviewer, Interviewer};
+use fabro_interview::{AutoApproveInterviewer, ConsoleInterviewer, FileInterviewer, Interviewer};
use fabro_model::Provider;
use fabro_util::terminal::Styles;
use fabro_validate::Severity;
@@ -57,6 +57,19 @@ impl From for SandboxProvider {
}
}
+impl From for CliSandboxProvider {
+ fn from(value: SandboxProvider) -> Self {
+ match value {
+ SandboxProvider::Local => Self::Local,
+ SandboxProvider::Docker => Self::Docker,
+ SandboxProvider::Daytona => Self::Daytona,
+ #[cfg(feature = "exedev")]
+ SandboxProvider::Exe => Self::Exe,
+ SandboxProvider::Ssh => Self::Ssh,
+ }
+ }
+}
+
#[derive(Args)]
pub struct RunArgs {
/// Path to a .fabro workflow file or .toml task config (not required with --run-branch)
@@ -137,7 +150,7 @@ pub struct RunArgs {
}
/// Resolve goal from `--goal` string or `--goal-file` path.
-fn resolve_cli_goal(
+pub(crate) fn resolve_cli_goal(
goal: &Option,
goal_file: &Option,
) -> anyhow::Result