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
- Settle each reservation exactly once — guard with the flag: `if (!reservation.settled) meter.settle(reservation, actual);`
- For failed attempts, call `meter.cancel(reservation)` instead of settling; cancel settles at zero and releases the hold.
- For carved wallets, use `settleCarve(carve)` only and never settle `carve.reservation` separately — it settles internally.
- 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
- Settle each reservation exactly once; use cancel() for attempts that never reached the provider.
- For carved wallets rely on settleCarve() and never settle carve.reservation yourself.
- Give each retry attempt its own reservation instead of reusing one reservation object.
- Centralize settle/cancel in one code path so no callback or cleanup pass can settle twice.
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
- cave_budget_revoked
- assembly slot changed after being declared stable
- budget is below CCR storage minimum
- cannot read agent-native bundle journal
- cave_breaker_retry_requires_budget
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)