Honor after/unless/abortOn gates in the orca engine

Second batch of v1 features. With this, the `provides`/`emits` token
infrastructure that has lived in OrcaAction since v0 finally has
control-flow consequences: tokens emitted by one action gate the
acceptance of subsequent actions in the same run.

Gate semantics:
  - `unless: T[]` — declared on actions that should not run again if
    a sibling action already produced their precondition (idempotency
    guard). When any token in `unless` is present, the action is
    SKIPPED with reason `unless-triggered:<token>`.
  - `abortOn: T[]` — declared on actions that must halt when an upstream
    flagged danger. When any token in `abortOn` is present, the action
    is BLOCKED (distinct from skipped) with reason
    `abort-on-triggered:<token>`.
  - `after: T[]` — declared on actions whose work depends on tokens
    emitted by upstream actions. When any required token is missing,
    the action is SKIPPED with reason
    `after-not-met:<missing1>,<missing2>,…`.

Evaluation order is deliberate: unless first (idempotency), abortOn
second (halt signal), after third (weakest reason to skip). The first
gate that fires short-circuits the action; later gates are not
evaluated. The FINALLY stage bypasses all gates so cleanup work runs
unconditionally.

Engine changes:
  - New evaluateGates(action, tokens, now) helper produces a
    synthesized OrcaActionRun when a gate fires, or null when the
    action should proceed.
  - executeRun calls evaluateGates() right after the trace-aborted
    short-circuit and before the per-action AbortController is set up.
    Gated actions emit either ACTION_SKIPPED or ACTION_BLOCKED
    diagnostics and never reach runAction().
  - The "run aborted between stages" path now also emits an
    ACTION_BLOCKED diagnostic with reason 'run-aborted', so blocked
    actions are observable in logs regardless of cause.
  - OrcaActionRun.interruptedReason renamed to OrcaActionRun.reason —
    the field carries gate, reentry, or authored reasons uniformly
    across SKIPPED, BLOCKED, INTERRUPTED. The status determines what
    kind of reason it is.

New constants exported from $orca:
  - ORCA_GATE_REASON_AFTER_NOT_MET
  - ORCA_GATE_REASON_UNLESS_TRIGGERED
  - ORCA_GATE_REASON_ABORT_ON_TRIGGERED
  - ORCA_GATE_REASON_RUN_ABORTED
  - ORCA_DIAGNOSTIC_EVENTS.ACTION_BLOCKED

Tests (+11):
  v1 — after gate (3):
    - skips an action whose `after` token is missing
    - runs the action when every `after` token has been emitted
    - reports every missing token in the reason when partially met

  v1 — unless gate (2):
    - skips an action when any `unless` token is present
    - runs the action when no `unless` token is present

  v1 — abortOn gate (3):
    - blocks an action when an abortOn token is present
    - runs the action when no abortOn token is present
    - emits orca.action.blocked diagnostic with the abortOn reason

  v1 — gate precedence and FINALLY bypass (3):
    - unless wins over after when both would short-circuit
    - abortOn wins over after when both would short-circuit
    - FINALLY stage bypasses gates so cleanup always runs

The legacy "accepts after/unless/abortOn without enforcing" forward-
compat tests from the v0 ignored-fields block are removed; v0 promise
is now v1 reality. The orcaInterrupted-reason test was updated to read
the renamed `reason` field.

Verification: 1381/1381 tests pass (71 in orca, +11 from this commit
on top of 63 from the previous v1 commits).

README updated: gates moved from "Roadmap v1" to "Ya en el motor (de
v1)". Remaining v1 items: compensate, commit/replace queue policies,
transaction groups, parallel, payload-bearing tokens, fan-in, bus
interception (Option B), validate()/commit() static graph validation,
and createActiveOrca() reactive wrapper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
master
dev 5 months ago
parent 793767fe0d
commit 79d6d1f776

@ -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 / 1373 tests pasan, 63 de ellos
implementada y testeada (114 archivos / 1381 tests pasan, 71 de ellos
sobre orca). El kernel expone:
- `createEngineOrca({ bus, timers, logger?, maxRuns?, reentry? })`
@ -46,9 +46,14 @@ sobre orca). El kernel expone:
`onError` (precedencia: `FATAL > TIMEOUT > ABORTED > INTERRUPTED >
PARTIAL > SUCCESS`). El stage `FINALLY` sigue ejecutándose tras
fatal.
- **Gates `after` / `unless` / `abortOn`:** declaras tokens en cada
acción; el motor los evalúa contra los emitidos en el run actual.
`unless` (idempotencia) gana sobre `abortOn` (halt) gana sobre
`after` (dependencia faltante). El stage `FINALLY` bypasa los
gates — la limpieza siempre corre.
- Diagnostics estructurados (`orca.run.*`, `orca.action.*`,
`orca.event.emitted`, `orca.reentry.blocked`, `orca.trace.aborted`,
`orca.action.fatal`, `orca.action.timeout`)
`orca.action.fatal`, `orca.action.timeout`, `orca.action.blocked`)
- `applyStandardOrca(App)` agregador de presets en
`arts/active-app/presets/`
@ -1330,7 +1335,6 @@ Marcado como `@v1+` en el código fuente:
| Pieza | Qué falta para v1 |
|---|---|
| `compensate` | Invocar la función compensatoria cuando el run aborta tras un éxito previo. |
| `after` / `unless` / `abortOn` | Honrar los gates de tokens entre acciones. |
| `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`. |
@ -1349,3 +1353,12 @@ Marcado como `@v1+` en el código fuente:
`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.
- ✅ **Gates `after` / `unless` / `abortOn`** — el motor evalúa los
tokens declarados en cada acción contra los emitidos hasta ese
punto del run. `unless` se prueba primero (idempotencia: skip si
alguno está presente), luego `abortOn` (halt: blocked si alguno
está presente), luego `after` (dependencia: skip si alguno falta).
La razón se serializa en `OrcaActionRun.reason` como
`unless-triggered:<token>` / `abort-on-triggered:<token>` /
`after-not-met:<tok1>,<tok2>,…`. El stage `FINALLY` los bypasa
siempre.

@ -89,6 +89,22 @@ export const ORCA_REENTRY_REASON_TRACE_ABORTED = 'trace-aborted' as const;
export const ORCA_REENTRY_REASON_RUN_ABORTED = 'run-aborted' as const;
export const ORCA_REENTRY_REASON_DISPOSED = 'disposed' as const;
// ── Gate reasons ───────────────────────────────────────────────────────
//
// Carried on `OrcaActionRun.reason` when an action is short-circuited by
// one of its declared gates (`after` / `unless` / `abortOn`). Distinct
// from reentry reasons because gates fire inside a single run while
// reentry guards fire across a trace.
/** Action declared `after: [...]` and at least one token was missing. */
export const ORCA_GATE_REASON_AFTER_NOT_MET = 'after-not-met' as const;
/** Action declared `unless: [...]` and one of its tokens was present. */
export const ORCA_GATE_REASON_UNLESS_TRIGGERED = 'unless-triggered' as const;
/** Action declared `abortOn: [...]` and one of its tokens was present. */
export const ORCA_GATE_REASON_ABORT_ON_TRIGGERED = 'abort-on-triggered' as const;
/** The action's signal was already aborted when it would have run. */
export const ORCA_GATE_REASON_RUN_ABORTED = 'run-aborted' as const;
// ── Error policies ──────────────────────────────────────────────────────
// v0.0 implements CONTINUE and ABORT_RUN. The remaining policies are
// accepted in the type and treated as CONTINUE.
@ -112,6 +128,7 @@ export const ORCA_DIAGNOSTIC_EVENTS = {
ACTION_FATAL: 'orca.action.fatal',
ACTION_TIMEOUT: 'orca.action.timeout',
ACTION_SKIPPED: 'orca.action.skipped',
ACTION_BLOCKED: 'orca.action.blocked',
ACTION_INTERRUPTED: 'orca.action.interrupted',
EVENT_EMITTED: 'orca.event.emitted',
REENTRY_BLOCKED: 'orca.reentry.blocked',
@ -130,6 +147,7 @@ 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_BLOCKED = 'orca action blocked by gate';
export const ORCA_LOG_MSG_ACTION_INTERRUPTED = 'orca action interrupted';
export const ORCA_LOG_MSG_EVENT_EMITTED = 'orca derived event emitted';
export const ORCA_LOG_MSG_REENTRY_BLOCKED = 'orca reentry guard blocked an event';

@ -12,6 +12,7 @@ import {
ORCA_LOG_MSG_ACTION_FAILED,
ORCA_LOG_MSG_ACTION_FATAL,
ORCA_LOG_MSG_ACTION_INTERRUPTED,
ORCA_LOG_MSG_ACTION_BLOCKED,
ORCA_LOG_MSG_ACTION_SKIPPED,
ORCA_LOG_MSG_ACTION_STARTED,
ORCA_LOG_MSG_ACTION_TIMEOUT,
@ -157,6 +158,10 @@ const ORCA_DIAGNOSTIC_LOGS: DiagnosticCatalog<OrcaDiagnosticEvent> = {
level: LogLevel.DEBUG,
message: ORCA_LOG_MSG_ACTION_SKIPPED
},
[ORCA_DIAGNOSTIC_EVENTS.ACTION_BLOCKED]: {
level: LogLevel.DEBUG,
message: ORCA_LOG_MSG_ACTION_BLOCKED
},
[ORCA_DIAGNOSTIC_EVENTS.ACTION_INTERRUPTED]: {
level: LogLevel.WARN,
message: ORCA_LOG_MSG_ACTION_INTERRUPTED

@ -40,6 +40,10 @@ import {
ORCA_DEFAULT_MAX_RUNS,
ORCA_DEFAULT_REPEATED_EVENT_LIMIT,
ORCA_DIAGNOSTIC_EVENTS,
ORCA_GATE_REASON_ABORT_ON_TRIGGERED,
ORCA_GATE_REASON_AFTER_NOT_MET,
ORCA_GATE_REASON_RUN_ABORTED,
ORCA_GATE_REASON_UNLESS_TRIGGERED,
ORCA_ON_ERROR_ABORT_RUN,
ORCA_ON_ERROR_CONTINUE,
ORCA_REENTRY_ABORT_TRACE,
@ -430,6 +434,30 @@ export function createEngineOrca(options: EngineOrcaOptions): EngineOrca {
continue;
}
// Gate evaluation: short-circuit when after/unless/abortOn
// have something to say about the current token set. The
// finally stage bypasses gates because cleanup must run
// regardless of upstream success/failure.
if (stage !== ORCA_STAGE_FINALLY) {
const gated = evaluateGates(action, tokens, timers.clock.now());
if (gated !== null) {
actionRuns.push(gated);
const eventName =
gated.status === ORCA_ACTION_STATUS_BLOCKED
? ORCA_DIAGNOSTIC_EVENTS.ACTION_BLOCKED
: ORCA_DIAGNOSTIC_EVENTS.ACTION_SKIPPED;
emitOrcaDiagnostic(diagnostics, eventName, {
runId,
actionId: action.id,
eventId: envelope.meta.eventId,
traceId: envelope.meta.traceId,
depth: envelope.meta.depth,
reason: gated.reason
});
continue;
}
}
// Per-action AbortController, chained to the run-level
// controller. Lets `actionTimeoutMs` abort just this
// action without aborting the whole run.
@ -646,6 +674,14 @@ export function createEngineOrca(options: EngineOrcaOptions): EngineOrca {
// If the run is already aborted (e.g. between stages, only finally
// remains), and we are not in finally, mark blocked.
if (context.signal.aborted && context.stage !== ORCA_STAGE_FINALLY) {
emitOrcaDiagnostic(diagnostics, ORCA_DIAGNOSTIC_EVENTS.ACTION_BLOCKED, {
runId: context.runId,
actionId: action.id,
eventId: context.eventId,
traceId: context.traceId,
depth: context.depth,
reason: ORCA_GATE_REASON_RUN_ABORTED
});
return {
id: action.id,
stage: action.stage,
@ -653,7 +689,8 @@ export function createEngineOrca(options: EngineOrcaOptions): EngineOrca {
startedAt,
endedAt: startedAt,
durationMs: 0,
emitted: []
emitted: [],
reason: ORCA_GATE_REASON_RUN_ABORTED
};
}
@ -735,8 +772,9 @@ export function createEngineOrca(options: EngineOrcaOptions): EngineOrca {
status === ORCA_ACTION_STATUS_FATAL
? (result as { error?: unknown }).error
: undefined,
interruptedReason:
status === ORCA_ACTION_STATUS_INTERRUPTED
reason:
status === ORCA_ACTION_STATUS_INTERRUPTED ||
status === ORCA_ACTION_STATUS_SKIPPED
? (result as { reason?: string }).reason
: undefined
};
@ -888,9 +926,73 @@ function interruptedActionRun(
endedAt: now,
durationMs: 0,
emitted: [],
interruptedReason: reason
reason
};
}
/**
* Evaluate the action's `unless` / `abortOn` / `after` gates against
* the tokens emitted so far in the run. Returns either a synthesized
* action-run record describing the gate outcome, or `null` when the
* action should proceed normally.
*
* Order matters: `unless` (already-done semantics) is checked first
* because it represents an idempotency guard; `abortOn` next, because
* it represents a halt signal; `after` last, because absence of a
* dependency is the weakest reason to skip.
*/
function evaluateGates(
action: OrcaAction,
tokens: ReadonlySet<OrcaToken>,
now: number
): OrcaActionRun | null {
if (action.unless && action.unless.length > 0) {
const matched = action.unless.find((t) => tokens.has(t));
if (matched !== undefined) {
return {
id: action.id,
stage: action.stage,
status: ORCA_ACTION_STATUS_SKIPPED,
startedAt: now,
endedAt: now,
durationMs: 0,
emitted: [],
reason: `${ORCA_GATE_REASON_UNLESS_TRIGGERED}:${matched}`
};
}
}
if (action.abortOn && action.abortOn.length > 0) {
const matched = action.abortOn.find((t) => tokens.has(t));
if (matched !== undefined) {
return {
id: action.id,
stage: action.stage,
status: ORCA_ACTION_STATUS_BLOCKED,
startedAt: now,
endedAt: now,
durationMs: 0,
emitted: [],
reason: `${ORCA_GATE_REASON_ABORT_ON_TRIGGERED}:${matched}`
};
}
}
if (action.after && action.after.length > 0) {
const missing = action.after.filter((t) => !tokens.has(t));
if (missing.length > 0) {
return {
id: action.id,
stage: action.stage,
status: ORCA_ACTION_STATUS_SKIPPED,
startedAt: now,
endedAt: now,
durationMs: 0,
emitted: [],
reason: `${ORCA_GATE_REASON_AFTER_NOT_MET}:${missing.join(',')}`
};
}
}
return null;
}
function generateRunId(): OrcaRunId {
return `run_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;

@ -53,6 +53,10 @@ export {
ORCA_REENTRY_REASON_TRACE_ABORTED,
ORCA_REENTRY_REASON_RUN_ABORTED,
ORCA_REENTRY_REASON_DISPOSED,
ORCA_GATE_REASON_AFTER_NOT_MET,
ORCA_GATE_REASON_UNLESS_TRIGGERED,
ORCA_GATE_REASON_ABORT_ON_TRIGGERED,
ORCA_GATE_REASON_RUN_ABORTED,
ORCA_DIAGNOSTIC_EVENTS,
ORCA_DEFAULT_MAX_RUNS,
ORCA_DEFAULT_MAX_DEPTH,

@ -752,76 +752,6 @@ describe('EngineOrca v0.0 — accepted-but-ignored fields (forward-compat)', ()
timers = createFakeTimers();
});
it('accepts after without waiting for tokens', async () => {
const orca = createEngineOrca({ bus, timers });
const calls: string[] = [];
orca.onEvent('e1', {
id: 'declares-after',
stage: ORCA_STAGE_MAIN,
after: ['nonexistent-token'],
action: () => {
calls.push('ran');
return orcaSuccess();
}
});
bus.publish('e1', null);
await flush();
expect(calls).toEqual(['ran']); // ran despite missing token
});
it('accepts unless without skipping', async () => {
const orca = createEngineOrca({ bus, timers });
const calls: string[] = [];
orca.onEvent('e1', {
id: 'producer',
stage: ORCA_STAGE_MAIN,
action: () => orcaSuccess({ emits: ['present'] })
});
orca.onEvent('e1', {
id: 'declares-unless',
stage: ORCA_STAGE_POST,
unless: ['present'],
action: () => {
calls.push('ran');
return orcaSuccess();
}
});
bus.publish('e1', null);
await flush();
expect(calls).toEqual(['ran']); // ran despite token being present
});
it('accepts abortOn without blocking', async () => {
const orca = createEngineOrca({ bus, timers });
const calls: string[] = [];
orca.onEvent('e1', {
id: 'producer',
stage: ORCA_STAGE_MAIN,
action: () => orcaSuccess({ emits: ['danger'] })
});
orca.onEvent('e1', {
id: 'declares-abortOn',
stage: ORCA_STAGE_POST,
abortOn: ['danger'],
action: () => {
calls.push('ran');
return orcaSuccess();
}
});
bus.publish('e1', null);
await flush();
expect(calls).toEqual(['ran']);
});
it('accepts compensate without invoking it', async () => {
const orca = createEngineOrca({ bus, timers });
const compensate = vi.fn(() => orcaSuccess());
@ -1180,7 +1110,7 @@ describe('EngineOrca v0 — reentry guards', () => {
expect(runs).toHaveLength(1);
expect(runs[0].status).toBe(ORCA_RUN_INTERRUPTED);
expect(runs[0].actions[0].status).toBe(ORCA_ACTION_STATUS_INTERRUPTED);
expect(runs[0].actions[0].interruptedReason).toBe('aborted-by-author');
expect(runs[0].actions[0].reason).toBe('aborted-by-author');
});
it('blocks the (N+1)th event in a trace when N = maxEventsPerTrace', async () => {
@ -1719,3 +1649,319 @@ describe('EngineOrca v1 — OrcaFatal', () => {
});
});
// ── v1: after / unless / abortOn gates ────────────────────────────────
describe('EngineOrca v1 — after gate', () => {
let bus: FakeBus;
let timers: ReturnType<typeof createFakeTimers>;
beforeEach(() => {
bus = createFakeBus();
timers = createFakeTimers();
});
it('skips an action whose `after` token is missing', async () => {
const orca = createEngineOrca({ bus, timers });
const ran: string[] = [];
orca.onEvent('e', {
id: 'gated',
stage: ORCA_STAGE_MAIN,
after: ['producer:done'],
action: () => {
ran.push('gated');
return orcaSuccess();
}
});
bus.publish('e', null);
await flush();
expect(ran).toEqual([]);
const runs = orca.recentRuns();
expect(runs[0].actions[0].status).toBe(ORCA_ACTION_STATUS_SKIPPED);
expect(runs[0].actions[0].reason).toMatch(/^after-not-met:producer:done$/);
});
it('runs the action when every `after` token has been emitted', async () => {
const orca = createEngineOrca({ bus, timers });
const ran: string[] = [];
orca.onEvent('e', {
id: 'producer',
stage: ORCA_STAGE_PRE,
provides: ['ready'],
action: () => orcaSuccess({ emits: ['ready'] })
});
orca.onEvent('e', {
id: 'gated',
stage: ORCA_STAGE_MAIN,
after: ['ready'],
action: () => {
ran.push('gated');
return orcaSuccess();
}
});
bus.publish('e', null);
await flush();
expect(ran).toEqual(['gated']);
const runs = orca.recentRuns();
expect(runs[0].actions[1].status).toBe(ORCA_ACTION_STATUS_SUCCESS);
});
it('reports every missing token in the reason when partially met', async () => {
const orca = createEngineOrca({ bus, timers });
orca.onEvent('e', {
id: 'producer',
stage: ORCA_STAGE_PRE,
action: () => orcaSuccess({ emits: ['t1'] })
});
orca.onEvent('e', {
id: 'gated',
stage: ORCA_STAGE_MAIN,
after: ['t1', 't2', 't3'],
action: () => orcaSuccess()
});
bus.publish('e', null);
await flush();
const runs = orca.recentRuns();
expect(runs[0].actions[1].status).toBe(ORCA_ACTION_STATUS_SKIPPED);
expect(runs[0].actions[1].reason).toBe('after-not-met:t2,t3');
});
});
describe('EngineOrca v1 — unless gate', () => {
let bus: FakeBus;
let timers: ReturnType<typeof createFakeTimers>;
beforeEach(() => {
bus = createFakeBus();
timers = createFakeTimers();
});
it('skips an action when any `unless` token is present', async () => {
const orca = createEngineOrca({ bus, timers });
const ran: string[] = [];
orca.onEvent('e', {
id: 'producer',
stage: ORCA_STAGE_PRE,
action: () => orcaSuccess({ emits: ['already-done'] })
});
orca.onEvent('e', {
id: 'idempotent',
stage: ORCA_STAGE_MAIN,
unless: ['already-done'],
action: () => {
ran.push('idempotent');
return orcaSuccess();
}
});
bus.publish('e', null);
await flush();
expect(ran).toEqual([]);
const runs = orca.recentRuns();
expect(runs[0].actions[1].status).toBe(ORCA_ACTION_STATUS_SKIPPED);
expect(runs[0].actions[1].reason).toBe('unless-triggered:already-done');
});
it('runs the action when no `unless` token is present', async () => {
const orca = createEngineOrca({ bus, timers });
const ran: string[] = [];
orca.onEvent('e', {
id: 'idempotent',
stage: ORCA_STAGE_MAIN,
unless: ['was-done'],
action: () => {
ran.push('idempotent');
return orcaSuccess();
}
});
bus.publish('e', null);
await flush();
expect(ran).toEqual(['idempotent']);
});
});
describe('EngineOrca v1 — abortOn gate', () => {
let bus: FakeBus;
let timers: ReturnType<typeof createFakeTimers>;
beforeEach(() => {
bus = createFakeBus();
timers = createFakeTimers();
});
it('blocks an action when an abortOn token is present', async () => {
const orca = createEngineOrca({ bus, timers });
const ran: string[] = [];
orca.onEvent('e', {
id: 'flag-danger',
stage: ORCA_STAGE_PRE,
action: () => orcaSuccess({ emits: ['danger'] })
});
orca.onEvent('e', {
id: 'risky',
stage: ORCA_STAGE_MAIN,
abortOn: ['danger'],
action: () => {
ran.push('risky');
return orcaSuccess();
}
});
bus.publish('e', null);
await flush();
expect(ran).toEqual([]);
const runs = orca.recentRuns();
expect(runs[0].actions[1].status).toBe(ORCA_ACTION_STATUS_BLOCKED);
expect(runs[0].actions[1].reason).toBe('abort-on-triggered:danger');
});
it('runs the action when no abortOn token is present', async () => {
const orca = createEngineOrca({ bus, timers });
const ran: string[] = [];
orca.onEvent('e', {
id: 'risky',
stage: ORCA_STAGE_MAIN,
abortOn: ['danger'],
action: () => {
ran.push('risky');
return orcaSuccess();
}
});
bus.publish('e', null);
await flush();
expect(ran).toEqual(['risky']);
});
it('emits orca.action.blocked diagnostic with the abortOn reason', async () => {
const entries: DiagnosticEntry[] = [];
const orca = createEngineOrca({
bus,
timers,
logger: createCapturingLogger(entries) as Parameters<
typeof createEngineOrca
>[0]['logger']
});
orca.onEvent('e', {
id: 'flag-danger',
stage: ORCA_STAGE_PRE,
action: () => orcaSuccess({ emits: ['danger'] })
});
orca.onEvent('e', {
id: 'risky',
stage: ORCA_STAGE_MAIN,
abortOn: ['danger'],
action: () => orcaSuccess()
});
bus.publish('e', null);
await flush();
const blocked = entries.find((e) => e.type === 'orca.action.blocked');
expect(blocked).toBeDefined();
expect(blocked?.meta.actionId).toBe('risky');
expect(blocked?.meta.reason).toBe('abort-on-triggered:danger');
});
});
describe('EngineOrca v1 — gate precedence and FINALLY bypass', () => {
let bus: FakeBus;
let timers: ReturnType<typeof createFakeTimers>;
beforeEach(() => {
bus = createFakeBus();
timers = createFakeTimers();
});
it('unless wins over after when both would short-circuit (unless evaluated first)', async () => {
const orca = createEngineOrca({ bus, timers });
orca.onEvent('e', {
id: 'producer',
stage: ORCA_STAGE_PRE,
action: () => orcaSuccess({ emits: ['already-done'] })
});
orca.onEvent('e', {
id: 'gated',
stage: ORCA_STAGE_MAIN,
after: ['unmet-token'],
unless: ['already-done'],
action: () => orcaSuccess()
});
bus.publish('e', null);
await flush();
const runs = orca.recentRuns();
expect(runs[0].actions[1].reason).toBe('unless-triggered:already-done');
});
it('abortOn wins over after when both would short-circuit', async () => {
const orca = createEngineOrca({ bus, timers });
orca.onEvent('e', {
id: 'producer',
stage: ORCA_STAGE_PRE,
action: () => orcaSuccess({ emits: ['danger'] })
});
orca.onEvent('e', {
id: 'gated',
stage: ORCA_STAGE_MAIN,
after: ['unmet-token'],
abortOn: ['danger'],
action: () => orcaSuccess()
});
bus.publish('e', null);
await flush();
const runs = orca.recentRuns();
expect(runs[0].actions[1].status).toBe(ORCA_ACTION_STATUS_BLOCKED);
expect(runs[0].actions[1].reason).toBe('abort-on-triggered:danger');
});
it('FINALLY stage bypasses gates so cleanup always runs', async () => {
const orca = createEngineOrca({ bus, timers });
let cleanupRan = false;
orca.onEvent('e', {
id: 'producer',
stage: ORCA_STAGE_PRE,
action: () => orcaSuccess({ emits: ['danger'] })
});
orca.onEvent('e', {
id: 'cleanup',
stage: ORCA_STAGE_FINALLY,
abortOn: ['danger'], // would normally block this action
after: ['nonexistent'], // and this would skip it
action: () => {
cleanupRan = true;
return orcaSuccess();
}
});
bus.publish('e', null);
await flush();
expect(cleanupRan).toBe(true);
});
});

@ -367,8 +367,16 @@ export interface OrcaActionRun {
readonly durationMs: number;
readonly emitted: readonly OrcaToken[];
readonly error?: unknown;
/** Populated when status === 'interrupted'. */
readonly interruptedReason?: OrcaReentryReason | string;
/**
* Short tag describing why the action ended in this status.
* Populated for SKIPPED (gate / authored skipped), BLOCKED (gate /
* run-aborted), INTERRUPTED (trace-aborted / authored interrupted).
* Carries one of the `ORCA_GATE_REASON_*` or `ORCA_REENTRY_REASON_*`
* constants when the engine produced the status, or the string
* passed by the action when it returned `orcaSkipped(reason)` /
* `orcaInterrupted(reason)`.
*/
readonly reason?: OrcaReentryReason | string;
}
export interface OrcaRunResult {

Loading…
Cancel
Save

Powered by TurnKey Linux.