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
- Settle exactly once per reservation: route all accounting through a single owner of the reservation object.
- In guard code, check reservation.settled (or track settled IDs in a Set) before calling settle.
- 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
- Give each reservation exactly one owner responsible for settling it.
- Use a done flag or Set to make settle idempotent in adapters with multiple completion callbacks (stream end, promise resolution, error).
- In retry loops, create a fresh reservation per attempt rather than reusing one.
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
- cave_retry_accounting_invalid
- cave_breaker_retry_requires_budget
- cave_breaker_retry_spend_invalid
- cave_budget_denomination_ambiguous
- cave_budget_max_invalid
AI-assisted analysis of JuliusBrussee/caveman@27d5a3981a (2026-08-15).
Data as JSON: /api/errors/bad8656a16d7f011.
Report an issue: GitHub.