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