GNL
Docs menu
Core · Free@gnldev/durable

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:

constcompensated

The compensate hook ran successfully.

constalready-compensated

This step was already compensated in a prior compensateRun call.

constwould-compensate

Under dryRun: true — the step WOULD be compensated, but nothing ran.

constskipped-no-hook

The tool has no compensate hook, so nothing to undo.

constskipped-not-executed

The tool call never actually executed (e.g. it was never reached).

constuncertain

The outcome of the compensate call could not be determined.

constbusy

The compensate call is currently in progress elsewhere — unwind stops here.

constfailed

The compensate hook threw — unwind stops here.

constnot-attempted

An 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#

fncompensateRun(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.

fnAnyTool.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.

typeCompensationReport

{ runId, dryRun, condemned, entries: CompensationEntry[] }. entries are in unwind (reverse execution) order.

typeCompensationEntry

{ suffix, toolCallId?, toolName?, status, error? }. status: 'compensated' | 'already-compensated' | 'would-compensate' | 'skipped-no-hook' | 'skipped-not-executed' | 'uncertain' | 'busy' | 'failed' | 'not-attempted'.

classCompensatedRunError

Thrown by assertNotCompensated (used by runDurable/streamDurable/forkRun) when a condemned run is resumed. detail: { runId }.

fnrunCompensated(journal, runId)

runCompensated(journal, runId): Promise<boolean> — checks whether a run has been condemned/compensated.

fnassertNotCompensated(journal, runId)

assertNotCompensated(journal, runId): Promise<void> — throws CompensatedRunError if the run is condemned.

Condemn-first
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.
Note
Unwind is strictly reverse-order and stops at the first failure: if step N's compensate call is failed or busy, all earlier steps (N-1, N-2, …) are reported not-attempted rather than being attempted out of order.
Related
See 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.