{"record":{"id":"bad8656a16d7f011","repo":"JuliusBrussee/caveman","slug":"cave-budget-reservation-double-settle","errorCode":"cave_budget_reservation_double_settle","errorMessage":"cave_budget_reservation_double_settle","messagePattern":"cave_budget_reservation_double_settle","errorType":"error_code","errorClass":null,"httpStatus":null,"severity":"error","filePath":"packages/agent/src/budget.ts","lineNumber":408,"sourceCode":"    return Object.freeze({ child, reservation });\n  }\n\n  /** Return a carved wallet's unspent remainder to this meter. */\n  settleCarve(carve: BudgetCarve): void {\n    this.liveWallets.delete(carve.child);\n    this.settle(carve.reservation, carve.child.settled);\n  }\n\n  /**\n   * Replace a reservation with the measured cost of the call it covered.\n   *\n   * `actual` is recorded as it came in. It is never clamped to the reservation\n   * and never floored at `max`: a ledger that quietly rewrites what a call cost\n   * is a fake ledger, and the honest failure is a flagged breach, not a\n   * flattering number.\n   */\n  settle(reservation: BudgetReservation, actual: number): void {\n    if (reservation.settled) throw new Error(\"cave_budget_reservation_double_settle\");\n    reservation.settled = true;\n    this.reservedAmount = Math.max(0, this.reservedAmount - reservation.amount);\n    if (!Number.isFinite(actual)) {\n      // A NaN/Infinity measured cost is not knowable, so it fails closed at the\n      // reservation's worst case rather than booking $0. A call\n      // whose real cost we cannot read is never a free call — booking zero would\n      // be the flattering number this ledger refuses to write.\n      this.settledAmount += reservation.amount;\n    } else if (actual > 0) {\n      this.settledAmount += actual;\n    }\n    if (this.settledAmount > this.max) this.breachedFlag = true;\n  }\n\n  /** Drop a reservation whose call never reached the provider. */\n  cancel(reservation: BudgetReservation): void {\n    if (reservation.settled) return;\n    reservation.settled = true;","sourceCodeStart":390,"sourceCodeEnd":426,"githubUrl":"https://github.com/JuliusBrussee/caveman/blob/3ee70a102609e550bd2e68004bf5990a9341c851/packages/agent/src/budget.ts#L390-L426","documentation":"`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.","triggerScenarios":"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).","commonSituations":"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).","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."],"exampleFix":"// before\nmeter.settle(reservation, usage.costUsd);\n// ...later, cleanup path\nmeter.settle(reservation, usage.costUsd); // throws\n\n// after\nif (!reservation.settled) {\n  meter.settle(reservation, usage.costUsd);\n}","handlingStrategy":"try-catch","validationCode":"if (!reservation.settled) {\n  meter.settle(reservation, actualCost);\n}","typeGuard":"const isUnsettled = (r: BudgetReservation): boolean => !r.settled;","tryCatchPattern":"try {\n  meter.settle(reservation, actual);\n} catch (e) {\n  if (e instanceof Error && e.message === \"cave_budget_reservation_double_settle\") {\n    // already settled elsewhere; treat as no-op\n    return;\n  }\n  throw e;\n}","preventionTips":["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."],"tags":["budget","reservations","state"],"backgroundTag":"invalid-state-transition","analyzedSha":"3ee70a102609e550bd2e68004bf5990a9341c851","analyzedAt":"2026-09-20T15:53:39.229Z","contentChangedAt":"2026-09-20T15:53:39.229Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}