refactor(workflow): consolidate list_events walk and dedupe test helpers

FINALIZE loaded the run event log twice: once via build_conclusion_from_store
for stage durations, then again to count ArtifactCaptured events. Merged into
a single walk feeding both the conclusion and the artifact count.

Collapsed six near-identical pipeline::execute + emit_terminal + flush blocks
in test_support into one execute_and_emit_terminal helper. Also trimmed
narrative comments that described caller ordering, control flow, or the fix
commit rather than non-obvious invariants.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-04-23 19:01:58 -04:00
parent 0e29d8edd1
commit 904c8842f0
No known key found for this signature in database
4 changed files with 47 additions and 76 deletions

View file

@ -917,7 +917,7 @@ retros = true
.expect("retro.completed should be present");
assert!(
retro_completed_index < run_completed_index,
"retro.completed must precede run.completed: run.completed is the terminal event and retro runs before FINALIZE"
"retro.completed must precede run.completed"
);
}

View file

@ -788,8 +788,6 @@ async fn execute_cancelled_mid_run_persists_cancelled_status() {
let executed = execute_test_run_with_options(run_options, g, Some(Arc::new(registry))).await;
// The status update is driven by the terminal event, which FINALIZE emits.
// This test only runs EXECUTE, so we assert on the cancellation outcome.
assert!(matches!(executed.outcome, Err(Error::Cancelled)));
}

View file

@ -101,12 +101,9 @@ fn build_conclusion_from_parts(
run_duration_ms: u64,
final_git_commit_sha: Option<String>,
) -> Conclusion {
// Dedupe by node id: looping workflows push a duplicate into
// `completed_nodes` on every revisit, but `node_outcomes`,
// `node_retries`, and `stage_durations` are all keyed by node_id with
// overwrite semantics, so duplicate rows would carry identical
// (latest-visit) values. One row per unique node keeps the table
// consistent with the deduped `billing` total below.
// Looping workflows revisit nodes; `completed_nodes` accumulates duplicates
// while the other checkpoint maps are keyed by node_id. Dedupe to one row
// per node so the stages table matches the deduped billing total.
let (stages, total_retries) = if let Some(cp) = checkpoint {
let mut stages = Vec::new();
let mut seen = std::collections::HashSet::new();
@ -152,11 +149,8 @@ fn build_conclusion_from_parts(
}
}
/// Write a finalize projection snapshot commit to the metadata branch.
///
/// `conclusion` is injected into the projection copy: the terminal event
/// hasn't been emitted yet (FINALIZE emits it after this commit lands), so
/// the run store's `projection.conclusion` is still `None`.
/// `conclusion` is injected because the terminal event hasn't been emitted
/// yet — the run store's `projection.conclusion` is still `None` at this point.
pub async fn write_finalize_commit(
run_options: &RunOptions,
run_store: &RunStoreHandle,
@ -198,12 +192,8 @@ pub async fn write_finalize_commit(
.await;
}
/// Compute the diff between the run's base sha and the workspace head.
///
/// Failed and cancelled runs use a shorter timeout: a corrupted workspace
/// must not stall the terminal event downstream consumers (Slack, SSE, CI
/// hooks) are waiting for. The diff is still captured for cancelled runs so
/// partial work stays visible after sandbox cleanup.
/// Failed and cancelled runs use a shorter diff timeout so a corrupted
/// workspace can't stall downstream consumers waiting on the terminal event.
async fn compute_final_patch(
run_options: &RunOptions,
sandbox: &dyn fabro_agent::Sandbox,
@ -230,13 +220,8 @@ async fn compute_final_patch(
}
}
/// Billing aggregate for the run. Iterates `node_outcomes.values()` to give
/// one entry per unique node — `completed_nodes` contains duplicates for
/// looping workflows and would over-count the last visit's usage.
///
/// Used by both `build_conclusion_from_parts` (persisted into
/// `Conclusion.billing`) and `build_terminal_event` so the metadata snapshot
/// and the emitted `run.completed`/`run.failed` event can never disagree.
/// Iterates `node_outcomes.values()` rather than `completed_nodes` to avoid
/// over-counting the last visit's usage on looping workflows.
pub(crate) fn billing_from_checkpoint(cp: &Checkpoint) -> Option<BilledTokenCounts> {
let usage: Vec<_> = cp
.node_outcomes
@ -285,9 +270,6 @@ pub(crate) fn build_terminal_event(
};
}
// Err(Cancelled) was handled above, so Err here is a real failure: surface
// it directly without re-wrapping in Error::engine, which would double the
// "Engine error: " prefix.
let error = match outcome {
Err(err) => err.clone(),
Ok(o) => Error::engine(
@ -338,11 +320,8 @@ async fn cleanup_sandbox(
/// FINALIZE phase: build conclusion, write the meta branch, emit the terminal
/// `WorkflowRunCompleted`/`WorkflowRunFailed` event.
///
/// The terminal event MUST be emitted from here, not from the executor's
/// `on_run_end` lifecycle hook. Observers (CLI attach, daemon SSE) treat the
/// event as the run's "done" signal — emitting it earlier means they can
/// observe terminal state and act on it (e.g. delete the meta branch in
/// recovery tests) before this function's writes are flushed.
/// The terminal event is emitted here (not from `on_run_end`) so observers
/// can't act on "done" before the meta branch writes are flushed.
///
/// # Errors
///
@ -361,27 +340,32 @@ pub async fn finalize(retroed: Retroed, options: &FinalizeOptions) -> Result<Con
} = retroed;
let (final_status, failure_reason, _run_status) = classify_engine_result(&outcome);
let conclusion = build_conclusion_from_store(
&options.run_store,
let events = options.run_store.list_events().await.unwrap_or_default();
let stage_durations = crate::extract_stage_durations_from_events(&events);
let artifact_count = events
.iter()
.filter(|envelope| matches!(envelope.event.body, EventBody::ArtifactCaptured(_)))
.count();
let checkpoint = options
.run_store
.state()
.await
.ok()
.and_then(|state| state.checkpoint);
let conclusion = build_conclusion_from_parts(
checkpoint.as_ref(),
&stage_durations,
final_status.clone(),
failure_reason,
duration_ms,
options.last_git_sha.clone(),
)
.await;
);
let final_patch = compute_final_patch(&run_options, &*sandbox, final_status, &emitter).await;
write_finalize_commit(&run_options, &options.run_store, &conclusion).await;
let artifact_count = options
.run_store
.list_events()
.await
.unwrap_or_default()
.iter()
.filter(|envelope| matches!(envelope.event.body, EventBody::ArtifactCaptured(_)))
.count();
let terminal_event = build_terminal_event(
&outcome,
duration_ms,

View file

@ -20,16 +20,17 @@ use crate::pipeline::{billing_from_checkpoint, build_terminal_event};
use crate::records::Checkpoint;
use crate::run_options::RunOptions;
/// FINALIZE emits the terminal event in production. These helpers stop at
/// EXECUTE, so they emit it here to keep test consumers seeing the same
/// end-of-run signal — including billing derived from recorded node usage.
/// These helpers stop at EXECUTE, so they emit the terminal event here to
/// keep test consumers seeing the same end-of-run signal as production
/// (FINALIZE).
///
/// Flushes `store_logger` first: `StoreProgressLogger` forwards events
/// through an mpsc channel, so the projection returned by `run_store.state()`
/// can still be missing `StageCompleted` entries when EXECUTE's `await`
/// returns. Without this, billing reads from a stale checkpoint.
async fn emit_test_terminal_event(executed: &Executed, store_logger: &StoreProgressLogger) {
store_logger.flush().await;
/// The first flush is needed because `StoreProgressLogger` forwards events
/// through an mpsc channel — without it, billing would read from a stale
/// checkpoint. The second flush ensures the just-emitted terminal event is
/// persisted before tests reopen the run store.
async fn execute_and_emit_terminal(initialized: InitializedState) -> Executed {
let executed = Box::pin(pipeline::execute(initialized.initialized)).await;
initialized.store_logger.flush().await;
let state = executed.run_store.state().await.ok();
let billing = state
.as_ref()
@ -44,6 +45,8 @@ async fn emit_test_terminal_event(executed: &Executed, store_logger: &StoreProgr
billing,
);
executed.emitter.emit(&event);
initialized.store_logger.flush().await;
executed
}
pub fn test_store_dir(run_dir: &std::path::Path) -> PathBuf {
@ -191,11 +194,7 @@ pub async fn run_graph(
},
)
.await;
let executed = pipeline::execute(initialized.initialized).await;
emit_test_terminal_event(&executed, &initialized.store_logger).await;
// Tests often reopen the run store immediately after `run()` returns.
// Flush the async store logger first so they don't observe partial state.
initialized.store_logger.flush().await;
let executed = execute_and_emit_terminal(initialized).await;
executed.outcome
}
@ -219,9 +218,7 @@ pub async fn run_graph_with_state(
},
)
.await;
let executed = pipeline::execute(initialized.initialized).await;
emit_test_terminal_event(&executed, &initialized.store_logger).await;
initialized.store_logger.flush().await;
let executed = execute_and_emit_terminal(initialized).await;
let outcome = executed.outcome?;
let state = executed
.run_store
@ -253,9 +250,7 @@ pub async fn run_graph_with_hooks(
},
)
.await;
let executed = pipeline::execute(initialized.initialized).await;
emit_test_terminal_event(&executed, &initialized.store_logger).await;
initialized.store_logger.flush().await;
let executed = execute_and_emit_terminal(initialized).await;
executed.outcome
}
@ -281,9 +276,7 @@ pub async fn run_graph_with_hooks_and_state(
},
)
.await;
let executed = pipeline::execute(initialized.initialized).await;
emit_test_terminal_event(&executed, &initialized.store_logger).await;
initialized.store_logger.flush().await;
let executed = execute_and_emit_terminal(initialized).await;
let outcome = executed.outcome?;
let state = executed
.run_store
@ -314,9 +307,7 @@ pub async fn run_graph_from_checkpoint(
},
)
.await;
let executed = pipeline::execute(initialized.initialized).await;
emit_test_terminal_event(&executed, &initialized.store_logger).await;
initialized.store_logger.flush().await;
let executed = execute_and_emit_terminal(initialized).await;
executed.outcome
}
@ -341,9 +332,7 @@ pub async fn run_graph_from_checkpoint_with_state(
},
)
.await;
let executed = pipeline::execute(initialized.initialized).await;
emit_test_terminal_event(&executed, &initialized.store_logger).await;
initialized.store_logger.flush().await;
let executed = execute_and_emit_terminal(initialized).await;
let outcome = executed.outcome?;
let state = executed
.run_store