Rollback - agbossjs/agboss GitHub Wiki
A step that changes the world (reserves stock, charges a card, books a slot) may have to be undone
when a later step fails. The undo is an ordinary @Action, and the action that needs it says so:
@Action({ compensateWith: ReleaseStockAction }) (#339,
ADR-0020). Where a path can say what happens after a failure —
an .onError(ErrorClass, <side path>) right after the step that throws, or a join's
.onJoinError(…) — the side path runs the undo actions itself, newest first, and agboss check
proves it does. Where no path can say it, the framework runs them itself: a failed join's finished
branches (#340 PR 2, below); a cancel, an uncaught failure or limit, an aborted child workflow
(#340 PR 3, below).
// ReserveStockAction { compensateWith: ReleaseStockAction }, ChargeCardAction { compensateWith: RefundCardAction }
@Workflow({
name: "order-workflow",
version: "1.0.0",
flow: [
start(OrderWorkflowStart)
.returns(OrderRequestDto)
.toAction(ReserveStockAction)
.returns(StockReservationDto) // data: ReleaseStockAction reads its reservationId
.toAction(ChargeCardAction)
.returns(PaymentDto) // RefundCardAction reads its paymentId
.toAction(ShipOrderAction)
.onError(
CarrierError, // the carrier failed: undo what was done, newest first — checked
toAction(RefundCardAction) // side effects without output: no .returns
.onError(
PaymentProviderError, // the refund failed: a person takes over; the stock still goes back
toAction(NotifyFinanceTeamAction)
.toAction(ReleaseStockAction),
)
.toAction(ReleaseStockAction),
)
.returns(OrderShippedDto)
.toFinish(OrderWorkflowFinish), // a finish commits what is still open
action(ReleaseStockAction) // reached from two places: its own path
.toFinish(OrderFailedWorkflowFinish),
],
})
export class OrderWorkflow {}- It stands right after the step that throws (an agent, an action, a router), before that
step's
.returns(…), or right after the source of an own path (agent(X).onError(E, …).returns(oneOf(…)).switch(…)). It catches that step's own failure only, never the next step's; the main path continues after it. - A step may have several; the most specific class wins, matched by class through the error's
cause chain — a subclass is caught by its parent's handler, and so is an error caused by it
(
ActionFailedErrorwraps what an action throws, the original incause). Two handlers for one class on one step fail assembly ([flow.duplicate-error-handler]). Without a handler the execution fails. - The side path is a path like any other:
toAction(…)/toAgent(…)continued to a finish, ortoFinish(F)alone; it may have its own.onError(…)for a failed undo. - The failure is a plain record in state (
lastError: its codes, message, the failed node), so a handler holds after a resume. The visited path shows the failed step before the side path. - Limits are errors too:
.onError(VisitsLimitExceededError, …)after a node withmaxVisits,.onError(LimitExceededError, …)for any limit.
-
compensateWithandcommittedByname@Actionclasses — an undo is code, never a model call ([action.compensator-not-an-action],[action.committer-not-an-action]); an action does not undo itself ([action.self-compensation]), and an undo action is not undone ([action.compensator-compensates], no chains). - The undo reads only what the path holds right after its action — what reached the action and
what it returned, on every path (
[action.invalid-compensator-input]):ReleaseStockActionreads theStockReservationDtoits action returned. - A side path entered after an undoable action succeeded —
.onErrorof a later step, or.onJoinErrorof a later launch — reaches a finish only through its undo action ([flow.uncompensated-action]), and runs several undo actions newest first ([flow.compensation-out-of-order]). An undo whose own.onErrortakes over still counts as run. A failed join's side path owes what was open before the launch; the launch's finished branches are undone by the framework (#340). -
committedBy: ConfirmOfferAction— once it succeeds, nothing undoes the action any more: a side path after it owes nothing for it. Every path from the action then runs the committer or the undo before a finish ([flow.uncommitted-action], an error — leavecommittedByout and a finish commits it); a committer no path from the action reaches is a warning ([flow.committer-unreachable], an error under--strict).committedBywithoutcompensateWithhas no effect — nothing undoes the action, so nothing commits it — and is an error ([action.committer-without-compensator]): addcompensateWith, or removecommittedBy.
Without RefundCardAction on the side path above, the check names it:
[flow.uncompensated-action] order-workflow: the .onError(CarrierError, …) side path after
ship-order-action reaches order-failed-workflow-finish without refund-card-action (compensates
charge-card-action); add it to the side path, or commit the action before it
agboss describe prints each undoable action (--json: its node's action,
CLI):
actions
charge-card-action compensateWith refund-card-action
reserve-stock-action compensateWith release-stock-action
examples/flow-patterns/src/hiring/ sends an offer this way: two undoable steps, both
committedBy: ConfirmOfferAction, and a letter whose failure undoes them.
At run time every undoable action that succeeds opens a record in the execution's state (the
compensations channel, checkpointed with it, and mirrored in process at once): its step and key,
its undo action, its committer, and a snapshot of what its undo reads — the undo's input fields as
the scope held them right after the action. A parallel branch's record opens when the branch ends.
A record is closed by
- its committer succeeding (
committedBy): nothing undoes it any more; - a finish, for a record without
committedBy(with one, the rules close it first); a child workflow's finish commits the child's own records (Workflow as a step); - its undo action on a side path — it closes the newest open record of the action it undoes:
CompensationReason.SidePath,CompensationStatus.Compensated, orFailed(with its error) when the undo's own.onErrortook its failure. That undo keeps its own visit's key.
result.compensations (and result.record.compensations) list the undo actions that ran, in the
order they ended — CompensationRecord { compensated, compensator, reason, status, error?, startedAt, endedAt }; a run that undid nothing has []. Tests: toHaveCompensated(Action, { by, reason?, status? }) (Testing).
A join that fails drops its branches' values: an .onJoinError side path cannot undo a finished
sibling. So before that side path runs — or the branch's error fails the execution — the framework
undoes the launch's finished undoable branches itself (CompensationReason.JoinFailed), newest
first. It first awaits the launch's branches whose bodies still run, up to compensationTimeout;
one that returns meanwhile is undone too.
// BookFlightAction { compensateWith: CancelFlightAction }, BookHotelAction { compensateWith: CancelHotelAction };
// neither has committedBy: the finish commits
@Workflow({
name: "trip-workflow",
version: "1.0.0",
flow: [
start(TripWorkflowStart)
.returns(TripRequestDto)
.toParallelActions(BookFlightAction, BookHotelAction)
.join()
.onJoinError(
BookingFailedError, // the hotel failed: the finished flight is cancelled before this path runs
toFinish(TripNotBookedWorkflowFinish),
)
.returns(FlightBookingDto)
.returns(HotelBookingDto)
.toFinish(TripBookedWorkflowFinish),
],
})
export class TripWorkflow {}Each automatic undo runs in a frame of its own: its step is the undo action's, its
executionContext.step.idempotencyKey is the undone step's key + :compensation
(execution-1:book-flight-action:1:compensation — a second run of the same undo carries the same
key), step.compensates is the step it undoes, and its executionContext.signal is a clean-up
signal — not the execution's: it aborts after @Application({ compensationTimeout }) (default
seconds(15), App), when the instance loses the tree's lease or stops. It takes no tree or
instance slot and no budget refuses it; what its services cost (SpendRecorder) is the execution's
spend under action:<undo name> (FinOps). onCompensationStart / onCompensationEnd
report each one (Observability).
An undo that fails does not stop the others; then the execution fails with
CompensationFailedError (compensation.failed): failures are the undo actions that threw,
cause the branch's error — toFailWith(BookingFailedError) still matches through it. No
.onError / .onJoinError takes it. agboss describe marks a launched undoable action:
actions
book-flight-action compensateWith cancel-flight-action (on a failed join: automatic)
examples/flow-patterns/src/travel/ books a trip this way.
An execution that stops before a finish — an uncaught step failure, an uncaught limit
(BudgetExceededError, StepsLimitExceededError, VisitsLimitExceededError), app.cancel, a
sibling's failure under FailurePolicy.FailFast — undoes its open undoable actions, newest first,
before it ends (#340 PR 3): CompensationReason.ExecutionFailed for a failure or a limit,
ExecutionCancelled for a cancel or a FailFast abort. Each undo runs as a failed join's does
(above): its own frame and key, the clean-up signal, no slot, no budget check. Where it happens:
- A child workflow undoes its own actions in its parent's step, before its error reaches the parent (Workflow as a step); its finish commits them, so a finished child is never undone by a later failure of its parent.
-
A root is undone by the instance that holds its lease, after its pass fails and before its
record is failed, its keys are removed and
onExecutionErroris observed (whosecompensationslists what ran, Observability) — the actions of a paused child of it too, read from its checkpoint. The record is written after the undo (#340 S7): while it runs the root staysrunning, then its status and error are final (CompensationFailedErrorincluded). -
app.cancelof a paused tree takes its lease, withdraws its pauses (their channels are told, #337), undoes, and only then returns{ cancelled: true }(Cancellation); a cancel from another instance (#360) of a running tree returns once it is recorded, and the holder undoes it.
LangGraph rejects an aborted pass before the step bodies still running return, and their writes
never reach the checkpoint, so the undo reads the records from the checkpoint (pending writes
applied) and from the instance's own registry, which every record reaches the moment its step
writes it. It first awaits the tree's running step bodies — children still undoing among them —
up to compensationTimeout: an action that returns meanwhile is undone too; one still running then
is logged (compensation.step-still-running) and, if it returns later, undone at once, in process
(its key <step key>:compensation makes a second run safe; a failure then is logged,
compensation.late-undo-failed). The undo's service spend goes to the ledger at once
(FinOps).
A failing undo does not stop the others; then the execution fails with CompensationFailedError,
its cause the original error (toFailWith(StepFailedError) still matches through it).
CompensationFailedError passes a child's boundary as it is — never wrapped in
ChildExecutionFailedError, like a cancel or an exhausted budget — so no .onError of the parent
takes it, and the parent, getting it uncaught, undoes its own open actions too. A cancel whose undo
failed still cancels the tree, but app.cancel of a paused tree rejects with the
CompensationFailedError (its cause the ExecutionCancelledError), and a running tree's holder's
execute / resume rejects with it (#340 S7).
Durable (#340 PR 4). An undo that an instance's stop, crash or lost lease interrupts is finished
by the instance that takes the tree over: before a root's undo starts the reserved tree key
@agboss/unwinding ({ reason, cause }) is written, and @agboss/compensated/<compensationKey>
after each undo that succeeds (a child's own undo marks @agboss/unwinding:<childExecutionId>).
Recovery finds the root still running, runs the remaining undos on its checkpoint state — the ones
marked done skipped — and ends it failed with the recorded reason, without a pass; a child
re-entered mid-undo finishes its own before any step and raises its error again
(Several instances → An undo interrupted mid-way).
A failed undo is not marked: the next holder runs it again (its key <step key>:compensation
makes that safe).
test("a cancel while paused: the pause is withdrawn, both undone, then cancelled", async ({ app, mockLlm, mockOf }) => {
mockOf(AtsClientService).registerOffer.mockResolvedValue("offer-7");
mockOf(HrCalendarService).bookSlot.mockResolvedValue("slot-3");
mockLlm(OfferLetterAgent).thenReturn(replyWith(OfferLetterDto, { text: "Dear Ada…" }));
mockLlm(OfferSenderAgent).thenReturn(callTool(SendOfferLetterTool, { text: "Dear Ada…" }));
const paused = await app.execute(SendOfferWorkflowStart, REQUEST, { threadId: "t-1" });
const cancel = await app.cancel(paused.threadId); // waits for the undo, each bounded by compensationTimeout
expect(cancel).toEqual({ cancelled: true });
expect(mockOf(HrCalendarService).releaseSlot.mock.calls).toHaveLength(1);
expect(mockOf(AtsClientService).withdrawOffer.mock.calls).toHaveLength(1);
});A side path is where a failure is handled: writing the undo there keeps it reviewed, typed (the
undo reads what its action returned) and tested like the rest of the flow, and the declaration
lets agboss check prove no side path forgets it. Some stops have no path to write: a cancel, an
uncaught step failure or limit, an aborted child workflow, and the finished branches of a failed
join, whose values never reach the side path. There — and only there — the framework runs the open
actions' undo actions itself, newest first, each with its own idempotency key and a bounded
clean-up signal; a failing undo does not stop the others, and the execution fails with
CompensationFailedError. A failed join's undo came with #340 PR 2, the undo of an execution that
ends without a finish — a cancel, an uncaught failure or limit, an aborted child — with #340 PR 3
(above), and finishing an undo a stop, a crash or a
lost lease interrupted with #340 PR 4.