JuliusBrussee/caveman · error

cave_budget_reservation_double_settle

cave_budget_reservation_double_settle

Error message

cave_budget_reservation_double_settle

What it means

`BudgetMeter.settle(reservation, actual)` throws `cave_budget_reservation_double_settle` when the reservation's `settled` flag is already true. Each reservation may be settled exactly once: settling replaces the worst-case hold with the measured cost, and settling twice would release the hold twice and double-book the spend, corrupting the ledger. The library treats this as an internal contract violation at the call site and throws rather than silently ignoring the second settle.

Solutions

  1. Settle each reservation exactly once — guard with the flag: `if (!reservation.settled) meter.settle(reservation, actual);`
  2. For failed attempts, call `meter.cancel(reservation)` instead of settling; cancel settles at zero and releases the hold.
  3. For carved wallets, use `settleCarve(carve)` only and never settle `carve.reservation` separately — it settles internally.
  4. Refactor retry loops so each attempt gets its own reservation (call `reserve` again) and settle/cancel exactly once per attempt.

Example fix

// before
meter.settle(reservation, usage.costUsd);
// ...later, cleanup path
meter.settle(reservation, usage.costUsd); // throws

// after
if (!reservation.settled) {
  meter.settle(reservation, usage.costUsd);
}
Defensive patterns

Strategy: try-catch

Validate before calling

if (!reservation.settled) {
  meter.settle(reservation, actualCost);
}

Type guard

const isUnsettled = (r: BudgetReservation): boolean => !r.settled;

Try / catch

try {
  meter.settle(reservation, actual);
} catch (e) {
  if (e instanceof Error && e.message === "cave_budget_reservation_double_settle") {
    // already settled elsewhere; treat as no-op
    return;
  }
  throw e;
}

Prevention

When it happens

Trigger: Calling `settle(reservation, actual)` twice on the same reservation; settling a reservation that was already passed to `cancel()` (cancel also marks it settled, though cancel itself is a no-op on settled reservations); mixing responsibilities — e.g. framework code settles after the provider call and your `onBudgetExhausted`/breakers callback settles again; reusing one reservation object across retry attempts and settling each attempt against it; settling a carve's reservation directly and then also calling `settleCarve()` (which settles internally).

Common situations: Custom retry wrappers that settle per attempt instead of cancel-on-failure/settle-on-success; a code path that calls both `settleCarve(carve)` and `settle(carve.reservation, ...)` for the same carve; holding reservations in a collection and iterating it twice (once for cost accounting, once for cleanup).

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.

Related errors


AI-assisted analysis of JuliusBrussee/caveman@3ee70a1026 (2026-09-20). Data as JSON: /api/errors/bad8656a16d7f011. Report an issue: GitHub.

Appendix: source

Thrown at packages/agent/src/budget.ts:408

    return Object.freeze({ child, reservation });
  }

  /** Return a carved wallet's unspent remainder to this meter. */
  settleCarve(carve: BudgetCarve): void {
    this.liveWallets.delete(carve.child);
    this.settle(carve.reservation, carve.child.settled);
  }

  /**
   * Replace a reservation with the measured cost of the call it covered.
   *
   * `actual` is recorded as it came in. It is never clamped to the reservation
   * and never floored at `max`: a ledger that quietly rewrites what a call cost
   * is a fake ledger, and the honest failure is a flagged breach, not a
   * flattering number.
   */
  settle(reservation: BudgetReservation, actual: number): void {
    if (reservation.settled) throw new Error("cave_budget_reservation_double_settle");
    reservation.settled = true;
    this.reservedAmount = Math.max(0, this.reservedAmount - reservation.amount);
    if (!Number.isFinite(actual)) {
      // A NaN/Infinity measured cost is not knowable, so it fails closed at the
      // reservation's worst case rather than booking $0. A call
      // whose real cost we cannot read is never a free call — booking zero would
      // be the flattering number this ledger refuses to write.
      this.settledAmount += reservation.amount;
    } else if (actual > 0) {
      this.settledAmount += actual;
    }
    if (this.settledAmount > this.max) this.breachedFlag = true;
  }

  /** Drop a reservation whose call never reached the provider. */
  cancel(reservation: BudgetReservation): void {
    if (reservation.settled) return;
    reservation.settled = true;

View on GitHub (pinned to 3ee70a1026)