{"record":{"id":"8890b7befc3ef6a6","repo":"JuliusBrussee/caveman","slug":"cave-retry-accounting-invalid","errorCode":null,"errorMessage":"cave_retry_accounting_invalid","messagePattern":"cave_retry_accounting_invalid","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/agent/src/breakers.ts","lineNumber":319,"sourceCode":"  /** Settle receipt evidence for one already-recorded retry attempt. */\n  settleRetry(\n    attempt: number,\n    measuredSpend: number,\n    spendBasis: NonNullable<BreakerEvent[\"spendBasis\"]>,\n  ): void {\n    // Attempt numbers restart for each model call. Settle the newest matching\n    // event that has not already been settled, never an earlier call's retry.\n    let event: BreakerEvent | undefined;\n    for (let index = this.events.length - 1; index >= 0; index--) {\n      const candidate = this.events[index]!;\n      if (candidate.kind === \"retry_attempted\" && candidate.count === attempt &&\n          candidate.measuredSpend === undefined) {\n        event = candidate;\n        break;\n      }\n    }\n    if (event === undefined || !Number.isFinite(measuredSpend) || measuredSpend < 0) {\n      throw new Error(\"cave_retry_accounting_invalid\");\n    }\n    (event as { measuredSpend?: number; spendBasis?: BreakerEvent[\"spendBasis\"] }).measuredSpend = measuredSpend;\n    (event as { measuredSpend?: number; spendBasis?: BreakerEvent[\"spendBasis\"] }).spendBasis = spendBasis;\n  }\n\n  /** Deterministic exponential backoff. No jitter: a breaker must be reproducible. */\n  backoffMs(attempt: number): number {\n    return this.config.retryBackoffMs * 2 ** Math.max(0, attempt - 1);\n  }\n}\n\nexport function callSignature(toolName: string, args: unknown): string {\n  return sha256(stableStringify({ tool: toolName, args: args ?? null }));\n}\n","sourceCodeStart":301,"sourceCodeEnd":334,"githubUrl":"https://github.com/JuliusBrussee/caveman/blob/27d5a3981a347890211bb1bf2439e5c821a63bc9/packages/agent/src/breakers.ts#L301-L334","documentation":"Thrown by BreakerEvents.settleRetrySpend when it cannot settle a retry's measured spend: either no 'retry_attempted' event exists whose count matches the given attempt and whose measuredSpend is still unset, or the measuredSpend value itself is non-finite or negative. The breaker keeps a strict audit trail; attempt numbers restart per model call, and each retry event must be settled exactly once. This error means the retry ledger and the settle call have diverged — a double settle, a wrong attempt index, or garbage cost data from the provider adapter.","triggerScenarios":"Calling settleRetrySpend(attempt, spend) twice for the same retry event; passing an attempt number that never had a retry_attempted event recorded; calling settle before recordRetry; passing NaN, Infinity, or a negative number as measuredSpend; settling attempt N when the newest unsettled matching event belongs to an earlier model call that was already settled.","commonSituations":"A provider adapter that reports usage twice (once from streaming chunks and once from the final response), leading the caller to settle the same retry twice; an off-by-one when computing attempt numbers across a retry loop that spans multiple model calls; a usage parser producing NaN when the provider omits a usage field.","solutions":["Audit every call site of settleRetrySpend: confirm each retry event is settled exactly once, guarded by its own control flow rather than hope.","Check the attempt value being passed — it must match the count recorded on the retry_attempted event for the current model call, not a global retry counter.","Validate measuredSpend with Number.isFinite(spend) && spend >= 0 before calling settle; log the raw provider usage payload when it fails.","If usage arrives from multiple sources (stream + final message), pick one canonical source and ignore the other, or make settle idempotent per event upstream."],"exampleFix":"// before\nbreaker.settleRetrySpend(attempt, parsedUsage.totalCost); // parsedUsage.totalCost is NaN when provider omits usage\n\n// after\nconst spend = parsedUsage?.totalCost;\nif (spend !== undefined && Number.isFinite(spend) && spend >= 0 && !retrySettled.has(attempt)) {\n  retrySettled.add(attempt);\n  breaker.settleRetrySpend(attempt, spend);\n}","handlingStrategy":"validation","validationCode":"const settled = new Set<number>();\nfunction settleRetry(breaker: BreakerEvents, attempt: number, spend: number | undefined): boolean {\n  if (settled.has(attempt)) return false; // already settled for this call\n  if (spend === undefined || !Number.isFinite(spend) || spend < 0) return false; // unusable measurement\n  settled.add(attempt);\n  breaker.settleRetrySpend(attempt, spend);\n  return true;\n}","typeGuard":"function isSettleableSpend(v: unknown): v is number {\n  return typeof v === \"number\" && Number.isFinite(v) && v >= 0;\n}","tryCatchPattern":"try {\n  breaker.settleRetrySpend(attempt, spend);\n} catch (e) {\n  if (e instanceof Error && e.message === \"cave_retry_accounting_invalid\") {\n    // Ledger divergence: log the attempt number, event log length, and raw usage payload; do not retry the settle.\n    logger.error(\"retry settle diverged\", { attempt, spend, events: breaker.events.length });\n    return;\n  }\n  throw e;\n}","preventionTips":["Track settled attempt numbers in a Set per model call so double settles are impossible.","Validate provider usage payloads (Number.isFinite, >= 0) at the adapter boundary before they reach the breaker.","Treat settle as single-owner: exactly one code path terminates a retry attempt and records its spend."],"tags":["retry","accounting","invariant","breaker","usage"],"backgroundTag":null,"analyzedSha":"27d5a3981a347890211bb1bf2439e5c821a63bc9","analyzedAt":"2026-08-15T09:26:11.751Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}