Skip to main content

proef_core/
runner.rs

1//! The run orchestrator (TECH-SPEC §1, §12; ADR-0007): scenario-per-OS-thread,
2//! `--jobs`-bounded, cooperative cancellation at batch boundaries, and a
3//! heartbeat watchdog that **abandons** over-budget scenario threads (recording
4//! a `System` failure and detaching — the process reaps them at exit).
5//!
6//! Scenarios *prepare* lazily at dispatch time: `${global:key}` reads happen at
7//! lower time of the scenario (ADR-0005), so lowering + emission run inside the
8//! scenario thread against a snapshot of the shared global store; `saveAs:
9//! global` promotions merge back through the store lock when the scenario ends.
10//!
11//! Clock note: the pipeline stays clock-free (core purity); the orchestrator's
12//! monotonic-clock use is confined to budget enforcement and never enters
13//! events (durations are engine-measured).
14
15use std::collections::{BTreeMap, HashSet};
16use std::sync::mpsc;
17use std::sync::{Arc, Mutex, PoisonError};
18use std::time::{Duration, Instant};
19
20use crate::cancel::CancellationToken;
21use crate::diag::Diag;
22use crate::engine::{ArtifactRef, EngineFactory, HttpDefaults, ScenarioCtx};
23use crate::error::ExitCode;
24use crate::event::{EVENT_SCHEMA_VERSION, Event, EventSink};
25use crate::step::{Status, StepBatch, StepOutcome};
26use crate::world::{GlobalStore, World};
27
28/// Everything a scenario needs at dispatch time. Built by the CLI edge from
29/// owned/`Arc`ed data so scenario threads are `'static` (abandonable).
30pub struct ScenarioSpec {
31    /// Feature file path.
32    pub file: Arc<str>,
33    /// Scenario name (post-expansion).
34    pub name: Arc<str>,
35    /// 1-based header line.
36    pub line: usize,
37    /// Root for file bodies referenced by the scenario's entries (the feature
38    /// file's directory — hurl `context_dir` confinement, TECH-SPEC §13).
39    pub file_root: Option<std::path::PathBuf>,
40    /// Lower + emit against the live World snapshot (pure; runs in-thread).
41    pub prepare: PrepareFn,
42}
43
44/// The dispatch-time preparation: World snapshot in, batches + artifact out.
45pub type PrepareFn = Box<dyn FnOnce(&World) -> Result<Prepared, Vec<Diag>> + Send>;
46
47/// A scenario ready to execute.
48pub struct Prepared {
49    /// Engine batches in authored order.
50    pub batches: Vec<StepBatch>,
51    /// The emitted artifact (the hurl engine's executed input, ADR-0010).
52    pub artifact: Option<ArtifactRef>,
53    /// This scenario's secrets as **engine variable name → secret name**. The
54    /// two differ when a fragment binding renamed one (ADR-0018); an inline
55    /// `${secret:X}` maps `X` to itself, so this covers every secret the
56    /// scenario needs, not just the renamed ones.
57    pub secret_bindings: std::collections::BTreeMap<String, String>,
58}
59
60/// Run-level configuration.
61#[derive(Clone)]
62pub struct RunConfig {
63    /// Injected run identifier.
64    pub run_id: Arc<str>,
65    /// Parallel scenario workers.
66    pub jobs: usize,
67    /// Watchdog budget for a batch whose engine cannot estimate one.
68    pub default_batch_budget: Duration,
69    /// Secret name → value (engines inject via their redacting mechanisms).
70    pub secrets: Arc<BTreeMap<String, String>>,
71    /// Batch-level HTTP defaults.
72    pub http: HttpDefaults,
73}
74
75/// Aggregate result of a run.
76#[derive(Debug)]
77pub struct RunSummary {
78    /// Per-scenario outcomes, in completion order.
79    pub outcomes: Vec<ScenarioOutcome>,
80    /// Scenarios that passed (warnings allowed).
81    pub passed: usize,
82    /// Scenarios that failed.
83    pub failed: usize,
84    /// Scenarios skipped (cancellation).
85    pub skipped: usize,
86    /// The run was cancelled (Ctrl-C / token) — some scenarios did not run.
87    pub cancelled: bool,
88}
89
90impl RunSummary {
91    /// The run's exit code: system faults dominate, then user faults, then
92    /// test failures (ADR-0009). A cancelled run is never `Success` — the
93    /// suite did not pass; it was interrupted (folds in as a test failure).
94    pub fn exit_code(&self) -> ExitCode {
95        self.exit_code_excluding(&[])
96    }
97
98    /// [`Self::exit_code`], treating the given `(file, name)` scenarios as
99    /// non-gating (`@quarantine`): their *test-failures* no longer count toward
100    /// the exit code, but a `System`/`User` fault still does — quarantine is for
101    /// flaky tests, not broken input or infra.
102    pub fn exit_code_excluding(&self, non_gating: &[(String, String)]) -> ExitCode {
103        let mut worst = if self.cancelled {
104            ExitCode::TestFailure
105        } else {
106            ExitCode::Success
107        };
108        for outcome in &self.outcomes {
109            let quarantined = non_gating.iter().any(|(file, name)| {
110                file.as_str() == outcome.file.as_ref() && name.as_str() == outcome.name.as_ref()
111            });
112            let code = match (&outcome.fault, outcome.status) {
113                (Some(Fault::System(_)), _) => ExitCode::SystemError,
114                (Some(Fault::User(_)), _) => ExitCode::UserError,
115                (None, Status::Failed) if !quarantined => ExitCode::TestFailure,
116                _ => ExitCode::Success,
117            };
118            worst = pick_worse(worst, code);
119        }
120        worst
121    }
122}
123
124/// Prefer system errors over user errors over test failures.
125fn pick_worse(a: ExitCode, b: ExitCode) -> ExitCode {
126    let rank = |c: ExitCode| match c {
127        ExitCode::SystemError => 3,
128        ExitCode::UserError => 2,
129        ExitCode::TestFailure => 1,
130        ExitCode::Success => 0,
131    };
132    if rank(b) > rank(a) { b } else { a }
133}
134
135/// One scenario's outcome.
136#[derive(Debug)]
137pub struct ScenarioOutcome {
138    /// Feature file path.
139    pub file: Arc<str>,
140    /// Scenario name.
141    pub name: Arc<str>,
142    /// 1-based header line.
143    pub line: usize,
144    /// Aggregate status.
145    pub status: Status,
146    /// Step outcomes, in execution order.
147    pub steps: Vec<StepOutcome>,
148    /// Non-test fault, when one occurred.
149    pub fault: Option<Fault>,
150    /// Slug of the emitted artifact, when one exists (drives the CLI's
151    /// `reproduce:` line — the emitter's naming is never re-derived).
152    pub artifact_slug: Option<Arc<str>>,
153}
154
155/// A non-test fault attributed per ADR-0009.
156#[derive(Debug)]
157pub enum Fault {
158    /// The user's input is at fault (runtime resolution, missing secret, …).
159    User(String),
160    /// The environment or proef is at fault (engine infra, abandoned budget).
161    System(String),
162}
163
164enum Msg {
165    BatchBegin {
166        scenario: usize,
167        deadline: Instant,
168    },
169    Done {
170        scenario: usize,
171        outcome: ScenarioOutcome,
172    },
173}
174
175/// A scenario's run-wide identity: `(file, scenario)`, matching the pairing
176/// `identities`/`ScenarioOutcome` already use.
177type ScenarioId = (Arc<str>, Arc<str>);
178
179/// The gate's mutable state, behind one lock (see [`RecordGate`]).
180struct GateState {
181    /// Scenario identities that have been finalized — a worker's late event
182    /// for one of these is dropped rather than reaching `inner`.
183    closed: HashSet<ScenarioId>,
184    /// Set once, after `RunFinished` is written. Once `true`, nothing reaches
185    /// `inner` again, for any identity.
186    run_closed: bool,
187}
188
189/// Wraps the run's sink so a finalized scenario's late events never reach the
190/// record. An abandoned scenario's thread is detached and observes its
191/// cancellation token only at its next batch boundary (ADR-0007), so it can
192/// still try to emit after the sweep recorded its outcome — and after the run
193/// itself was finalized. The record's tail must be the tail.
194///
195/// One gate, two write paths, one lock: [`Self::scenario_sink`] hands each
196/// worker thread a sink pre-bound to its own scenario identity (so it filters
197/// without needing to inspect every event variant for `scenario`/`file`
198/// fields — not all of them carry both); [`Self::finish_scenario`] is called
199/// directly by the dispatcher thread, from both the normal completion path
200/// and `sweep_expired`, to emit a scenario's terminal event and mark it
201/// closed. Every read *and* write of [`GateState`] — including the emit
202/// itself — happens under the same `Mutex`, so "is this identity still open"
203/// and "write the event" are one atomic step rather than a check racing a
204/// concurrent close. [`Self::close_run`] is called only after `RunFinished`
205/// has already been written, per [`Self::emit_run_level`].
206#[derive(Clone)]
207struct RecordGate {
208    inner: EventSink,
209    state: Arc<Mutex<GateState>>,
210}
211
212impl RecordGate {
213    fn new(inner: EventSink) -> Self {
214        Self {
215            inner,
216            state: Arc::new(Mutex::new(GateState {
217                closed: HashSet::new(),
218                run_closed: false,
219            })),
220        }
221    }
222
223    /// A sink bound to one scenario's identity, handed to its worker thread.
224    /// Every event passed through it is dropped once the run is closed, or
225    /// once this scenario has been finalized via [`Self::finish_scenario`] —
226    /// including when that happened on the *dispatcher* thread (the watchdog
227    /// sweep), racing ahead of this scenario's own thread. The state lock is
228    /// held across the emit itself: a check-then-act (read the flag, drop
229    /// the lock, then emit) would leave a window for a worker to be
230    /// preempted right there and still write after the tail — the emit is
231    /// part of the same critical section as the check, not a step after it.
232    fn scenario_sink(&self, file: Arc<str>, scenario: Arc<str>) -> EventSink {
233        let inner = self.inner.clone();
234        let state = Arc::clone(&self.state);
235        let id: ScenarioId = (file, scenario);
236        EventSink::new(move |event| {
237            let guard = state.lock().unwrap_or_else(PoisonError::into_inner);
238            if guard.run_closed || guard.closed.contains(&id) {
239                return;
240            }
241            inner.emit(event);
242            // `guard` drops here, after the emit — not before it.
243        })
244    }
245
246    /// Emit a scenario's terminal `ScenarioFinished` and mark it closed under
247    /// one lock acquisition, so no [`Self::scenario_sink`] call for this
248    /// identity — nor a concurrent [`Self::close_run`] — can land between the
249    /// two: the terminal event and the closing of its identity are atomic.
250    /// Called from both the dispatcher's normal completion path and
251    /// `sweep_expired` — the two places a scenario is ever finalized.
252    fn finish_scenario(&self, event: &Event, file: &Arc<str>, scenario: &Arc<str>) {
253        let mut guard = self.state.lock().unwrap_or_else(PoisonError::into_inner);
254        if !guard.run_closed {
255            self.inner.emit(event);
256        }
257        guard
258            .closed
259            .insert((Arc::clone(file), Arc::clone(scenario)));
260    }
261
262    /// Emit an event that carries no single-scenario identity (`RunStarted`,
263    /// `RunFinished`) — gated only by the run-level close.
264    fn emit_run_level(&self, event: &Event) {
265        let guard = self.state.lock().unwrap_or_else(PoisonError::into_inner);
266        if !guard.run_closed {
267            self.inner.emit(event);
268        }
269    }
270
271    /// Shut the gate for good. Call only after `RunFinished` has already
272    /// been written via [`Self::emit_run_level`] — the tail must be written
273    /// before the gate closes, never the other way around.
274    fn close_run(&self) {
275        self.state
276            .lock()
277            .unwrap_or_else(PoisonError::into_inner)
278            .run_closed = true;
279    }
280}
281
282/// Execute `specs` and return the summary. Emits the full event stream on
283/// `events` (`RunStarted` … `RunFinished` — ADR-0008).
284// One cohesive listing of the dispatch/watchdog loop; splitting hides the order.
285#[allow(clippy::too_many_lines)]
286pub fn run(
287    specs: Vec<ScenarioSpec>,
288    engines: &Arc<Vec<Box<dyn EngineFactory>>>,
289    store: &Arc<Mutex<GlobalStore>>,
290    config: &RunConfig,
291    events: &EventSink,
292    cancel: &CancellationToken,
293) -> RunSummary {
294    // Every emission in this function goes through the gate, never `events`
295    // directly (one mechanism deciding what reaches the record — see
296    // `RecordGate`), so late writes from an abandoned scenario's detached
297    // thread can never slip past it.
298    let gate = RecordGate::new(events.clone());
299    gate.emit_run_level(&Event::RunStarted {
300        schema: EVENT_SCHEMA_VERSION,
301        run_id: Arc::clone(&config.run_id),
302    });
303
304    let (tx, rx) = mpsc::channel::<Msg>();
305    // Identities survive the specs' move into the queue, so an abandoned
306    // scenario is reported as itself, never as a synthetic placeholder.
307    let identities: Vec<(Arc<str>, Arc<str>, usize)> = specs
308        .iter()
309        .map(|spec| (Arc::clone(&spec.file), Arc::clone(&spec.name), spec.line))
310        .collect();
311    let mut queue: std::collections::VecDeque<(usize, ScenarioSpec)> =
312        specs.into_iter().enumerate().collect();
313    let total = queue.len();
314    // Deadline plus the scenario's child cancellation token — retained so
315    // abandonment can cancel the detached thread's work instead of leaving it
316    // appending events forever (record hygiene; engines gain real stop points
317    // as they support cancellation).
318    let mut active: BTreeMap<usize, (Instant, CancellationToken)> = BTreeMap::new();
319    let mut outcomes: Vec<ScenarioOutcome> = Vec::new();
320    let grace = Duration::from_secs(2);
321
322    while outcomes.len() < total {
323        // Fill free slots.
324        while active.len() < config.jobs.max(1) {
325            let Some((index, spec)) = queue.pop_front() else {
326                break;
327            };
328            if cancel.is_cancelled() {
329                gate.finish_scenario(
330                    &Event::ScenarioFinished {
331                        scenario: Arc::clone(&spec.name),
332                        file: Arc::clone(&spec.file),
333                        status: Status::Skipped,
334                        timestamp_ms: None,
335                        worker: None,
336                        phase: None,
337                    },
338                    &spec.file,
339                    &spec.name,
340                );
341                outcomes.push(ScenarioOutcome {
342                    file: spec.file,
343                    name: spec.name,
344                    line: spec.line,
345                    status: Status::Skipped,
346                    steps: Vec::new(),
347                    fault: None,
348                    artifact_slug: None,
349                });
350                continue;
351            }
352            let initial_deadline = Instant::now() + config.default_batch_budget + grace;
353            let child = cancel.child_token();
354            active.insert(index, (initial_deadline, child.clone()));
355            let scenario_events =
356                gate.scenario_sink(Arc::clone(&spec.file), Arc::clone(&spec.name));
357            spawn_scenario(
358                index,
359                spec,
360                Arc::clone(engines),
361                Arc::clone(store),
362                config.clone(),
363                scenario_events,
364                child,
365                tx.clone(),
366            );
367        }
368        if active.is_empty() {
369            continue; // only skipped scenarios remained in the queue
370        }
371
372        // Wait for progress, bounded by the earliest active deadline.
373        let now = Instant::now();
374        let next_deadline = active
375            .values()
376            .map(|(deadline, _)| *deadline)
377            .min()
378            .unwrap_or(now + grace);
379        let wait = next_deadline
380            .saturating_duration_since(now)
381            .max(Duration::from_millis(20));
382        match rx.recv_timeout(wait) {
383            Ok(Msg::BatchBegin { scenario, deadline }) => {
384                if let Some(entry) = active.get_mut(&scenario) {
385                    entry.0 = deadline + grace;
386                }
387            }
388            Ok(Msg::Done { scenario, outcome }) => {
389                if active.remove(&scenario).is_some() {
390                    gate.finish_scenario(
391                        &Event::ScenarioFinished {
392                            scenario: Arc::clone(&outcome.name),
393                            file: Arc::clone(&outcome.file),
394                            status: outcome.status,
395                            timestamp_ms: None,
396                            worker: None,
397                            phase: None,
398                        },
399                        &outcome.file,
400                        &outcome.name,
401                    );
402                    outcomes.push(outcome);
403                }
404                // else: a previously-abandoned thread finished late — ignored.
405            }
406            Err(mpsc::RecvTimeoutError::Timeout) => {}
407            Err(mpsc::RecvTimeoutError::Disconnected) => break,
408        }
409        // Sweep expired deadlines on *every* turn of the loop — a steady
410        // stream of messages from healthy scenarios must not keep a hung
411        // one alive past its budget.
412        sweep_expired(&mut active, &mut outcomes, &gate, &identities);
413    }
414
415    let passed = outcomes
416        .iter()
417        .filter(|o| matches!(o.status, Status::Passed | Status::Warned))
418        .count();
419    let failed = outcomes
420        .iter()
421        .filter(|o| o.status == Status::Failed)
422        .count();
423    let skipped = outcomes
424        .iter()
425        .filter(|o| o.status == Status::Skipped)
426        .count();
427    let cancelled = cancel.is_cancelled();
428    gate.emit_run_level(&Event::RunFinished {
429        passed,
430        failed,
431        skipped,
432        cancelled,
433    });
434    // The tail is written — only now does the gate shut. Any scenario thread
435    // still detached out there (an abandoned one, cooperatively cancelled but
436    // not yet reaped, ADR-0007) can no longer reach the sink at all.
437    gate.close_run();
438    RunSummary {
439        outcomes,
440        passed,
441        failed,
442        skipped,
443        cancelled,
444    }
445}
446
447/// Abandon every scenario whose deadline has passed: cancel its child token,
448/// record a `System` fault, and detach the thread (ADR-0007 — the process
449/// reaps it at exit; the cancelled token is the thread's cooperative signal
450/// to stop). Cooperation is not guaranteed by every engine, so `gate` is the
451/// actual backstop: `finish_scenario` closes this scenario's identity right
452/// here, before the detached thread can notice its token and try to keep
453/// appending to the record.
454fn sweep_expired(
455    active: &mut BTreeMap<usize, (Instant, CancellationToken)>,
456    outcomes: &mut Vec<ScenarioOutcome>,
457    gate: &RecordGate,
458    identities: &[(Arc<str>, Arc<str>, usize)],
459) {
460    let now = Instant::now();
461    let expired: Vec<usize> = active
462        .iter()
463        .filter(|(_, (deadline, _))| *deadline <= now)
464        .map(|(index, _)| *index)
465        .collect();
466    for index in expired {
467        if let Some((_, token)) = active.remove(&index) {
468            token.cancel();
469        }
470        // `active` keys are spec indices and `identities` is built 1:1 from
471        // the same specs — the lookup cannot miss.
472        let (file, name, line) = &identities[index];
473        let outcome = ScenarioOutcome {
474            file: Arc::clone(file),
475            name: Arc::clone(name),
476            line: *line,
477            status: Status::Failed,
478            steps: Vec::new(),
479            fault: Some(Fault::System(
480                "batch budget exceeded — scenario thread abandoned (ADR-0007)".to_owned(),
481            )),
482            artifact_slug: None,
483        };
484        gate.finish_scenario(
485            &Event::ScenarioFinished {
486                scenario: Arc::clone(&outcome.name),
487                file: Arc::clone(&outcome.file),
488                status: Status::Failed,
489                timestamp_ms: None,
490                worker: None,
491                phase: None,
492            },
493            &outcome.file,
494            &outcome.name,
495        );
496        outcomes.push(outcome);
497    }
498}
499
500#[allow(clippy::too_many_arguments)]
501fn spawn_scenario(
502    index: usize,
503    spec: ScenarioSpec,
504    engines: Arc<Vec<Box<dyn EngineFactory>>>,
505    store: Arc<Mutex<GlobalStore>>,
506    config: RunConfig,
507    events: EventSink,
508    cancel: CancellationToken,
509    tx: mpsc::Sender<Msg>,
510) {
511    std::thread::spawn(move || {
512        let identity = (Arc::clone(&spec.file), Arc::clone(&spec.name), spec.line);
513        let heartbeat_tx = tx.clone();
514        // A panicking engine or prepare closure must never look like a hang:
515        // contain it, report a System fault under the real identity, and let
516        // the dispatcher move on immediately instead of waiting out the budget.
517        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
518            run_scenario(
519                spec,
520                &engines,
521                &store,
522                &config,
523                &events,
524                &cancel,
525                |budget| {
526                    let _ = heartbeat_tx.send(Msg::BatchBegin {
527                        scenario: index,
528                        deadline: Instant::now() + budget,
529                    });
530                },
531            )
532        }));
533        let outcome = result.unwrap_or_else(|panic| {
534            let message = panic
535                .downcast_ref::<&str>()
536                .map(ToString::to_string)
537                .or_else(|| panic.downcast_ref::<String>().cloned())
538                .unwrap_or_else(|| "opaque panic payload".to_owned());
539            ScenarioOutcome {
540                file: identity.0,
541                name: identity.1,
542                line: identity.2,
543                status: Status::Failed,
544                steps: Vec::new(),
545                fault: Some(Fault::System(format!(
546                    "scenario thread panicked: {message}"
547                ))),
548                artifact_slug: None,
549            }
550        });
551        let _ = tx.send(Msg::Done {
552            scenario: index,
553            outcome,
554        });
555    });
556}
557
558// One cohesive listing of the scenario lifecycle; splitting hides the order.
559#[allow(clippy::too_many_lines)]
560fn run_scenario(
561    spec: ScenarioSpec,
562    engines: &[Box<dyn EngineFactory>],
563    store: &Mutex<GlobalStore>,
564    config: &RunConfig,
565    events: &EventSink,
566    cancel: &CancellationToken,
567    heartbeat: impl Fn(Duration),
568) -> ScenarioOutcome {
569    let (file, name, line) = (Arc::clone(&spec.file), Arc::clone(&spec.name), spec.line);
570    let outcome = move |status, steps, fault, artifact_slug| ScenarioOutcome {
571        file: Arc::clone(&file),
572        name: Arc::clone(&name),
573        line,
574        status,
575        steps,
576        fault,
577        artifact_slug,
578    };
579
580    events.emit(&Event::ScenarioStarted {
581        scenario: Arc::clone(&spec.name),
582        file: Arc::clone(&spec.file),
583        timestamp_ms: None,
584        worker: None,
585        phase: None,
586    });
587
588    // Prepare against a snapshot of the shared globals (lower-time reads).
589    let snapshot = match store.lock() {
590        Ok(guard) => guard.clone(),
591        Err(_) => {
592            return outcome(
593                Status::Failed,
594                Vec::new(),
595                Some(Fault::System("global store lock poisoned".to_owned())),
596                None,
597            );
598        }
599    };
600    let mut world = World::new(snapshot);
601    let prepared = match (spec.prepare)(&world) {
602        Ok(prepared) => prepared,
603        Err(diags) => {
604            let detail = diags
605                .iter()
606                .map(|d| d.message.clone())
607                .collect::<Vec<_>>()
608                .join("; ");
609            return outcome(Status::Failed, Vec::new(), Some(Fault::User(detail)), None);
610        }
611    };
612
613    let mut sessions: Vec<(String, Box<dyn crate::engine::EngineSession>)> = Vec::new();
614    let mut steps: Vec<StepOutcome> = Vec::new();
615    let mut fault: Option<Fault> = None;
616    let mut failed = false;
617
618    let mut interrupted = false;
619    let mut processed = 0usize;
620    for batch in &prepared.batches {
621        if failed {
622            break;
623        }
624        if cancel.is_cancelled() {
625            // Batches remain — the scenario did not run to completion and must
626            // not report `Passed` (a cancelled run would otherwise exit 0).
627            interrupted = true;
628            break;
629        }
630        let engine_id = batch.engine.as_str().to_owned();
631        if !sessions.iter().any(|(id, _)| *id == engine_id) {
632            let Some(factory) = engines.iter().find(|f| f.id() == engine_id) else {
633                fault = Some(Fault::System(format!(
634                    "no engine registered for `{engine_id}`"
635                )));
636                failed = true;
637                break;
638            };
639            let ctx = ScenarioCtx {
640                run_id: Arc::clone(&config.run_id),
641                scenario: Arc::clone(&spec.name),
642                artifact: prepared.artifact.clone(),
643                secrets: Arc::clone(&config.secrets),
644                secret_bindings: Arc::new(prepared.secret_bindings.clone()),
645                http: config.http,
646                file_root: spec.file_root.clone(),
647            };
648            match factory.open(&ctx) {
649                Ok(session) => sessions.push((engine_id.clone(), session)),
650                Err(err) => {
651                    fault = Some(Fault::System(format!(
652                        "cannot open engine `{engine_id}`: {err}"
653                    )));
654                    failed = true;
655                    break;
656                }
657            }
658        }
659        let Some((_, session)) = sessions.iter_mut().find(|(id, _)| *id == engine_id) else {
660            break; // unreachable: just ensured above
661        };
662
663        let budget = session
664            .batch_budget(batch)
665            .unwrap_or(config.default_batch_budget);
666        heartbeat(budget);
667        events.emit(&Event::BatchStarted {
668            scenario: Arc::clone(&spec.name),
669            engine: Arc::from(engine_id.as_str()),
670            steps: batch.steps.len(),
671        });
672
673        let result = session.run_batch(batch, &mut world, events, cancel);
674        let all_optional = batch.steps.iter().all(|s| s.optional);
675        for mut step_outcome in result.steps {
676            if step_outcome.status == Status::Failed && all_optional {
677                step_outcome.status = Status::Warned;
678            }
679            steps.push(step_outcome);
680        }
681        if let Some(err) = result.error {
682            if all_optional {
683                // `optional:` — warn and continue (segmentation isolates it).
684                // The batch WAS dispatched and its steps already carry real
685                // outcomes: count it, or the unreached-steps loop below would
686                // re-report it as Skipped on top of them (ADR-0008).
687                processed += 1;
688                continue;
689            }
690            match err.class {
691                crate::error::EngineErrorClass::AssertFailed => {}
692                crate::error::EngineErrorClass::UserInput => {
693                    fault = Some(Fault::User(err.message.clone()));
694                }
695                crate::error::EngineErrorClass::Infra | crate::error::EngineErrorClass::Setup => {
696                    fault = Some(Fault::System(err.message.clone()));
697                }
698            }
699            failed = true;
700        }
701        processed += 1;
702    }
703
704    // Every authored step gets an outcome: batches never dispatched (earlier
705    // failure or cancellation) report their steps as Skipped instead of
706    // silently vanishing from console, record, and JUnit alike.
707    let unreached_reason = if interrupted {
708        "not run (run cancelled)"
709    } else {
710        "not run (an earlier step failed)"
711    };
712    for batch in prepared.batches.iter().skip(processed) {
713        for step in &batch.steps {
714            events.emit(&Event::StepFinished {
715                scenario: Arc::clone(&spec.name),
716                engine: Arc::from(batch.engine.as_str()),
717                step: step.step.clone(),
718                status: Status::Skipped,
719                attempts: 0,
720                duration_ms: 0,
721                captures: Vec::new(),
722                // A step that never ran still says where it would have run
723                // from: "not run" is exactly when a reader is reconstructing
724                // what the suite was about to do.
725                fragment: step.fragment.clone(),
726                detail: Some(unreached_reason.to_owned()),
727                attempt_details: Vec::new(),
728            });
729            steps.push(StepOutcome {
730                step: step.step.clone(),
731                status: Status::Skipped,
732                attempts: 0,
733                duration: std::time::Duration::ZERO,
734                detail: Some(unreached_reason.to_owned()),
735                attempt_details: Vec::new(),
736                reproduce_hint: None,
737                fragment: step.fragment.clone(),
738            });
739        }
740    }
741
742    for (engine_id, session) in sessions.iter_mut().rev() {
743        if let Err(err) = session.finish()
744            && fault.is_none()
745        {
746            // Teardown failure on an otherwise-clean scenario is a real infra
747            // signal (never silently swallowed); a scenario that already
748            // failed keeps its primary fault.
749            fault = Some(Fault::System(format!(
750                "engine `{engine_id}` teardown failed: {}",
751                err.message
752            )));
753        }
754    }
755
756    // Merge global promotions back through the store lock (§12) — the write
757    // set only. Writing the whole world back would clobber keys another
758    // scenario promoted after this one took its snapshot (lost update).
759    // Poison recovery is sound here: store inserts are plain map writes with
760    // no cross-key invariant a panicked holder could have torn — dropping the
761    // write set instead would silently lose `saveAs: global` promotions from
762    // a scenario that reports Passed.
763    {
764        let mut guard = store
765            .lock()
766            .unwrap_or_else(std::sync::PoisonError::into_inner);
767        for (key, value) in world.promotions() {
768            guard.insert(key, value.clone());
769        }
770    }
771
772    let status = if failed || steps.iter().any(|s| s.status == Status::Failed) {
773        Status::Failed
774    } else if interrupted {
775        Status::Skipped
776    } else {
777        Status::Passed
778    };
779    let artifact_slug = prepared
780        .artifact
781        .as_ref()
782        .map(|artifact| Arc::clone(&artifact.slug));
783    outcome(status, steps, fault, artifact_slug)
784}