Fourth batch of v1 features. With this, OrcaAction.compensate moves
from accepted-and-ignored to a real Saga-style rollback hook: when a
run aborts (OrcaFatal, ORCA_ON_ERROR_ABORT_RUN, or trace-aborted), the
engine walks back through every action that previously completed in
success and invokes its compensator before the FINALLY stage runs.
Engine semantics:
- During the action loop, when an action returns success and has
declared `compensate`, push { action, payload } onto a LIFO stack
(compensable[]). Stage FINALLY actions are not compensable —
FINALLY is the cleanup pass itself.
- On entry to the FINALLY stage with aborted=true and
compensable.length > 0, walk the stack in reverse. Each
compensator gets a fresh AbortController (the run controller is
already aborted) and an OrcaActionContext whose emit() returns
null — compensations do not fan out new events, they roll back.
- Compensations are best-effort: a thrown compensator is recorded as
ORCA_ACTION_STATUS_ERROR but the next compensation in the chain
still runs. v1 chooses best-effort over fail-fast because rolling
back N-1 entries when one of them failed is more useful than
rolling back zero.
- FINALLY actions run AFTER compensations, in normal stage order.
Conceptual order on abort:
action loop → compensation phase → FINALLY → run trace recorded.
- Compensations appear in OrcaRunResult.compensations[], a separate
field from actions[]. The original action's record stays in
actions[] with its original success status; the compensator's
record lives only in compensations[]. This keeps the run trace
accurate (the action did succeed; it was just compensated later).
- Diagnostics: orca.compensation.started (DEBUG),
orca.compensation.completed (DEBUG), orca.compensation.failed
(ERROR). All carry runId, actionId, eventId, traceId, depth.
- Compensation does NOT trigger for PARTIAL runs (CONTINUE-onError
actions that errored without aborting) or TIMEOUT-only runs that
didn't abort. The semantic is "all-or-nothing rollback for
aborted runs", not "partial cleanup".
OrcaRunResult gains a `compensations: readonly OrcaActionRun[]` field
(empty array when no compensation ran).
Tests (+10 in a new "v1 — compensate" describe block):
- does not invoke compensate when the run completes successfully
- invokes compensate of a previously successful action when the run
aborts (the simplest happy path)
- does not invoke compensate of an action that errored itself
- runs compensations in LIFO order (with three compensators)
- runs compensations even when OrcaFatal aborts the run
- continues with remaining compensations even if one throws
(best-effort, the failing compensator's status is ERROR but
earlier and later ones still ran)
- runs FINALLY actions after compensations (order: ['compensate',
'finally'])
- does not invoke compensate when a CONTINUE-onError action errors
(PARTIAL run, no abort)
- emits orca.compensation.{started,completed,failed} diagnostics
- passes the original event payload to compensate
- emits inside ctx during compensation are dropped (return null)
The legacy "accepts compensate without invoking it" test from the v0
forward-compat block was deleted; v0 promise is now v1 reality. The
case it asserted (compensate of a failing action is not invoked) is
now covered explicitly by the new "does not invoke compensate of an
action that errored itself" test.
Verification: 1402/1402 vitest tests pass (92 in orca, +10 from this
commit on top of 82 from previous v1 commits).
README updated: compensate moved from "Roadmap v1" to "Ya en el motor
(de v1)". Remaining v1 items: commit() configuration freeze, queue
policies (commit/replace), transaction groups, parallel,
payload-bearing tokens, fan-in, bus interception (Option B), and
createActiveOrca() reactive wrapper.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>