JuliusBrussee/caveman · error · Error

cave_budget_reservation_double_settle

Error message

cave_budget_reservation_double_settle

What it means

Thrown by BudgetMeter.settle when the reservation passed in has already been settled. Reservations are single-use: settle() replaces the reserved worst-case amount with the measured cost, and the reservation is flagged settled on first use. Settling twice would double-count spend and corrupt the ledger, so it is rejected as an invariant violation.

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 27d5a3981a)

Solutions

  1. Settle exactly once per reservation: route all accounting through a single owner of the reservation object.
  2. In guard code, check reservation.settled (or track settled IDs in a Set) before calling settle.
  3. If multiple observers exist, have only the terminal one (success or error, exclusively) perform the settle — use a done flag or Promise's settle-once semantics.

Example fix

// before
stream.on("end", () => meter.settle(reservation, cost));
request.then(() => meter.settle(reservation, cost)); // second settle throws

// after
let settled = false;
const settleOnce = (cost: number) => {
  if (!settled) { settled = true; meter.settle(reservation, cost); }
};
stream.on("end", () => settleOnce(cost));
request.then(() => settleOnce(cost));
Defensive patterns

Strategy: try-catch

Validate before calling

class SettleOnce {
  private done = false;
  constructor(private meter: BudgetMeter, private reservation: BudgetReservation) {}
  settle(cost: number): void {
    if (this.done || this.reservation.settled) return;
    this.done = true;
    this.meter.settle(this.reservation, cost);
  }
}

Type guard

function isUnsettled(r: BudgetReservation): boolean {
  return !r.settled;
}

Try / catch

try {
  meter.settle(reservation, actual);
} catch (e) {
  if (e instanceof Error && e.message === "cave_budget_reservation_double_settle") {
    logger.warn("settle skipped: reservation already settled", { atCall });
    return;
  }
  throw e;
}

Prevention

When it happens

Trigger: Calling settle(reservation, cost) twice with the same reservation object; two code paths both handling the same completed call (e.g. a success handler and a completion callback); a carve-out helper that settles a child reservation and then the caller settles the parent reservation again.

Common situations: Promise wrappers where both .then() and .finally() trigger accounting; retry loops that reuse a reservation object across attempts; fan-out code where multiple observers of one provider call each try to record its cost.

Related errors


AI-assisted analysis of JuliusBrussee/caveman@27d5a3981a (2026-08-15). Data as JSON: /api/errors/bad8656a16d7f011. Report an issue: GitHub.