mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-12 23:02:41 +00:00
Emit StageStarted on retry attempts (#113)
This change fixes a bug where the CLI progress UI would freeze during
stage retry attempts. When a stage fails with a transient error and the
engine retries it, the UI was never notified that a new attempt had
begun — `StageStarted` was only emitted once before the retry loop, so
subsequent attempts had no corresponding entry in `active_stages` and
all their progress events were silently dropped.
The fix moves `StageStarted` emission inside the retry loop for attempts
after the first. The first attempt's emission stays in its original
location (before the `StageStart` lifecycle hook) so that skipped nodes
still receive the event and hooks continue to fire only once. Each retry
now emits `StageStarted` with the correct `attempt` and `max_attempts`
values, which the existing `on_stage_started` handler in the progress UI
already handles correctly by inserting a fresh `ActiveStage` entry and
creating a new spinner.
A regression test is included that wires up a
`FailOnceThenSucceedHandler` — a handler that returns a retryable error
on its first call and succeeds on the second — and asserts that exactly
two `StageStarted` events are emitted for the retried node, one per
attempt. This directly encodes the invariant that every attempt,
including retries, produces a visible `StageStarted` event.
### Fabro Details
<details>
<summary>Ran 9 stages in 14m 4s for $2.78</summary>
| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 0s | – | 0 |
| preflight_compile | 1m 0s | – | 0 |
| preflight_lint | 10s | – | 0 |
| implement | 6m 38s | $1.54 | 0 |
| simplify_opus | 4m 38s | $1.23 | 0 |
| simplify_gpt | 0s | – | 0 |
| verify | 1m 10s | – | 0 |
| fmt | 0s | – | 0 |
| **Total** | **14m 4s** | **$2.78** | **0** |
</details>
<details>
<summary>Ran <code>ImplementAndSimplify.fabro</code> (12 nodes and 15
edges)</summary>
```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
}
```
</details>
⚒️ Generated with [Fabro](https://fabro.sh)
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4df605e561
commit
863ecf7c22
2 changed files with 150 additions and 58 deletions
|
|
@ -1071,12 +1071,52 @@ impl WorkflowRunEngine {
|
|||
stage_index: usize,
|
||||
visit: usize,
|
||||
asset_globs: &[String],
|
||||
run_id: &str,
|
||||
hook_work_dir: Option<&Path>,
|
||||
) -> Result<(Outcome, u32)> {
|
||||
let handler = self.services.registry.resolve(node);
|
||||
|
||||
let node_timeout = node.timeout();
|
||||
|
||||
for attempt in 1..=policy.max_attempts {
|
||||
// Run StageStart hook (blocking — can skip or block node)
|
||||
{
|
||||
let mut hook_ctx = HookContext::new(
|
||||
HookEvent::StageStart,
|
||||
run_id.to_string(),
|
||||
graph.name.clone(),
|
||||
);
|
||||
hook_ctx.cwd = hook_work_dir.map(|p| p.display().to_string());
|
||||
set_hook_node(&mut hook_ctx, node);
|
||||
hook_ctx.attempt = Some(usize::try_from(attempt).unwrap_or(usize::MAX));
|
||||
hook_ctx.max_attempts =
|
||||
Some(usize::try_from(policy.max_attempts).unwrap_or(usize::MAX));
|
||||
let decision = self.run_hooks(&hook_ctx, hook_work_dir).await;
|
||||
match decision {
|
||||
HookDecision::Skip { reason } => {
|
||||
let mut outcome = Outcome::skipped();
|
||||
outcome.notes =
|
||||
Some(reason.unwrap_or_else(|| "skipped by StageStart hook".into()));
|
||||
return Ok((outcome, attempt));
|
||||
}
|
||||
HookDecision::Block { reason } => {
|
||||
let msg = reason.unwrap_or_else(|| "blocked by StageStart hook".into());
|
||||
return Err(FabroError::engine(msg));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Emit StageStarted (fires once per attempt, only after hook passes)
|
||||
self.services.emitter.emit(&WorkflowRunEvent::StageStarted {
|
||||
node_id: node.id.clone(),
|
||||
name: node.label().to_string(),
|
||||
index: stage_index,
|
||||
handler_type: node.handler_type().map(String::from),
|
||||
script: node_script(node),
|
||||
attempt: usize::try_from(attempt).unwrap_or(usize::MAX),
|
||||
max_attempts: usize::try_from(policy.max_attempts).unwrap_or(usize::MAX),
|
||||
});
|
||||
// Floor to integer seconds: macOS stat reports mtime as integer seconds,
|
||||
// so a fractional epoch would reject files created in the same second.
|
||||
let command_start_epoch = std::time::SystemTime::now()
|
||||
|
|
@ -1849,65 +1889,13 @@ impl WorkflowRunEngine {
|
|||
context.set(context::keys::CURRENT_NODE, serde_json::json!(&node.id));
|
||||
let retry_policy = build_retry_policy(node, graph);
|
||||
|
||||
self.services.emitter.emit(&WorkflowRunEvent::StageStarted {
|
||||
node_id: node.id.clone(),
|
||||
name: node.label().to_string(),
|
||||
index: stage_index,
|
||||
handler_type: node.handler_type().map(String::from),
|
||||
script: node_script(node),
|
||||
attempt: 1,
|
||||
max_attempts: usize::try_from(retry_policy.max_attempts).unwrap_or(usize::MAX),
|
||||
});
|
||||
|
||||
// StageStart hook (blocking — can skip node)
|
||||
{
|
||||
let mut hook_ctx =
|
||||
HookContext::new(HookEvent::StageStart, run_id.clone(), graph.name.clone());
|
||||
hook_ctx.cwd = hook_work_dir.as_ref().map(|p| p.display().to_string());
|
||||
set_hook_node(&mut hook_ctx, node);
|
||||
hook_ctx.attempt = Some(1);
|
||||
hook_ctx.max_attempts =
|
||||
Some(usize::try_from(retry_policy.max_attempts).unwrap_or(usize::MAX));
|
||||
let decision = self.run_hooks(&hook_ctx, hook_work_dir.as_deref()).await;
|
||||
match decision {
|
||||
HookDecision::Skip { reason } => {
|
||||
let mut outcome = Outcome::skipped();
|
||||
outcome.notes =
|
||||
Some(reason.unwrap_or_else(|| "skipped by StageStart hook".into()));
|
||||
completed_nodes.push(node.id.clone());
|
||||
node_outcomes.insert(node.id.clone(), outcome);
|
||||
previous_node_id = Some(node.id.clone());
|
||||
stage_index += 1;
|
||||
// Select next edge and continue
|
||||
let selection = select_edge(
|
||||
&node.id,
|
||||
&Outcome::skipped(),
|
||||
&context,
|
||||
graph,
|
||||
node.selection(),
|
||||
);
|
||||
if let Some(sel) = selection {
|
||||
current_node_id = sel.edge.to.clone();
|
||||
incoming_edge = Some(sel.edge);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
HookDecision::Block { reason } => {
|
||||
let msg = reason.unwrap_or_else(|| "blocked by StageStart hook".into());
|
||||
return Err(FabroError::engine(msg));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let stage_start = Instant::now();
|
||||
|
||||
let (mut outcome, attempts_used) = if let Some((ref token, _)) = stall_token {
|
||||
tokio::select! {
|
||||
result = self.execute_with_retry(
|
||||
node, &context, graph, &config.run_dir, &retry_policy, stage_index, visit, &config.asset_globs,
|
||||
&run_id, hook_work_dir.as_deref(),
|
||||
) => result?,
|
||||
() = token.cancelled() => {
|
||||
let idle_secs = graph.stall_timeout().map_or(0, |d| d.as_secs());
|
||||
|
|
@ -1931,6 +1919,8 @@ impl WorkflowRunEngine {
|
|||
stage_index,
|
||||
visit,
|
||||
&config.asset_globs,
|
||||
&run_id,
|
||||
hook_work_dir.as_deref(),
|
||||
)
|
||||
.await?
|
||||
};
|
||||
|
|
@ -1943,7 +1933,10 @@ impl WorkflowRunEngine {
|
|||
|
||||
// Gap #1: Auto status -- when auto_status=true and outcome is non-success,
|
||||
// override to success with auto-status note
|
||||
if node.auto_status() && outcome.status != StageStatus::Success {
|
||||
if node.auto_status()
|
||||
&& outcome.status != StageStatus::Success
|
||||
&& outcome.status != StageStatus::Skipped
|
||||
{
|
||||
outcome = Outcome {
|
||||
status: StageStatus::Success,
|
||||
notes: Some(
|
||||
|
|
@ -1982,7 +1975,10 @@ impl WorkflowRunEngine {
|
|||
None
|
||||
};
|
||||
|
||||
if outcome.status == StageStatus::Fail {
|
||||
// Hook-skipped stages have no StageStarted, so skip completion events/hooks
|
||||
if outcome.status == StageStatus::Skipped {
|
||||
// No StageCompleted/StageFailed — proceed to write_node_status, edge selection, etc.
|
||||
} else if outcome.status == StageStatus::Fail {
|
||||
self.services.emitter.emit(&WorkflowRunEvent::StageFailed {
|
||||
node_id: node.id.clone(),
|
||||
name: node.label().to_string(),
|
||||
|
|
@ -5749,6 +5745,102 @@ mod tests {
|
|||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
/// Handler that returns a retryable error on the first call and succeeds on subsequent calls.
|
||||
struct FailOnceThenSucceedHandler {
|
||||
call_count: std::sync::atomic::AtomicU32,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl HandlerTrait for FailOnceThenSucceedHandler {
|
||||
async fn execute(
|
||||
&self,
|
||||
_node: &Node,
|
||||
_context: &Context,
|
||||
_graph: &Graph,
|
||||
_run_dir: &Path,
|
||||
_services: &crate::handler::EngineServices,
|
||||
) -> std::result::Result<Outcome, FabroError> {
|
||||
let n = self.call_count.fetch_add(1, Ordering::Relaxed);
|
||||
if n == 0 {
|
||||
Err(FabroError::handler("transient failure"))
|
||||
} else {
|
||||
Ok(Outcome::success())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retry_emits_stage_started_per_attempt() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut g = Graph::new("retry_events");
|
||||
g.attrs
|
||||
.insert("goal".to_string(), AttrValue::String("test".to_string()));
|
||||
|
||||
let mut start = Node::new("start");
|
||||
start.attrs.insert(
|
||||
"shape".to_string(),
|
||||
AttrValue::String("Mdiamond".to_string()),
|
||||
);
|
||||
g.nodes.insert("start".to_string(), start);
|
||||
|
||||
let mut work = Node::new("work");
|
||||
work.attrs.insert(
|
||||
"type".to_string(),
|
||||
AttrValue::String("fail_once".to_string()),
|
||||
);
|
||||
// Allow 1 retry → 2 attempts total
|
||||
work.attrs
|
||||
.insert("max_retries".to_string(), AttrValue::Integer(1));
|
||||
g.nodes.insert("work".to_string(), work);
|
||||
|
||||
let mut exit = Node::new("exit");
|
||||
exit.attrs.insert(
|
||||
"shape".to_string(),
|
||||
AttrValue::String("Msquare".to_string()),
|
||||
);
|
||||
g.nodes.insert("exit".to_string(), exit);
|
||||
|
||||
g.edges.push(Edge::new("start", "work"));
|
||||
g.edges.push(Edge::new("work", "exit"));
|
||||
|
||||
let events = Arc::new(std::sync::Mutex::new(Vec::<WorkflowRunEvent>::new()));
|
||||
let events_clone = events.clone();
|
||||
let mut emitter = EventEmitter::new();
|
||||
emitter.on_event(move |event| {
|
||||
events_clone.lock().unwrap().push(event.clone());
|
||||
});
|
||||
|
||||
let mut registry = make_registry();
|
||||
registry.register(
|
||||
"fail_once",
|
||||
Box::new(FailOnceThenSucceedHandler {
|
||||
call_count: std::sync::atomic::AtomicU32::new(0),
|
||||
}),
|
||||
);
|
||||
|
||||
let engine = WorkflowRunEngine::new(registry, Arc::new(emitter), local_env());
|
||||
let config = test_run_config(dir.path(), "retry-events-test");
|
||||
let outcome = engine.run(&g, &config).await.unwrap();
|
||||
assert_eq!(outcome.status, StageStatus::Success);
|
||||
|
||||
let collected = events.lock().unwrap();
|
||||
// Collect all StageStarted events for the "work" node
|
||||
let work_started: Vec<_> = collected
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
WorkflowRunEvent::StageStarted {
|
||||
node_id, attempt, ..
|
||||
} if node_id == "work" => Some(*attempt),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
work_started,
|
||||
vec![1, 2],
|
||||
"expected StageStarted for attempt 1 and attempt 2, got: {work_started:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_with_lifecycle_emits_events_in_order() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
|
|
|||
|
|
@ -7630,7 +7630,7 @@ async fn hook_stage_start_skip_bypasses_node() {
|
|||
"response.md should not exist when StageStart hook skips node"
|
||||
);
|
||||
|
||||
// StageStarted should have been emitted
|
||||
// StageStarted should NOT be emitted for hook-skipped stages (the stage never started)
|
||||
let captured = events.lock().unwrap();
|
||||
let stage_starts: Vec<_> = captured
|
||||
.iter()
|
||||
|
|
@ -7640,8 +7640,8 @@ async fn hook_stage_start_skip_bypasses_node() {
|
|||
})
|
||||
.collect();
|
||||
assert!(
|
||||
!stage_starts.is_empty(),
|
||||
"StageStarted should be emitted before hook skips"
|
||||
stage_starts.is_empty(),
|
||||
"StageStarted should not be emitted when StageStart hook skips"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue