fabro(01KM8C0SVZW77C5W018CYEVE4Y): implement (success)

Fabro-Run: 01KM8C0SVZW77C5W018CYEVE4Y
Fabro-Completed: 5
Fabro-Checkpoint: 7b249487c8

⚒️ Generated with [Fabro](https://fabro.sh)
This commit is contained in:
Fabro 2026-03-21 14:35:46 +00:00
parent 3295828378
commit 52a46a218b
13 changed files with 742 additions and 407 deletions

View file

@ -95,13 +95,13 @@ See [Observability](/execution/observability) for more on querying run data.
Because Fabro checkpoints after every stage, interrupted runs can be resumed from where they left off:
```bash
fabro run --resume path/to/checkpoint.json
fabro resume <RUN_ID>
```
Or resume from a git run branch:
Or resume from a checkpoint file:
```bash
fabro run --run-branch fabro/runs/abc123
fabro resume --checkpoint path/to/checkpoint.json --workflow workflow.fabro
```
The engine restores the full context, node visit counts, and retry state, then continues execution from the next node.
The engine restores the full context, node visit counts, and retry state, then continues execution from the next node.

View file

@ -98,7 +98,7 @@ There are two ways to resume an interrupted run:
Resume from a `checkpoint.json` saved in the run directory:
```bash
fabro run workflow.fabro --resume path/to/logs/checkpoint.json
fabro resume --checkpoint path/to/logs/checkpoint.json --workflow workflow.fabro
```
Fabro loads the checkpoint, restores the context and execution state, and continues from the next node after the checkpoint.
@ -108,7 +108,7 @@ Fabro loads the checkpoint, restores the context and execution state, and contin
Resume from the Git branches created during a previous run:
```bash
fabro run --run-branch fabro/run/01JKXYZ...
fabro resume 01JKXYZ
```
This reads the checkpoint, manifest, and Graphviz graph from the metadata branch (`fabro/meta/01JKXYZ...`), re-attaches a worktree to the existing run branch, and resumes execution. No workflow file argument is needed — everything is recovered from Git.
@ -163,7 +163,7 @@ fabro rewind <RUN_ID> --list
fabro rewind <RUN_ID> plan@2
# Resume from the rewound point
fabro run --run-branch fabro/run/<RUN_ID>
fabro resume <RUN_ID>
```
See [`fabro rewind`](/reference/cli#fabro-rewind) for the full command reference.
@ -180,7 +180,7 @@ fabro fork <RUN_ID> --list
fabro fork <RUN_ID> plan@2
# Resume the forked run
fabro run --run-branch fabro/run/<NEW_RUN_ID>
fabro resume <NEW_RUN_ID>
```
Use **rewind** when you want to redo a run from an earlier point (destructive — resets the original). Use **fork** when you want to try a different approach while keeping the original run as a reference.

View file

@ -42,18 +42,15 @@ Launch a workflow from a `.fabro` workflow file or `.toml` task config.
```bash
fabro run <WORKFLOW>
fabro run run.toml
fabro run --run-branch fabro/run/abc123
```
| Argument / Flag | Description |
|---|---|
| `<WORKFLOW>` | Path to a `.fabro` workflow file, `.toml` task config, or workflow name (resolved from `fabro/workflows/` in the project, then `~/.fabro/workflows/`). Not required when using `--run-branch`. |
| `<WORKFLOW>` | Path to a `.fabro` workflow file, `.toml` task config, or workflow name (resolved from `fabro/workflows/` in the project, then `~/.fabro/workflows/`). |
| `--run-dir <DIR>` | Run output directory |
| `--dry-run` | Execute with a simulated LLM backend |
| `--preflight` | Validate run configuration without executing |
| `--auto-approve` | Auto-approve all human gates |
| `--resume <FILE>` | Resume from a checkpoint file |
| `--run-branch <BRANCH>` | Resume from a git run branch (reads checkpoint and graph from metadata branch) |
| `--model <MODEL>` | Override default LLM model |
| `--provider <PROVIDER>` | Override default LLM provider |
| `-v, --verbose` | Enable verbose output |
@ -67,9 +64,37 @@ fabro run --run-branch fabro/run/abc123
| `-d, --detach` | Fork the workflow as a background process and print the run ID. Reconnect later with `fabro logs -f`. |
<Note>
`--preflight` conflicts with `--resume`, `--run-branch`, `--dry-run`, and `--detach`. `--run-branch` conflicts with `--resume`.
`--preflight` conflicts with `--dry-run` and `--detach`.
</Note>
## `fabro resume`
Resume an interrupted workflow run from its last checkpoint.
```bash
fabro resume <RUN_ID>
fabro resume <RUN_ID> --workflow updated.fabro
fabro resume --checkpoint path/to/checkpoint.json --workflow workflow.fabro
```
| Argument / Flag | Description |
|---|---|
| `<RUN_ID>` | Run ID, prefix, or branch (`fabro/run/...`). Not required when using `--checkpoint`. |
| `--checkpoint <FILE>` | Resume from a checkpoint file (requires `--workflow`) |
| `--workflow <FILE>` | Override workflow graph (required with `--checkpoint`) |
| `--run-dir <DIR>` | Run output directory |
| `--dry-run` | Execute with a simulated LLM backend |
| `--auto-approve` | Auto-approve all human gates |
| `--model <MODEL>` | Override default LLM model |
| `--provider <PROVIDER>` | Override default LLM provider |
| `-v, --verbose` | Enable verbose output |
| `--sandbox <SANDBOX>` | Sandbox for agent tools: `local`, `docker`, `daytona`, `ssh`, or `exe` |
| `--goal <GOAL>` | Override the workflow goal |
| `--goal-file <FILE>` | Read the goal from a file |
| `--no-retro` | Skip retro generation after the run |
| `--ssh` | Create SSH access to the sandbox |
| `--preserve-sandbox` | Keep the sandbox alive after the run finishes |
## `fabro ps`
List workflow runs. By default, shows only active (running) runs — similar to `docker ps`. Use `-a` to include completed runs.
@ -399,7 +424,7 @@ fabro skill install --for user --dir claude
## `fabro rewind`
Rewind a workflow run to an earlier checkpoint. This resets both the run branch and metadata branch refs so that `fabro run --run-branch` resumes from the target checkpoint.
Rewind a workflow run to an earlier checkpoint. This resets both the run branch and metadata branch refs so that `fabro resume` continues from the target checkpoint.
```bash
fabro rewind <RUN_ID> [TARGET]
@ -424,7 +449,7 @@ Target formats:
After rewinding, resume from the earlier point:
```bash
fabro run --run-branch fabro/run/<RUN_ID>
fabro resume <RUN_ID>
```
See [Checkpoints](/execution/checkpoints#rewinding-to-an-earlier-checkpoint) for background on how checkpointing works.
@ -448,7 +473,7 @@ fabro fork <RUN_ID> --list
Target formats are the same as [`fabro rewind`](#fabro-rewind). After forking, resume the new run:
```bash
fabro run --run-branch fabro/run/<NEW_RUN_ID>
fabro resume <NEW_RUN_ID>
```
See [Checkpoints — Forking a run](/execution/checkpoints#forking-a-run) for when to use fork vs. rewind.
@ -786,4 +811,4 @@ Open the Fabro Discord community invite in your default browser.
```bash
fabro discord
```
```

View file

@ -83,8 +83,6 @@ pub async fn create_run(
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)?;

View file

@ -54,9 +54,8 @@ pub fn run(args: &ForkArgs, styles: &Styles) -> Result<()> {
&new_run_id[..8.min(new_run_id.len())]
);
eprintln!(
"To resume: fabro run --run-branch {}{}",
fabro_workflows::git::RUN_BRANCH_PREFIX,
new_run_id
"To resume: fabro resume {}",
&new_run_id[..8.min(new_run_id.len())]
);
Ok(())

View file

@ -11,6 +11,7 @@ pub mod parse;
pub mod pr;
pub mod preview;
pub mod provider;
pub mod resume;
pub mod rewind;
pub mod run;
pub(crate) mod run_progress;

View file

@ -0,0 +1,604 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use anyhow::{bail, Context};
use clap::Args;
use fabro_agent::{Sandbox, WorktreeConfig, WorktreeSandbox};
use fabro_config::run::RunDefaults;
use fabro_interview::{AutoApproveInterviewer, ConsoleInterviewer, Interviewer};
use fabro_model::Provider;
use fabro_util::terminal::Styles;
use fabro_workflows::backend::{AgentApiBackend, AgentCliBackend, BackendRouter};
use fabro_workflows::checkpoint::Checkpoint;
use fabro_workflows::engine::RunConfig;
use fabro_workflows::event::EventEmitter;
use fabro_workflows::outcome::StageStatus;
use fabro_workflows::sandbox_provider::SandboxProvider;
use indicatif::HumanDuration;
use super::run::{
apply_goal_override, generate_retro, local_sandbox_with_callback, print_assets,
print_final_output, resolve_cli_goal, resolve_sandbox_provider, resolve_ssh_clone_params,
resolve_ssh_config, write_finalize_commit, CliSandboxProvider,
};
use crate::commands::shared::{print_diagnostics, tilde_path};
use fabro_config::project as project_config;
use fabro_validate::Severity;
#[derive(Debug, Args)]
pub struct ResumeArgs {
/// Run ID, prefix, or branch (fabro/run/...)
#[arg(required_unless_present = "checkpoint")]
pub run: Option<String>,
/// Resume from a checkpoint file (requires --workflow)
#[arg(long)]
pub checkpoint: Option<PathBuf>,
/// Override workflow graph (required with --checkpoint)
#[arg(long)]
pub workflow: Option<PathBuf>,
/// Run output directory
#[arg(long)]
pub run_dir: Option<PathBuf>,
/// Execute with simulated LLM backend
#[arg(long)]
pub dry_run: bool,
/// Auto-approve all human gates
#[arg(long)]
pub auto_approve: bool,
/// Override the workflow goal (exposed as $goal in prompts)
#[arg(long)]
pub goal: Option<String>,
/// Read the workflow goal from a file
#[arg(long, conflicts_with = "goal")]
pub goal_file: Option<PathBuf>,
/// Override default LLM model
#[arg(long)]
pub model: Option<String>,
/// Override default LLM provider
#[arg(long)]
pub provider: Option<String>,
/// Enable verbose output
#[arg(short, long)]
pub verbose: bool,
/// Sandbox for agent tools
#[arg(long, value_enum)]
pub sandbox: Option<CliSandboxProvider>,
/// Skip retro generation after the run
#[arg(long)]
pub no_retro: bool,
/// Create SSH access to the Daytona sandbox and print the connection command
#[arg(long)]
pub ssh: bool,
/// Keep the sandbox alive after the run finishes (for debugging)
#[arg(long)]
pub preserve_sandbox: bool,
}
/// Resume an interrupted workflow run.
///
/// # Errors
///
/// Returns an error if the run cannot be found, the checkpoint cannot be loaded,
/// or the workflow cannot be resumed.
pub async fn resume_command(
args: ResumeArgs,
run_defaults: RunDefaults,
styles: &'static Styles,
github_app: Option<fabro_github::GitHubAppCredentials>,
git_author: fabro_workflows::git::GitAuthor,
) -> anyhow::Result<()> {
// Checkpoint-file path: load checkpoint and graph from files
if let Some(ref checkpoint_path) = args.checkpoint {
let workflow_path = args
.workflow
.as_ref()
.ok_or_else(|| anyhow::anyhow!("--workflow is required when using --checkpoint"))?;
let checkpoint = Checkpoint::load(checkpoint_path)?;
let (mut graph, diagnostics) = fabro_workflows::workflow::prepare_from_file(workflow_path)?;
let cli_goal = resolve_cli_goal(&args.goal, &args.goal_file)?;
apply_goal_override(&mut graph, cli_goal.as_deref(), None);
eprintln!(
"{} {} from checkpoint {}",
styles.bold.apply_to("Resuming workflow:"),
graph.name,
styles.dim.apply_to(checkpoint_path.display()),
);
print_diagnostics(&diagnostics, styles);
if diagnostics.iter().any(|d| d.severity == Severity::Error) {
bail!("Validation failed");
}
let run_id = ulid::Ulid::new().to_string();
let run_dir = args.run_dir.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!(
"{}-{}",
chrono::Local::now().format("%Y%m%d"),
run_id
))
}
});
tokio::fs::create_dir_all(&run_dir).await?;
fabro_util::run_log::activate(&run_dir.join("cli.log"))
.context("Failed to activate per-run log")?;
let original_cwd = std::env::current_dir()?;
let emitter = Arc::new(EventEmitter::new());
let sandbox: Arc<dyn Sandbox> =
local_sandbox_with_callback(original_cwd.clone(), Arc::clone(&emitter));
let sandbox: Arc<dyn Sandbox> = Arc::new(fabro_agent::ReadBeforeWriteSandbox::new(sandbox));
let interviewer: Arc<dyn Interviewer> = if args.auto_approve {
Arc::new(AutoApproveInterviewer)
} else {
Arc::new(ConsoleInterviewer::new(styles))
};
let dry_run_mode = args.dry_run
|| fabro_llm::client::Client::from_env()
.await
.map(|c| c.provider_names().is_empty())
.unwrap_or(true);
let model = args
.model
.unwrap_or_else(|| fabro_model::default_model_from_env().id);
let provider_enum = args
.provider
.as_deref()
.map(|s| s.parse::<Provider>())
.transpose()
.map_err(|e| anyhow::anyhow!("{e}"))?
.unwrap_or_else(Provider::default_from_env);
let fallback_chain = Vec::new();
let registry = fabro_workflows::handler::default_registry(interviewer.clone(), || {
if dry_run_mode {
None
} else {
let api =
AgentApiBackend::new(model.clone(), provider_enum, fallback_chain.clone());
let cli = AgentCliBackend::new(model.clone(), provider_enum);
Some(Box::new(BackendRouter::new(Box::new(api), cli)))
}
});
let mut engine = fabro_workflows::engine::WorkflowRunEngine::with_interviewer(
registry,
Arc::clone(&emitter),
interviewer,
Arc::clone(&sandbox),
);
if dry_run_mode {
engine.set_dry_run(true);
}
let mut config = RunConfig {
run_dir: run_dir.clone(),
cancel_token: None,
dry_run: dry_run_mode,
run_id: run_id.clone(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: github_app.clone(),
git_author,
base_branch: None,
pull_request: None,
asset_globs: Vec::new(),
workflow_slug: None,
};
let lifecycle = fabro_workflows::engine::LifecycleConfig {
setup_commands: Vec::new(),
setup_command_timeout_ms: 60_000,
devcontainer_phases: Vec::new(),
};
let run_start = Instant::now();
let engine_result = engine
.run_with_lifecycle(&graph, &mut config, lifecycle, Some(&checkpoint))
.await;
let run_duration_ms = run_start.elapsed().as_millis() as u64;
if !args.no_retro && project_config::is_retro_enabled() {
let failed = match &engine_result {
Ok(ref o) => o.status == StageStatus::Fail,
Err(_) => true,
};
let llm_client = if dry_run_mode {
None
} else {
fabro_llm::client::Client::from_env().await.ok()
};
generate_retro(
&config.run_id,
&graph.name,
graph.goal(),
&run_dir,
failed,
run_duration_ms,
dry_run_mode,
llm_client.as_ref(),
&sandbox,
provider_enum,
&model,
styles,
Some(Arc::clone(&emitter)),
)
.await;
}
let _ = engine
.cleanup_sandbox(&config.run_id, &graph.name, false)
.await;
let outcome = engine_result?;
eprintln!("\n{}", styles.bold.apply_to("=== Run Result ==="));
eprintln!("{}", styles.dim.apply_to(format!("Run: {run_id}")));
let status_str = outcome.status.to_string().to_uppercase();
let status_color = match outcome.status {
StageStatus::Success | StageStatus::PartialSuccess => &styles.bold_green,
_ => &styles.bold_red,
};
eprintln!("Status: {}", status_color.apply_to(&status_str));
eprintln!(
"Duration: {}",
HumanDuration(Duration::from_millis(run_duration_ms))
);
eprintln!(
"{}",
styles
.dim
.apply_to(format!("Run: {}", tilde_path(&run_dir)))
);
print_final_output(&run_dir, styles);
print_assets(&run_dir, styles);
fabro_util::run_log::deactivate();
match outcome.status {
StageStatus::Success | StageStatus::PartialSuccess => return Ok(()),
_ => std::process::exit(1),
}
}
// Run-ID path: resolve run_id and resume from git metadata
let run_arg = args.run.as_deref().expect("run is required");
let (run_id, run_branch) =
if let Some(stripped) = run_arg.strip_prefix(fabro_workflows::git::RUN_BRANCH_PREFIX) {
let id = stripped.to_string();
let branch = run_arg.to_string();
(id, branch)
} else {
let repo = git2::Repository::discover(".").context("not in a git repository")?;
let id = fabro_workflows::run_rewind::find_run_id_by_prefix(&repo, run_arg)?;
let branch = format!("{}{}", fabro_workflows::git::RUN_BRANCH_PREFIX, id);
(id, branch)
};
let original_cwd = std::env::current_dir()?;
// Read checkpoint from metadata branch
let checkpoint = fabro_workflows::git::MetadataStore::read_checkpoint(&original_cwd, &run_id)?
.ok_or_else(|| {
anyhow::anyhow!("no checkpoint found on metadata branch for run {run_id}")
})?;
// Read graph DOT from metadata branch
let source = fabro_workflows::git::MetadataStore::read_graph_dot(&original_cwd, &run_id)?
.ok_or_else(|| {
anyhow::anyhow!("no graph.fabro found on metadata branch for run {run_id}")
})?;
// If --workflow was also provided, use it instead (allows overriding)
let (mut graph, diagnostics) = if let Some(ref workflow_path) = args.workflow {
fabro_workflows::workflow::prepare_from_file(workflow_path)?
} else {
fabro_workflows::workflow::WorkflowBuilder::new().prepare(&source)?
};
let cli_goal = resolve_cli_goal(&args.goal, &args.goal_file)?;
apply_goal_override(&mut graph, cli_goal.as_deref(), None);
eprintln!(
"{} {} from branch {} ({})",
styles.bold.apply_to("Resuming workflow:"),
graph.name,
styles.dim.apply_to(&run_branch),
run_id,
);
print_diagnostics(&diagnostics, styles);
if diagnostics.iter().any(|d| d.severity == Severity::Error) {
bail!("Validation failed");
}
// Set up logs directory
let run_dir = args.run_dir.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!(
"{}-{}",
chrono::Local::now().format("%Y%m%d"),
run_id
))
}
});
tokio::fs::create_dir_all(&run_dir).await?;
fabro_util::run_log::activate(&run_dir.join("cli.log"))
.context("Failed to activate per-run log")?;
tokio::fs::write(run_dir.join("graph.fabro"), &source).await?;
let base_sha = fabro_workflows::git::MetadataStore::read_manifest(&original_cwd, &run_id)?
.and_then(|m| m.base_sha);
// Resolve sandbox provider
let sandbox_provider = if args.dry_run {
SandboxProvider::Local
} else {
resolve_sandbox_provider(args.sandbox.map(Into::into), None, &run_defaults)?
};
let emitter = Arc::new(EventEmitter::new());
let (sandbox, _worktree_path): (Arc<dyn Sandbox>, Option<PathBuf>) = match sandbox_provider {
SandboxProvider::Local | SandboxProvider::Docker => {
// Re-attach worktree to the existing run branch via WorktreeSandbox.
let wt = run_dir.join("worktree");
let wt_str = wt.to_string_lossy().into_owned();
let inner = local_sandbox_with_callback(original_cwd.clone(), Arc::clone(&emitter));
let wt_config = WorktreeConfig {
branch_name: run_branch.clone(),
base_sha: base_sha.clone().unwrap_or_default(),
worktree_path: wt_str.clone(),
skip_branch_creation: true, // branch already exists on resume
};
let mut wt_sandbox = WorktreeSandbox::new(inner, wt_config);
wt_sandbox.set_event_callback(Arc::clone(&emitter).worktree_callback());
wt_sandbox
.initialize()
.await
.map_err(|e| anyhow::anyhow!("failed to attach worktree to {run_branch}: {e}"))?;
std::env::set_current_dir(&wt)?;
(Arc::new(wt_sandbox) as Arc<dyn Sandbox>, Some(wt))
}
#[cfg(feature = "exedev")]
SandboxProvider::Exe => {
let exe_config = super::run::resolve_exe_config(None, &run_defaults);
let clone_params = super::run::resolve_exe_clone_params(&original_cwd);
let mgmt_ssh = fabro_sandbox::exe::OpensshRunner::connect_raw("exe.dev")
.await
.map_err(|e| anyhow::anyhow!("Failed to connect to exe.dev: {e}"))?;
let config = exe_config.unwrap_or_default();
let mut env = fabro_sandbox::exe::ExeSandbox::new(
Box::new(mgmt_ssh),
config,
clone_params,
Some(run_id.clone()),
github_app.clone(),
);
let emitter_cb = Arc::clone(&emitter);
env.set_event_callback(Arc::new(move |event| {
emitter_cb.emit(&fabro_workflows::event::WorkflowRunEvent::Sandbox { event });
}));
(Arc::new(env), None)
}
SandboxProvider::Ssh => {
let config = resolve_ssh_config(None, &run_defaults)
.ok_or_else(|| anyhow::anyhow!("--sandbox ssh requires [sandbox.ssh] config"))?;
let clone_params = resolve_ssh_clone_params(&original_cwd);
let mut env = fabro_sandbox::ssh::SshSandbox::new(
config,
clone_params,
Some(run_id.clone()),
github_app.clone(),
);
let emitter_cb = Arc::clone(&emitter);
env.set_event_callback(Arc::new(move |event| {
emitter_cb.emit(&fabro_workflows::event::WorkflowRunEvent::Sandbox { event });
}));
(Arc::new(env), None)
}
SandboxProvider::Daytona => {
bail!("resume is not yet supported with --sandbox daytona");
}
};
// Wrap with ReadBeforeWriteSandbox to enforce read-before-write guard
let sandbox: Arc<dyn Sandbox> = Arc::new(fabro_agent::ReadBeforeWriteSandbox::new(sandbox));
// Let the sandbox provide any commands needed to resume on the existing run branch
let resume_setup_commands: Vec<String> = sandbox.resume_setup_commands(&run_branch);
// Build interviewer
let interviewer: Arc<dyn Interviewer> = if args.auto_approve {
Arc::new(AutoApproveInterviewer)
} else {
Arc::new(ConsoleInterviewer::new(styles))
};
// Build engine with a backend
let dry_run_mode = args.dry_run
|| fabro_llm::client::Client::from_env()
.await
.map(|c| c.provider_names().is_empty())
.unwrap_or(true);
let model = args
.model
.unwrap_or_else(|| fabro_model::default_model_from_env().id);
let provider_enum = args
.provider
.as_deref()
.map(|s| s.parse::<Provider>())
.transpose()
.map_err(|e| anyhow::anyhow!("{e}"))?
.unwrap_or_else(Provider::default_from_env);
// No fallback config available for branch resume; use empty chain.
let fallback_chain = Vec::new();
let registry = fabro_workflows::handler::default_registry(interviewer.clone(), || {
if dry_run_mode {
None
} else {
let api = AgentApiBackend::new(model.clone(), provider_enum, fallback_chain.clone());
let cli = AgentCliBackend::new(model.clone(), provider_enum);
Some(Box::new(BackendRouter::new(Box::new(api), cli)))
}
});
let mut engine = fabro_workflows::engine::WorkflowRunEngine::with_interviewer(
registry,
Arc::clone(&emitter),
interviewer,
Arc::clone(&sandbox),
);
if dry_run_mode {
engine.set_dry_run(true);
}
let meta_branch = Some(fabro_workflows::git::MetadataStore::branch_name(&run_id));
let mut config = RunConfig {
run_dir: run_dir.clone(),
cancel_token: None,
dry_run: dry_run_mode,
run_id: run_id.clone(),
git_checkpoint_enabled: true, // always true for resume (worktree or sandbox git is set up)
host_repo_path: Some(original_cwd.clone()),
base_sha,
run_branch: Some(run_branch.to_string()),
meta_branch,
labels: HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: github_app.clone(),
git_author,
base_branch: None,
pull_request: None,
asset_globs: Vec::new(),
workflow_slug: None,
};
let lifecycle = fabro_workflows::engine::LifecycleConfig {
setup_commands: resume_setup_commands,
setup_command_timeout_ms: 60_000,
devcontainer_phases: Vec::new(),
};
let run_start = Instant::now();
let engine_result = engine
.run_with_lifecycle(&graph, &mut config, lifecycle, Some(&checkpoint))
.await;
let run_duration_ms = run_start.elapsed().as_millis() as u64;
// Restore cwd (worktree is kept for `fabro cp` access; pruned separately)
let _ = std::env::set_current_dir(&original_cwd);
// Auto-derive retro
if !args.no_retro && project_config::is_retro_enabled() {
let failed = match &engine_result {
Ok(ref o) => o.status == StageStatus::Fail,
Err(_) => true,
};
let llm_client = if dry_run_mode {
None
} else {
fabro_llm::client::Client::from_env().await.ok()
};
generate_retro(
&config.run_id,
&graph.name,
graph.goal(),
&run_dir,
failed,
run_duration_ms,
dry_run_mode,
llm_client.as_ref(),
&sandbox,
provider_enum,
&model,
styles,
Some(Arc::clone(&emitter)),
)
.await;
}
// Write finalize commit with retro.json + final node files (captures last diff.patch)
write_finalize_commit(&config, &run_dir).await;
// Cleanup sandbox via engine (fires SandboxCleanup hook)
let _ = engine
.cleanup_sandbox(&config.run_id, &graph.name, false)
.await;
let outcome = engine_result?;
eprintln!("\n{}", styles.bold.apply_to("=== Run Result ==="));
eprintln!("{}", styles.dim.apply_to(format!("Run: {run_id}")));
let status_str = outcome.status.to_string().to_uppercase();
let status_color = match outcome.status {
StageStatus::Success | StageStatus::PartialSuccess => &styles.bold_green,
_ => &styles.bold_red,
};
eprintln!("Status: {}", status_color.apply_to(&status_str));
eprintln!(
"Duration: {}",
HumanDuration(Duration::from_millis(run_duration_ms))
);
eprintln!(
"{}",
styles
.dim
.apply_to(format!("Run: {}", tilde_path(&run_dir)))
);
print_final_output(&run_dir, styles);
print_assets(&run_dir, styles);
fabro_util::run_log::deactivate();
match outcome.status {
StageStatus::Success | StageStatus::PartialSuccess => Ok(()),
_ => std::process::exit(1),
}
}

View file

@ -46,9 +46,8 @@ pub fn run(args: &RewindArgs, styles: &Styles) -> Result<()> {
fabro_workflows::run_rewind::execute_rewind(&store, &run_id, entry, !args.no_push)?;
eprintln!(
"\nTo resume: fabro run --run-branch {}{}",
fabro_workflows::git::RUN_BRANCH_PREFIX,
run_id
"\nTo resume: fabro resume {}",
&run_id[..8.min(run_id.len())]
);
Ok(())

View file

@ -74,8 +74,7 @@ impl From<SandboxProvider> for CliSandboxProvider {
#[derive(Args)]
pub struct RunArgs {
/// Path to a .fabro workflow file or .toml task config (not required with --run-branch)
#[arg(required_unless_present = "run_branch")]
/// Path to a .fabro workflow file or .toml task config
pub workflow: Option<PathBuf>,
/// Run output directory
@ -87,21 +86,13 @@ pub struct RunArgs {
pub dry_run: bool,
/// Validate run configuration without executing
#[arg(long, conflicts_with_all = ["resume", "run_branch", "dry_run"])]
#[arg(long, conflicts_with = "dry_run")]
pub preflight: bool,
/// Auto-approve all human gates
#[arg(long)]
pub auto_approve: bool,
/// Resume from a checkpoint file
#[arg(long)]
pub resume: Option<PathBuf>,
/// Resume from a git run branch (reads checkpoint and graph from metadata branch)
#[arg(long, conflicts_with = "resume")]
pub run_branch: Option<String>,
/// Override the workflow goal (exposed as $goal in prompts)
#[arg(long)]
pub goal: Option<String>,
@ -143,7 +134,7 @@ pub struct RunArgs {
pub preserve_sandbox: bool,
/// Run the workflow in the background and print the run ID
#[arg(short = 'd', long, conflicts_with_all = ["resume", "run_branch", "preflight"])]
#[arg(short = 'd', long, conflicts_with = "preflight")]
pub detach: bool,
/// Pre-generated run ID (used internally by --detach)
@ -258,7 +249,7 @@ pub(crate) fn resolve_sandbox_provider(
}
/// Resolve preserve-sandbox: CLI flag > TOML config > run defaults > false.
fn resolve_preserve_sandbox(
pub(crate) fn resolve_preserve_sandbox(
cli: bool,
run_cfg: Option<&WorkflowRunConfig>,
run_defaults: &RunDefaults,
@ -310,7 +301,7 @@ fn resolve_daytona_config(
#[cfg(feature = "exedev")]
/// Resolve exe.dev config: TOML config > run defaults.
fn resolve_exe_config(
pub(crate) fn resolve_exe_config(
run_cfg: Option<&WorkflowRunConfig>,
run_defaults: &RunDefaults,
) -> Option<fabro_sandbox::exe::ExeConfig> {
@ -325,7 +316,9 @@ fn resolve_exe_config(
///
/// Returns `None` if no git repo is detected. Credential resolution is
/// handled by ExeSandbox itself via its `github_app` field.
fn resolve_exe_clone_params(cwd: &std::path::Path) -> Option<fabro_sandbox::exe::GitCloneParams> {
pub(crate) fn resolve_exe_clone_params(
cwd: &std::path::Path,
) -> Option<fabro_sandbox::exe::GitCloneParams> {
let (detected_url, branch) = match fabro_sandbox::daytona::detect_repo_info(cwd) {
Ok(info) => info,
Err(e) => {
@ -338,7 +331,7 @@ fn resolve_exe_clone_params(cwd: &std::path::Path) -> Option<fabro_sandbox::exe:
}
/// Resolve SSH sandbox config: TOML config > run defaults.
fn resolve_ssh_config(
pub(crate) fn resolve_ssh_config(
run_cfg: Option<&WorkflowRunConfig>,
run_defaults: &RunDefaults,
) -> Option<fabro_sandbox::ssh::SshConfig> {
@ -352,7 +345,9 @@ fn resolve_ssh_config(
///
/// Returns `None` if no git repo is detected. Credential resolution is
/// handled by SshSandbox itself via its `github_app` field.
fn resolve_ssh_clone_params(cwd: &std::path::Path) -> Option<fabro_sandbox::ssh::GitCloneParams> {
pub(crate) fn resolve_ssh_clone_params(
cwd: &std::path::Path,
) -> Option<fabro_sandbox::ssh::GitCloneParams> {
let (detected_url, branch) = match fabro_sandbox::daytona::detect_repo_info(cwd) {
Ok(info) => info,
Err(e) => {
@ -436,7 +431,10 @@ struct CostAccumulator {
}
/// Create a [`LocalSandbox`] wired to emit [`WorkflowRunEvent::Sandbox`] events.
fn local_sandbox_with_callback(cwd: PathBuf, emitter: Arc<EventEmitter>) -> Arc<dyn Sandbox> {
pub(crate) fn local_sandbox_with_callback(
cwd: PathBuf,
emitter: Arc<EventEmitter>,
) -> Arc<dyn Sandbox> {
let mut env = LocalSandbox::new(cwd);
env.set_event_callback(Arc::new(move |event| {
emitter.emit(&fabro_workflows::event::WorkflowRunEvent::Sandbox { event });
@ -599,11 +597,6 @@ pub async fn run_command(
github_app: Option<fabro_github::GitHubAppCredentials>,
git_author: fabro_workflows::git::GitAuthor,
) -> anyhow::Result<()> {
// Handle --run-branch resume: read everything from git metadata
if let Some(branch) = args.run_branch.clone() {
return run_from_branch(args, &branch, styles, git_author, run_defaults, github_app).await;
}
let PreparedWorkflow {
source,
graph,
@ -1464,16 +1457,9 @@ pub async fn run_command(
});
let run_start = Instant::now();
let engine_result = if let Some(ref checkpoint_path) = args.resume {
let checkpoint = Checkpoint::load(checkpoint_path)?;
engine
.run_with_lifecycle(&graph, &mut config, lifecycle, Some(&checkpoint))
.await
} else {
engine
.run_with_lifecycle(&graph, &mut config, lifecycle, None)
.await
};
let engine_result = engine
.run_with_lifecycle(&graph, &mut config, lifecycle, None)
.await;
let run_duration_ms = run_start.elapsed().as_millis() as u64;
// Restore cwd (worktree is kept for `fabro cp` access; pruned separately)
@ -1803,329 +1789,8 @@ pub async fn run_command(
}
}
/// Resume a workflow run from a git run branch.
///
/// Reads the checkpoint, manifest, and graph DOT from the metadata branch
/// (`fabro/meta/{run_id}`), re-attaches a worktree to the existing run branch,
/// and resumes execution via `run_from_checkpoint()`.
async fn run_from_branch(
args: RunArgs,
run_branch: &str,
styles: &'static Styles,
git_author: fabro_workflows::git::GitAuthor,
run_defaults: RunDefaults,
github_app: Option<fabro_github::GitHubAppCredentials>,
) -> anyhow::Result<()> {
// Extract run_id from branch name: "fabro/run/{run_id}" -> "{run_id}"
let run_id = run_branch
.strip_prefix(fabro_workflows::git::RUN_BRANCH_PREFIX)
.ok_or_else(|| {
anyhow::anyhow!(
"invalid run branch format: expected '{}<run_id>', got '{run_branch}'",
fabro_workflows::git::RUN_BRANCH_PREFIX,
)
})?
.to_string();
let original_cwd = std::env::current_dir()?;
// Read checkpoint from metadata branch
let checkpoint = fabro_workflows::git::MetadataStore::read_checkpoint(&original_cwd, &run_id)?
.ok_or_else(|| {
anyhow::anyhow!("no checkpoint found on metadata branch for run {run_id}")
})?;
// Read graph DOT from metadata branch
let source = fabro_workflows::git::MetadataStore::read_graph_dot(&original_cwd, &run_id)?
.ok_or_else(|| {
anyhow::anyhow!("no graph.fabro found on metadata branch for run {run_id}")
})?;
// If --pipeline was also provided, use it instead (allows overriding)
let (mut graph, diagnostics) = if let Some(ref workflow_path) = args.workflow {
fabro_workflows::workflow::prepare_from_file(workflow_path)?
} else {
fabro_workflows::workflow::WorkflowBuilder::new().prepare(&source)?
};
let cli_goal = resolve_cli_goal(&args.goal, &args.goal_file)?;
apply_goal_override(&mut graph, cli_goal.as_deref(), None);
eprintln!(
"{} {} from branch {} ({})",
styles.bold.apply_to("Resuming workflow:"),
graph.name,
styles.dim.apply_to(run_branch),
run_id,
);
print_diagnostics(&diagnostics, styles);
if diagnostics.iter().any(|d| d.severity == Severity::Error) {
anyhow::bail!("Validation failed");
}
// Set up logs directory
let run_dir = args.run_dir.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!(
"{}-{}",
chrono::Local::now().format("%Y%m%d"),
run_id
))
}
});
tokio::fs::create_dir_all(&run_dir).await?;
fabro_util::run_log::activate(&run_dir.join("cli.log"))
.context("Failed to activate per-run log")?;
tokio::fs::write(run_dir.join("graph.fabro"), &source).await?;
let base_sha = fabro_workflows::git::MetadataStore::read_manifest(&original_cwd, &run_id)?
.and_then(|m| m.base_sha);
// Resolve sandbox provider
let sandbox_provider = if args.dry_run {
SandboxProvider::Local
} else {
resolve_sandbox_provider(args.sandbox.map(Into::into), None, &run_defaults)?
};
let emitter = Arc::new(EventEmitter::new());
let (sandbox, _worktree_path): (Arc<dyn fabro_agent::Sandbox>, Option<PathBuf>) =
match sandbox_provider {
SandboxProvider::Local | SandboxProvider::Docker => {
// Re-attach worktree to the existing run branch via WorktreeSandbox.
let wt = run_dir.join("worktree");
let wt_str = wt.to_string_lossy().into_owned();
let inner = local_sandbox_with_callback(original_cwd.clone(), Arc::clone(&emitter));
let wt_config = WorktreeConfig {
branch_name: run_branch.to_string(),
base_sha: base_sha.clone().unwrap_or_default(),
worktree_path: wt_str.clone(),
skip_branch_creation: true, // branch already exists on resume
};
let mut wt_sandbox = WorktreeSandbox::new(inner, wt_config);
wt_sandbox.set_event_callback(Arc::clone(&emitter).worktree_callback());
wt_sandbox.initialize().await.map_err(|e| {
anyhow::anyhow!("failed to attach worktree to {run_branch}: {e}")
})?;
std::env::set_current_dir(&wt)?;
(
Arc::new(wt_sandbox) as Arc<dyn fabro_agent::Sandbox>,
Some(wt),
)
}
#[cfg(feature = "exedev")]
SandboxProvider::Exe => {
let exe_config = resolve_exe_config(None, &run_defaults);
let clone_params = resolve_exe_clone_params(&original_cwd);
let mgmt_ssh = fabro_sandbox::exe::OpensshRunner::connect_raw("exe.dev")
.await
.map_err(|e| anyhow::anyhow!("Failed to connect to exe.dev: {e}"))?;
let config = exe_config.unwrap_or_default();
let mut env = fabro_sandbox::exe::ExeSandbox::new(
Box::new(mgmt_ssh),
config,
clone_params,
Some(run_id.clone()),
github_app.clone(),
);
let emitter_cb = Arc::clone(&emitter);
env.set_event_callback(Arc::new(move |event| {
emitter_cb.emit(&fabro_workflows::event::WorkflowRunEvent::Sandbox { event });
}));
(Arc::new(env), None)
}
SandboxProvider::Ssh => {
let config = resolve_ssh_config(None, &run_defaults).ok_or_else(|| {
anyhow::anyhow!("--sandbox ssh requires [sandbox.ssh] config")
})?;
let clone_params = resolve_ssh_clone_params(&original_cwd);
let mut env = fabro_sandbox::ssh::SshSandbox::new(
config,
clone_params,
Some(run_id.clone()),
github_app.clone(),
);
let emitter_cb = Arc::clone(&emitter);
env.set_event_callback(Arc::new(move |event| {
emitter_cb.emit(&fabro_workflows::event::WorkflowRunEvent::Sandbox { event });
}));
(Arc::new(env), None)
}
SandboxProvider::Daytona => {
bail!("--run-branch resume is not yet supported with --sandbox daytona");
}
};
// Wrap with ReadBeforeWriteSandbox to enforce read-before-write guard
let sandbox: Arc<dyn fabro_agent::Sandbox> =
Arc::new(fabro_agent::ReadBeforeWriteSandbox::new(sandbox));
// Let the sandbox provide any commands needed to resume on the existing run branch
let resume_setup_commands: Vec<String> = sandbox.resume_setup_commands(run_branch);
// Build interviewer
let interviewer: Arc<dyn Interviewer> = if args.auto_approve {
Arc::new(AutoApproveInterviewer)
} else {
Arc::new(ConsoleInterviewer::new(styles))
};
// Build engine with a backend
let dry_run_mode = args.dry_run
|| fabro_llm::client::Client::from_env()
.await
.map(|c| c.provider_names().is_empty())
.unwrap_or(true);
let model = args
.model
.unwrap_or_else(|| fabro_model::default_model_from_env().id);
let provider_enum = args
.provider
.as_deref()
.map(|s| s.parse::<fabro_model::Provider>())
.transpose()
.map_err(|e| anyhow::anyhow!("{e}"))?
.unwrap_or_else(fabro_model::Provider::default_from_env);
// No fallback config available for branch resume; use empty chain.
let fallback_chain = Vec::new();
let registry = fabro_workflows::handler::default_registry(interviewer.clone(), || {
if dry_run_mode {
None
} else {
let api = AgentApiBackend::new(model.clone(), provider_enum, fallback_chain.clone());
let cli = AgentCliBackend::new(model.clone(), provider_enum);
Some(Box::new(BackendRouter::new(Box::new(api), cli)))
}
});
let mut engine = fabro_workflows::engine::WorkflowRunEngine::with_interviewer(
registry,
Arc::clone(&emitter),
interviewer,
Arc::clone(&sandbox),
);
if dry_run_mode {
engine.set_dry_run(true);
}
let meta_branch = Some(fabro_workflows::git::MetadataStore::branch_name(&run_id));
let mut config = RunConfig {
run_dir: run_dir.clone(),
cancel_token: None,
dry_run: dry_run_mode,
run_id: run_id.clone(),
git_checkpoint_enabled: true, // always true for resume (worktree or sandbox git is set up)
host_repo_path: Some(original_cwd.clone()),
base_sha,
run_branch: Some(run_branch.to_string()),
meta_branch,
labels: HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: github_app.clone(),
git_author,
base_branch: None,
pull_request: None,
asset_globs: Vec::new(),
workflow_slug: None,
};
let lifecycle = fabro_workflows::engine::LifecycleConfig {
setup_commands: resume_setup_commands,
setup_command_timeout_ms: 60_000,
devcontainer_phases: Vec::new(),
};
let run_start = Instant::now();
let engine_result = engine
.run_with_lifecycle(&graph, &mut config, lifecycle, Some(&checkpoint))
.await;
let run_duration_ms = run_start.elapsed().as_millis() as u64;
// Restore cwd (worktree is kept for `fabro cp` access; pruned separately)
let _ = std::env::set_current_dir(&original_cwd);
// Auto-derive retro
if !args.no_retro && project_config::is_retro_enabled() {
let failed = match &engine_result {
Ok(ref o) => o.status == StageStatus::Fail,
Err(_) => true,
};
let llm_client = if dry_run_mode {
None
} else {
fabro_llm::client::Client::from_env().await.ok()
};
generate_retro(
&config.run_id,
&graph.name,
graph.goal(),
&run_dir,
failed,
run_duration_ms,
dry_run_mode,
llm_client.as_ref(),
&sandbox,
provider_enum,
&model,
styles,
Some(Arc::clone(&emitter)),
)
.await;
}
// Write finalize commit with retro.json + final node files (captures last diff.patch)
write_finalize_commit(&config, &run_dir).await;
// Cleanup sandbox via engine (fires SandboxCleanup hook)
let _ = engine
.cleanup_sandbox(&config.run_id, &graph.name, false)
.await;
let outcome = engine_result?;
eprintln!("\n{}", styles.bold.apply_to("=== Run Result ==="),);
eprintln!("{}", styles.dim.apply_to(format!("Run: {run_id}")));
let status_str = outcome.status.to_string().to_uppercase();
let status_color = match outcome.status {
StageStatus::Success | StageStatus::PartialSuccess => &styles.bold_green,
_ => &styles.bold_red,
};
eprintln!("Status: {}", status_color.apply_to(&status_str),);
eprintln!(
"Duration: {}",
HumanDuration(Duration::from_millis(run_duration_ms))
);
eprintln!(
"{}",
styles
.dim
.apply_to(format!("Run: {}", tilde_path(&run_dir)))
);
print_final_output(&run_dir, styles);
print_assets(&run_dir, styles);
fabro_util::run_log::deactivate();
match outcome.status {
StageStatus::Success | StageStatus::PartialSuccess => Ok(()),
_ => std::process::exit(1),
}
}
/// Print the final stage output from the checkpoint, if available.
fn print_final_output(run_dir: &std::path::Path, styles: &Styles) {
pub(crate) fn print_final_output(run_dir: &std::path::Path, styles: &Styles) {
let Ok(checkpoint) = Checkpoint::load(&run_dir.join("checkpoint.json")) else {
return;
};
@ -2146,7 +1811,7 @@ fn print_final_output(run_dir: &std::path::Path, styles: &Styles) {
}
/// Print collected asset paths, if any.
fn print_assets(run_dir: &std::path::Path, styles: &Styles) {
pub(crate) fn print_assets(run_dir: &std::path::Path, styles: &Styles) {
let paths = fabro_workflows::asset_snapshot::collect_asset_paths(run_dir);
if paths.is_empty() {
return;
@ -2520,7 +2185,7 @@ async fn run_preflight(
///
/// This captures the last diff.patch (written after the final checkpoint) and retro.json.
/// Best-effort: errors are logged as warnings.
async fn write_finalize_commit(config: &RunConfig, run_dir: &std::path::Path) {
pub(crate) async fn write_finalize_commit(config: &RunConfig, run_dir: &std::path::Path) {
let (Some(ref meta_branch), Some(ref repo_path)) =
(&config.meta_branch, &config.host_repo_path)
else {
@ -2557,7 +2222,7 @@ async fn write_finalize_commit(config: &RunConfig, run_dir: &std::path::Path) {
/// Derives a basic retro from the checkpoint, then optionally runs the retro agent
/// for a richer narrative. Errors are logged as warnings rather than propagated.
#[allow(clippy::too_many_arguments)]
async fn generate_retro(
pub(crate) async fn generate_retro(
run_id: &str,
workflow_name: &str,
goal: &str,

View file

@ -86,8 +86,6 @@ mod tests {
preserve_sandbox: false,
dry_run: false,
auto_approve: true,
resume: None,
run_branch: None,
}
}

View file

@ -164,6 +164,8 @@ enum Command {
#[command(subcommand)]
command: SecretCommand,
},
/// Resume an interrupted workflow run
Resume(commands::resume::ResumeArgs),
/// Rewind a workflow run to an earlier checkpoint
Rewind(commands::rewind::RewindArgs),
/// Fork a workflow run from an earlier checkpoint into a new run
@ -435,6 +437,7 @@ async fn main_inner() -> (String, Result<()>) {
SecretCommand::Rm(_) => "secret rm",
SecretCommand::Set(_) => "secret set",
},
Command::Resume(_) => "resume",
Command::Rewind(_) => "rewind",
Command::Fork(_) => "fork",
Command::Wait(_) => "wait",
@ -737,8 +740,6 @@ async fn main_inner() -> (String, Result<()>) {
dry_run: spec.dry_run,
preflight: false,
auto_approve: spec.auto_approve,
resume: spec.resume,
run_branch: spec.run_branch,
goal: spec.goal,
goal_file: None,
model: Some(spec.model),
@ -919,6 +920,24 @@ async fn main_inner() -> (String, Result<()>) {
commands::secret::set_command(&args)?;
}
},
Command::Resume(args) => {
let styles: &'static fabro_util::terminal::Styles =
Box::leak(Box::new(fabro_util::terminal::Styles::detect_stderr()));
let cli_config = cli_config::load_cli_config(None)?;
let github_app = build_github_app_credentials(cli_config.app_id());
let git_author = fabro_workflows::git::GitAuthor::from_options(
cli_config.git_author().and_then(|a| a.name.clone()),
cli_config.git_author().and_then(|a| a.email.clone()),
);
commands::resume::resume_command(
args,
cli_config.run_defaults,
styles,
github_app,
git_author,
)
.await?;
}
Command::Rewind(args) => {
let styles = fabro_util::terminal::Styles::detect_stderr();
commands::rewind::run(&args, &styles)?;

View file

@ -513,19 +513,31 @@ fn detach_creates_run_dir_with_detach_log() {
);
}
// == Resume ===================================================================
#[test]
fn detach_conflicts_with_resume() {
fn resume_help_shows_expected_args() {
arc()
.args([
"run",
"--detach",
"--resume",
"/tmp/fake-checkpoint.json",
"../../../test/simple.fabro",
])
.args(["resume", "--help"])
.assert()
.failure()
.stderr(predicate::str::contains("cannot be used with"));
.success()
.stdout(predicate::str::contains("--checkpoint"))
.stdout(predicate::str::contains("--workflow"));
}
#[test]
fn resume_requires_run_or_checkpoint() {
arc().args(["resume"]).assert().failure();
}
#[test]
fn run_help_no_longer_shows_resume_or_run_branch() {
arc()
.args(["run", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("--resume").not())
.stdout(predicate::str::contains("--run-branch").not());
}
// == Bug regression: create/start/attach lifecycle ============================
@ -572,9 +584,7 @@ fn setup_run_dir(
"ssh": false,
"preserve_sandbox": false,
"dry_run": true,
"auto_approve": true,
"resume": null,
"run_branch": null
"auto_approve": true
});
if let (Some(base), Some(overrides)) = (spec.as_object_mut(), spec_overrides.as_object()) {
for (k, v) in overrides {
@ -625,9 +635,7 @@ digraph G {
"ssh": false,
"preserve_sandbox": false,
"dry_run": true,
"auto_approve": true,
"resume": null,
"run_branch": null
"auto_approve": true
});
std::fs::write(
run_dir.join("spec.json"),

View file

@ -4,6 +4,7 @@ use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct RunSpec {
pub run_id: String,
pub workflow_path: PathBuf,
@ -20,8 +21,28 @@ pub struct RunSpec {
pub preserve_sandbox: bool,
pub dry_run: bool,
pub auto_approve: bool,
pub resume: Option<PathBuf>,
pub run_branch: Option<String>,
}
impl Default for RunSpec {
fn default() -> Self {
Self {
run_id: String::new(),
workflow_path: PathBuf::new(),
dot_source: String::new(),
working_directory: PathBuf::new(),
goal: None,
model: String::new(),
provider: None,
sandbox_provider: String::new(),
labels: HashMap::new(),
verbose: false,
no_retro: false,
ssh: false,
preserve_sandbox: false,
dry_run: false,
auto_approve: false,
}
}
}
impl RunSpec {
@ -65,8 +86,6 @@ mod tests {
preserve_sandbox: false,
dry_run: false,
auto_approve: true,
resume: Some(PathBuf::from("/tmp/checkpoint")),
run_branch: Some("fabro/run/abc123".to_string()),
}
}