Docs menu
Saga / compensation (unwind)
When a run must be unwound, compensateRun walks the executed tool records in REVERSE execution order and calls each tool's compensate hook — writing a condemn tombstone first so the run can never be resumed once compensation starts.
What it's for / when to use it#
A multi-step run can fail (or simply need to be reversed) after several side-effecting tools already succeeded — a charge went through, an email was sent, a shipment was created. compensateRun is the explicit, opt-in saga/undo mechanism: it never fires automatically on failure. You call it deliberately, and it invokes each tool's own compensate hook (a refund, a cancellation email, a shipment void) to unwind what already happened.
How it works#
compensateRun unwinds the executed (succeeded/failed/running) tool records in reverse execution order; entries in the report are in that same unwind order. It stops on the first failed or busy compensation — any remaining earlier steps are reported as not-attempted. Each entry's status is one of:
compensatedThe compensate hook ran successfully.
already-compensatedThis step was already compensated in a prior compensateRun call.
would-compensateUnder dryRun: true — the step WOULD be compensated, but nothing ran.
skipped-no-hookThe tool has no compensate hook, so nothing to undo.
skipped-not-executedThe tool call never actually executed (e.g. it was never reached).
uncertainThe outcome of the compensate call could not be determined.
busyThe compensate call is currently in progress elsewhere — unwind stops here.
failedThe compensate hook threw — unwind stops here.
not-attemptedAn earlier step in the unwind, left untried because a later (reverse-order) step above already failed or was busy.
Setup / import#
compensateRun and its error type live in @gnldev/durable:
import { createGnl, compensateRun, CompensatedRunError } from '@gnldev/durable';Step-by-step usage#
Give a side-effecting tool a compensate hook — compensate?(input, output, opts), where opts carries idempotencyKey, toolCallId and runId. Use the idempotencyKey to issue the matching refund or undo:
// 'charge' refunds using the idempotencyKey when compensated.
const charge = {
execute: async (input, opts) => billing.charge(input, opts.idempotencyKey),
compensate: async (input, output, opts) => {
// opts: { idempotencyKey, toolCallId, runId }
return billing.refund(opts.idempotencyKey);
},
};
await gnl.run('pay', { runId: 'order-7', prompt: 'charge the customer', tools: { charge } });Call compensateRun(runId, opts) with the journal and your tools (dryRun defaults to false; journal.readRun must exist or it throws). It unwinds the run in reverse execution order — inspect report.condemned and report.entries for the outcome:
// Unwind the run in reverse execution order.
const report = await compensateRun('order-7', {
journal: storage.runs,
tools: { charge },
});
console.log(report.condemned); // true — a tombstone was written first
console.log(report.entries.map((e) => e.status));Pass dryRun: true to get a plan without side effects: statuses become would-compensate, and the run is NOT condemned or executed:
// dryRun: true only plans the unwind — no tombstone, no execution.
const plan = await compensateRun('order-7', {
journal: storage.runs,
tools: { charge },
dryRun: true,
});
console.log(plan.entries.map((e) => e.status)); // e.g. ['would-compensate']API reference#
compensateRun(runId, opts)compensateRun(runId: string, opts: { journal: Journal & Partial<JournalReader>; tools?: Record<string, AnyTool>; dryRun?: boolean }): Promise<CompensationReport>. Requires journal.readRun (throws otherwise). tools defaults to {}, dryRun defaults to false.
AnyTool.compensate?(input, output, opts)Optional per-tool hook: compensate?(input, output, opts): Promise<unknown>, where opts = { idempotencyKey, toolCallId, runId }. Called ONLY by an explicit compensateRun — never automatically on failure.
CompensationReport{ runId, dryRun, condemned, entries: CompensationEntry[] }. entries are in unwind (reverse execution) order.
CompensationEntry{ suffix, toolCallId?, toolName?, status, error? }. status: 'compensated' | 'already-compensated' | 'would-compensate' | 'skipped-no-hook' | 'skipped-not-executed' | 'uncertain' | 'busy' | 'failed' | 'not-attempted'.
CompensatedRunErrorThrown by assertNotCompensated (used by runDurable/streamDurable/forkRun) when a condemned run is resumed. detail: { runId }.
runCompensated(journal, runId)runCompensated(journal, runId): Promise<boolean> — checks whether a run has been condemned/compensated.
assertNotCompensated(journal, runId)assertNotCompensated(journal, runId): Promise<void> — throws CompensatedRunError if the run is condemned.
compensateRun writes a tombstone FIRST (never during a dryRun). Once condemned, the run refuses resume: runDurable/streamDurable/forkRun call assertNotCompensated, which throws CompensatedRunError for a condemned run.failed or busy, all earlier steps (N-1, N-2, …) are reported not-attempted rather than being attempted out of order.exactly-once-tools for the per-run exactly-once guarantee that gives compensate hooks a stable idempotencyKey, deterministic-replay for how the journal records the executed steps that compensateRun walks, and human-in-loop-approvals for the sibling flow where a human — rather than an automatic unwind — decides a suspended call's fate.