diff --git a/src/arts/orca/README.md b/src/arts/orca/README.md index 15867dd..8e2bd3a 100644 --- a/src/arts/orca/README.md +++ b/src/arts/orca/README.md @@ -25,7 +25,7 @@ que el acoplamiento inter-modulo quede escondido en `buss`, `connection` o ## Estado Del Documento Este README es la referencia de diseño de orca. La **v0-kernel** está -implementada y testeada (114 archivos / 1357 tests pasan, 47 de ellos +implementada y testeada (114 archivos / 1373 tests pasan, 63 de ellos sobre orca). El kernel expone: - `createEngineOrca({ bus, timers, logger?, maxRuns?, reentry? })` @@ -33,14 +33,22 @@ sobre orca). El kernel expone: - Stages canónicos (`guard / pre / main / post / cleanup / finally`) - `OrcaEnvelope` interno + `OrcaActionContext` con `eventId`, `traceId`, `parentEventId`, `depth`, `signal`, `emit()`, `tokens` -- `OrcaResult`: `success / skipped / error / interrupted` (`fatal`, - `timeout` aceptados en el tipo, no producidos por v0) +- `OrcaResult`: `success / skipped / error / interrupted / timeout / fatal` - `OrcaRunResult` con `eventId`, `traceId`, `parentEventId`, `depth` - Reentry guards: `maxDepth`, `maxEventsPerTrace`, `repeatedEventLimit` con políticas `skip / abort-trace / error` - `dedupeKey` por trace +- **`actionTimeoutMs` honored:** orca corre la acción contra un timer + del scheduler inyectado; al expirar aborta el `signal` y produce + `ORCA_RESULT_TIMEOUT`. Cada acción tiene su propio `AbortController` + encadenado al del run, así un timeout no aborta acciones hermanas. +- **`OrcaFatal`:** distinto de `OrcaError`; aborta el run sin importar + `onError` (precedencia: `FATAL > TIMEOUT > ABORTED > INTERRUPTED > + PARTIAL > SUCCESS`). El stage `FINALLY` sigue ejecutándose tras + fatal. - Diagnostics estructurados (`orca.run.*`, `orca.action.*`, - `orca.event.emitted`, `orca.reentry.blocked`, `orca.trace.aborted`) + `orca.event.emitted`, `orca.reentry.blocked`, `orca.trace.aborted`, + `orca.action.fatal`, `orca.action.timeout`) - `applyStandardOrca(App)` agregador de presets en `arts/active-app/presets/` @@ -1321,10 +1329,8 @@ Marcado como `@v1+` en el código fuente: | Pieza | Qué falta para v1 | |---|---| -| `actionTimeoutMs` | Ejecutar la acción con timeout real (`signal.abort()` por timer); producir `ORCA_RESULT_TIMEOUT`. | | `compensate` | Invocar la función compensatoria cuando el run aborta tras un éxito previo. | | `after` / `unless` / `abortOn` | Honrar los gates de tokens entre acciones. | -| `OrcaFatal` | Distinguir fatal de error en políticas downstream. | | `commit()` / `replace` | Políticas de cola por evento (drop-prev / replace / parallel). | | `transaction` (atómico) | Grupos atómicos cuyo fallo lanza compensaciones en orden inverso. | | `parallel` | Ejecución concurrente dentro de un mismo stage cuando no hay `after`. | @@ -1333,3 +1339,13 @@ Marcado como `@v1+` en el código fuente: | Bus interception (Opción B) | Atribuir `emittedByAction` perfecto cuando un módulo hace `bus.publish` durante un run. | | `validate()` / `commit()` configuración | Validación estática del grafo (ciclos, tokens imposibles, IDs duplicados). | | `createActiveOrca()` | Wrapper reactivo (`$state` para `running`, `recentRuns`, etc.) — el `EngineOrca` ya está. | + +### Ya en el motor (de v1) + +- ✅ **`actionTimeoutMs`** — el motor corre la acción contra un timer + del scheduler inyectado; al expirar aborta el `signal` y produce + `ORCA_RESULT_TIMEOUT`. Las acciones hermanas siguen su curso. +- ✅ **`OrcaFatal`** — `orcaFatal(error)` produce + `ORCA_ACTION_STATUS_FATAL` y aborta el run inmediatamente sin + consultar `onError`. El stage `FINALLY` se ejecuta de todas formas. + Run status `ORCA_RUN_FATAL` con precedencia sobre cualquier otro. diff --git a/src/arts/orca/consts.ts b/src/arts/orca/consts.ts index 8eb5b46..45eebc4 100644 --- a/src/arts/orca/consts.ts +++ b/src/arts/orca/consts.ts @@ -109,6 +109,8 @@ export const ORCA_DIAGNOSTIC_EVENTS = { ACTION_STARTED: 'orca.action.started', ACTION_COMPLETED: 'orca.action.completed', ACTION_FAILED: 'orca.action.failed', + ACTION_FATAL: 'orca.action.fatal', + ACTION_TIMEOUT: 'orca.action.timeout', ACTION_SKIPPED: 'orca.action.skipped', ACTION_INTERRUPTED: 'orca.action.interrupted', EVENT_EMITTED: 'orca.event.emitted', @@ -125,6 +127,8 @@ export const ORCA_LOG_MSG_RUN_ABORTED = 'orca run aborted'; export const ORCA_LOG_MSG_ACTION_STARTED = 'orca action started'; export const ORCA_LOG_MSG_ACTION_COMPLETED = 'orca action completed'; export const ORCA_LOG_MSG_ACTION_FAILED = 'orca action failed'; +export const ORCA_LOG_MSG_ACTION_FATAL = 'orca action fatal — run aborted'; +export const ORCA_LOG_MSG_ACTION_TIMEOUT = 'orca action exceeded actionTimeoutMs'; export const ORCA_LOG_MSG_ACTION_SKIPPED = 'orca action skipped'; export const ORCA_LOG_MSG_ACTION_INTERRUPTED = 'orca action interrupted'; export const ORCA_LOG_MSG_EVENT_EMITTED = 'orca derived event emitted'; diff --git a/src/arts/orca/diagnostics.ts b/src/arts/orca/diagnostics.ts index 4f6b869..1168105 100644 --- a/src/arts/orca/diagnostics.ts +++ b/src/arts/orca/diagnostics.ts @@ -10,9 +10,11 @@ import { ORCA_DIAGNOSTIC_EVENTS, ORCA_LOG_MSG_ACTION_COMPLETED, ORCA_LOG_MSG_ACTION_FAILED, + ORCA_LOG_MSG_ACTION_FATAL, ORCA_LOG_MSG_ACTION_INTERRUPTED, ORCA_LOG_MSG_ACTION_SKIPPED, ORCA_LOG_MSG_ACTION_STARTED, + ORCA_LOG_MSG_ACTION_TIMEOUT, ORCA_LOG_MSG_CONFIGURATION_INVALID, ORCA_LOG_MSG_EVENT_EMITTED, ORCA_LOG_MSG_REENTRY_BLOCKED, @@ -79,6 +81,12 @@ export type OrcaDiagnosticMeta = readonly actionId: OrcaActionId; readonly error: unknown; readonly thrown?: boolean; + readonly fatal?: boolean; + }) + | (OrcaDiagnosticEnvelopeRef & { + readonly runId: OrcaRunId; + readonly actionId: OrcaActionId; + readonly timeoutMs: number; }) | (OrcaDiagnosticEnvelopeRef & { readonly runId: OrcaRunId; @@ -137,6 +145,14 @@ const ORCA_DIAGNOSTIC_LOGS: DiagnosticCatalog = { level: LogLevel.ERROR, message: ORCA_LOG_MSG_ACTION_FAILED }, + [ORCA_DIAGNOSTIC_EVENTS.ACTION_FATAL]: { + level: LogLevel.FATAL, + message: ORCA_LOG_MSG_ACTION_FATAL + }, + [ORCA_DIAGNOSTIC_EVENTS.ACTION_TIMEOUT]: { + level: LogLevel.WARN, + message: ORCA_LOG_MSG_ACTION_TIMEOUT + }, [ORCA_DIAGNOSTIC_EVENTS.ACTION_SKIPPED]: { level: LogLevel.DEBUG, message: ORCA_LOG_MSG_ACTION_SKIPPED diff --git a/src/arts/orca/engine-orca.ts b/src/arts/orca/engine-orca.ts index 0a72bcc..bcd0f40 100644 --- a/src/arts/orca/engine-orca.ts +++ b/src/arts/orca/engine-orca.ts @@ -30,9 +30,11 @@ import { ORCA_ACTION_STATUS_BLOCKED, ORCA_ACTION_STATUS_ERROR, + ORCA_ACTION_STATUS_FATAL, ORCA_ACTION_STATUS_INTERRUPTED, ORCA_ACTION_STATUS_SKIPPED, ORCA_ACTION_STATUS_SUCCESS, + ORCA_ACTION_STATUS_TIMEOUT, ORCA_DEFAULT_MAX_DEPTH, ORCA_DEFAULT_MAX_EVENTS_PER_TRACE, ORCA_DEFAULT_MAX_RUNS, @@ -55,10 +57,13 @@ import { ORCA_RESULT_INTERRUPTED, ORCA_RESULT_SKIPPED, ORCA_RESULT_SUCCESS, + ORCA_RESULT_TIMEOUT, ORCA_RUN_ABORTED, + ORCA_RUN_FATAL, ORCA_RUN_INTERRUPTED, ORCA_RUN_PARTIAL, ORCA_RUN_SUCCESS, + ORCA_RUN_TIMEOUT, ORCA_STAGE_FINALLY, ORCA_STAGES_CANONICAL_ORDER } from './consts.ts'; @@ -70,6 +75,7 @@ import { OrcaReentryError } from './errors.ts'; import { createOrcaDiagnostics, emitOrcaDiagnostic } from './diagnostics.ts'; +import { orcaTimeout } from './result.ts'; import type { EngineOrca, EngineOrcaOptions, @@ -424,18 +430,46 @@ export function createEngineOrca(options: EngineOrcaOptions): EngineOrca { continue; } + // Per-action AbortController, chained to the run-level + // controller. Lets `actionTimeoutMs` abort just this + // action without aborting the whole run. + const actionController = new AbortController(); + const onRunAbort = () => actionController.abort(); + if (controller.signal.aborted) actionController.abort(); + else controller.signal.addEventListener('abort', onRunAbort); + const context = buildActionContext( runId, envelope, action.stage, tokens, - controller.signal, + actionController.signal, action.id ); - const actionRun = await runAction(action, envelope.payload, context); + const actionRun = await runAction( + action, + envelope.payload, + context, + actionController + ); + controller.signal.removeEventListener('abort', onRunAbort); actionRuns.push(actionRun); + if (actionRun.status === ORCA_ACTION_STATUS_FATAL) { + // Fatal always aborts the run, regardless of onError. + aborted = true; + controller.abort(); + emitOrcaDiagnostic(diagnostics, ORCA_DIAGNOSTIC_EVENTS.RUN_ABORTED, { + runId, + event: envelope.event, + eventId: envelope.meta.eventId, + traceId: envelope.meta.traceId, + depth: envelope.meta.depth, + cause: actionRun.error + }); + break; + } if (actionRun.status === ORCA_ACTION_STATUS_ERROR) { const policy = action.onError ?? ORCA_ON_ERROR_CONTINUE; if (policy === ORCA_ON_ERROR_ABORT_RUN) { @@ -551,10 +585,52 @@ export function createEngineOrca(options: EngineOrcaOptions): EngineOrca { traceStates.delete(traceId); } + /** + * Race the action's promise against a timer scheduled on the App's + * `TimerScheduler`. When the timer fires first, abort the action's + * signal and resolve with `orcaTimeout(timeoutMs)`. The action's + * promise continues running in the background; if it eventually + * resolves it is discarded — the run trace records the timeout. + * + * Without `actionTimeoutMs` (or with `actionTimeoutMs <= 0`) the + * action runs unmediated. + */ + async function runActionWithTimeout( + action: OrcaAction, + payload: unknown, + context: OrcaActionContext, + actionController: AbortController + ): Promise { + const timeoutMs = action.actionTimeoutMs; + if (!timeoutMs || timeoutMs <= 0) { + return await action.action(payload, context); + } + const timeoutKey = `orca:timeout:${context.runId}:${action.id}`; + let timeoutFired = false; + let timerHandle: { cancel: () => boolean } | undefined; + const timeoutPromise = new Promise((resolve) => { + const handle = timers.schedule(timeoutKey, timeoutMs, () => { + timeoutFired = true; + actionController.abort(); + resolve(orcaTimeout(timeoutMs)); + }); + timerHandle = handle; + }); + const actionPromise = (async () => { + try { + return await action.action(payload, context); + } finally { + if (!timeoutFired && timerHandle) timerHandle.cancel(); + } + })(); + return Promise.race([actionPromise, timeoutPromise]); + } + async function runAction( action: OrcaAction, payload: unknown, - context: OrcaActionContext + context: OrcaActionContext, + actionController: AbortController ): Promise { const startedAt = timers.clock.now(); @@ -582,14 +658,33 @@ export function createEngineOrca(options: EngineOrcaOptions): EngineOrca { } try { - const result = await action.action(payload, context); + const result = await runActionWithTimeout(action, payload, context, actionController); const endedAt = timers.clock.now(); const emitted = result.emits ?? []; for (const token of emitted) (context.tokens as Set).add(token); const status = mapResultToActionStatus(result); - if (status === ORCA_ACTION_STATUS_ERROR) { + if (status === ORCA_ACTION_STATUS_FATAL) { + emitOrcaDiagnostic(diagnostics, ORCA_DIAGNOSTIC_EVENTS.ACTION_FATAL, { + runId: context.runId, + actionId: action.id, + eventId: context.eventId, + traceId: context.traceId, + depth: context.depth, + error: (result as { error?: unknown }).error, + fatal: true + }); + } else if (status === ORCA_ACTION_STATUS_TIMEOUT) { + emitOrcaDiagnostic(diagnostics, ORCA_DIAGNOSTIC_EVENTS.ACTION_TIMEOUT, { + runId: context.runId, + actionId: action.id, + eventId: context.eventId, + traceId: context.traceId, + depth: context.depth, + timeoutMs: (result as { timeoutMs?: number }).timeoutMs ?? 0 + }); + } else if (status === ORCA_ACTION_STATUS_ERROR) { emitOrcaDiagnostic(diagnostics, ORCA_DIAGNOSTIC_EVENTS.ACTION_FAILED, { runId: context.runId, actionId: action.id, @@ -636,7 +731,8 @@ export function createEngineOrca(options: EngineOrcaOptions): EngineOrca { durationMs: endedAt - startedAt, emitted: Array.from(emitted), error: - status === ORCA_ACTION_STATUS_ERROR + status === ORCA_ACTION_STATUS_ERROR || + status === ORCA_ACTION_STATUS_FATAL ? (result as { error?: unknown }).error : undefined, interruptedReason: @@ -740,20 +836,33 @@ function mapResultToActionStatus(result: OrcaResult) { case ORCA_RESULT_INTERRUPTED: return ORCA_ACTION_STATUS_INTERRUPTED; case ORCA_RESULT_FATAL: - // v0 treats fatal as error. - return ORCA_ACTION_STATUS_ERROR; + return ORCA_ACTION_STATUS_FATAL; + case ORCA_RESULT_TIMEOUT: + return ORCA_ACTION_STATUS_TIMEOUT; default: - // timeout is not produced in v0; defensive fallback. + // Defensive fallback for an unknown status. return ORCA_ACTION_STATUS_ERROR; } } +/** + * Run-status precedence: + * FATAL > TIMEOUT > ABORTED > INTERRUPTED > PARTIAL > SUCCESS + * + * Fatal is the loudest because it represents broken state (engine + * cannot trust the rest of the pipeline). Timeout means the action + * exceeded its budget; aborted means a CONTINUE-from-error policy + * triggered ORCA_ON_ERROR_ABORT_RUN. Interrupted is reserved for + * trace-aborted runs and authored `orcaInterrupted()`. + */ function computeRunStatus( aborted: boolean, actions: OrcaActionRun[], envelope: OrcaEnvelope, traceStates: Map ) { + if (actions.some((a) => a.status === ORCA_ACTION_STATUS_FATAL)) return ORCA_RUN_FATAL; + if (actions.some((a) => a.status === ORCA_ACTION_STATUS_TIMEOUT)) return ORCA_RUN_TIMEOUT; if (aborted) return ORCA_RUN_ABORTED; const trace = traceStates.get(envelope.meta.traceId); if (trace?.aborted && trace.abortedReason !== ORCA_REENTRY_REASON_RUN_ABORTED) { diff --git a/src/arts/orca/test/engine-orca.test.ts b/src/arts/orca/test/engine-orca.test.ts index 38469bf..a07bbef 100644 --- a/src/arts/orca/test/engine-orca.test.ts +++ b/src/arts/orca/test/engine-orca.test.ts @@ -822,27 +822,6 @@ describe('EngineOrca v0.0 — accepted-but-ignored fields (forward-compat)', () expect(calls).toEqual(['ran']); }); - it('accepts actionTimeoutMs without enforcing timeout', async () => { - const orca = createEngineOrca({ bus, timers }); - - orca.onEvent('e1', { - id: 'slow', - stage: ORCA_STAGE_MAIN, - actionTimeoutMs: 1, // ignored - action: async () => { - await sleep(5); - return orcaSuccess(); - } - }); - - bus.publish('e1', null); - await sleep(20); - await flush(); - - const runs = orca.recentRuns(); - expect(runs[0].actions[0].status).toBe(ORCA_ACTION_STATUS_SUCCESS); - }); - it('accepts compensate without invoking it', async () => { const orca = createEngineOrca({ bus, timers }); const compensate = vi.fn(() => orcaSuccess()); @@ -1465,3 +1444,278 @@ describe('EngineOrca v0 — diagnostics for v0 features', () => { }); }); +// ── v1: actionTimeoutMs and OrcaFatal ───────────────────────────────── + +describe('EngineOrca v1 — actionTimeoutMs', () => { + let bus: FakeBus; + let realTimers: ReturnType; + + beforeEach(async () => { + bus = createFakeBus(); + const { createEngineTimers } = await import('../../timer/engine-timers.ts'); + realTimers = createEngineTimers(); + }); + + afterEach(() => { + realTimers.dispose(); + }); + + it('produces ORCA_RESULT_TIMEOUT when an action exceeds its timeout', async () => { + const orca = createEngineOrca({ bus, timers: realTimers }); + + orca.onEvent('slow', { + id: 'long', + stage: ORCA_STAGE_MAIN, + actionTimeoutMs: 5, + action: async () => { + await sleep(50); + return orcaSuccess(); + } + }); + + bus.publish('slow', null); + await sleep(20); + await flush(); + + const runs = orca.recentRuns(); + expect(runs).toHaveLength(1); + expect(runs[0].actions[0].status).toBe('timeout'); + expect(runs[0].status).toBe('timeout'); + }); + + it('runs to completion when the action finishes before its timeout', async () => { + const orca = createEngineOrca({ bus, timers: realTimers }); + + orca.onEvent('fast', { + id: 'quick', + stage: ORCA_STAGE_MAIN, + actionTimeoutMs: 500, + action: async () => { + await sleep(2); + return orcaSuccess(); + } + }); + + bus.publish('fast', null); + await sleep(20); + await flush(); + + const runs = orca.recentRuns(); + expect(runs[0].actions[0].status).toBe(ORCA_ACTION_STATUS_SUCCESS); + expect(runs[0].status).toBe(ORCA_RUN_SUCCESS); + }); + + it('aborts the action signal when the timeout fires', async () => { + const orca = createEngineOrca({ bus, timers: realTimers }); + let signalAborted = false; + + orca.onEvent('slow', { + id: 'cooperative', + stage: ORCA_STAGE_MAIN, + actionTimeoutMs: 5, + action: async (_p, ctx) => { + await sleep(50); + signalAborted = ctx.signal.aborted; + return orcaSuccess(); + } + }); + + bus.publish('slow', null); + await sleep(20); + await flush(); + await sleep(60); + await flush(); + + expect(signalAborted).toBe(true); + }); + + it('emits orca.action.timeout diagnostic with timeoutMs', async () => { + const entries: DiagnosticEntry[] = []; + const orca = createEngineOrca({ + bus, + timers: realTimers, + logger: createCapturingLogger(entries) as Parameters< + typeof createEngineOrca + >[0]['logger'] + }); + + orca.onEvent('slow', { + id: 'long', + stage: ORCA_STAGE_MAIN, + actionTimeoutMs: 5, + action: async () => { + await sleep(50); + return orcaSuccess(); + } + }); + + bus.publish('slow', null); + await sleep(20); + await flush(); + + const timeout = entries.find((e) => e.type === 'orca.action.timeout'); + expect(timeout).toBeDefined(); + expect(timeout?.meta.timeoutMs).toBe(5); + expect(timeout?.meta.actionId).toBe('long'); + }); + + it('lets later actions in the same stage proceed after a timeout', async () => { + const orca = createEngineOrca({ bus, timers: realTimers }); + let secondRan = false; + + orca.onEvent('mixed', { + id: 'first', + stage: ORCA_STAGE_MAIN, + actionTimeoutMs: 5, + action: async () => { + await sleep(50); + return orcaSuccess(); + } + }); + orca.onEvent('mixed', { + id: 'second', + stage: ORCA_STAGE_MAIN, + action: () => { + secondRan = true; + return orcaSuccess(); + } + }); + + bus.publish('mixed', null); + await sleep(20); + await flush(); + + expect(secondRan).toBe(true); + }); +}); + +describe('EngineOrca v1 — OrcaFatal', () => { + let bus: FakeBus; + let timers: ReturnType; + + beforeEach(() => { + bus = createFakeBus(); + timers = createFakeTimers(); + }); + + it('maps OrcaFatal to ORCA_ACTION_STATUS_FATAL', async () => { + const orca = createEngineOrca({ bus, timers }); + const { orcaFatal } = await import('../result.ts'); + + orca.onEvent('e', { + id: 'broken', + stage: ORCA_STAGE_MAIN, + action: () => orcaFatal(new Error('cannot continue')) + }); + + bus.publish('e', null); + await flush(); + + const runs = orca.recentRuns(); + expect(runs[0].actions[0].status).toBe('fatal'); + expect(runs[0].actions[0].error).toBeInstanceOf(Error); + }); + + it('aborts the run regardless of onError policy when fatal fires', async () => { + const orca = createEngineOrca({ bus, timers }); + const { orcaFatal } = await import('../result.ts'); + let secondRan = false; + + orca.onEvent('e', { + id: 'fatal-action', + stage: ORCA_STAGE_MAIN, + onError: ORCA_ON_ERROR_CONTINUE, // would normally let the run proceed + action: () => orcaFatal(new Error('fatal')) + }); + orca.onEvent('e', { + id: 'should-be-blocked', + stage: ORCA_STAGE_MAIN, + action: () => { + secondRan = true; + return orcaSuccess(); + } + }); + + bus.publish('e', null); + await flush(); + + expect(secondRan).toBe(false); + const runs = orca.recentRuns(); + expect(runs[0].status).toBe('fatal'); + }); + + it('still runs the FINALLY stage after a fatal abort', async () => { + const orca = createEngineOrca({ bus, timers }); + const { orcaFatal } = await import('../result.ts'); + let cleanupRan = false; + + orca.onEvent('e', { + id: 'broken', + stage: ORCA_STAGE_MAIN, + action: () => orcaFatal(new Error('fatal')) + }); + orca.onEvent('e', { + id: 'cleanup', + stage: ORCA_STAGE_FINALLY, + action: () => { + cleanupRan = true; + return orcaSuccess(); + } + }); + + bus.publish('e', null); + await flush(); + + expect(cleanupRan).toBe(true); + }); + + it('emits orca.action.fatal diagnostic with fatal: true', async () => { + const entries: DiagnosticEntry[] = []; + const orca = createEngineOrca({ + bus, + timers, + logger: createCapturingLogger(entries) as Parameters< + typeof createEngineOrca + >[0]['logger'] + }); + const { orcaFatal } = await import('../result.ts'); + + orca.onEvent('e', { + id: 'broken', + stage: ORCA_STAGE_MAIN, + action: () => orcaFatal(new Error('boom')) + }); + + bus.publish('e', null); + await flush(); + + const fatal = entries.find((e) => e.type === 'orca.action.fatal'); + expect(fatal).toBeDefined(); + expect(fatal?.meta.fatal).toBe(true); + expect(fatal?.meta.actionId).toBe('broken'); + }); + + it('precedence: fatal beats timeout beats aborted beats interrupted beats partial', async () => { + const orca = createEngineOrca({ bus, timers }); + const { orcaFatal } = await import('../result.ts'); + + // FATAL together with ERROR → run status is FATAL. + orca.onEvent('e', { + id: 'errored', + stage: ORCA_STAGE_MAIN, + action: () => orcaError(new Error('partial')) + }); + orca.onEvent('e', { + id: 'fatal-action', + stage: ORCA_STAGE_MAIN, + action: () => orcaFatal(new Error('fatal')) + }); + + bus.publish('e', null); + await flush(); + + const runs = orca.recentRuns(); + expect(runs[0].status).toBe('fatal'); + }); +}); +