JuliusBrussee/caveman · error

cave_budget_controller_unbound

cave_budget_controller_unbound

Error message

cave_budget_controller_unbound

What it means

BudgetController lazily resolves its BudgetMeter through a WeakMap of controller-to-meter bindings. This error means the controller was created (createBudgetController) but never bound to a meter before any budget-reading method (e.g. tranches) was accessed. The library throws rather than silently reporting an empty budget.

Solutions

  1. Ensure the controller is created and consumed within the run lifecycle that calls bindBudgetController before any budget access
  2. If reusing a controller across runs, re-bind it via bindBudgetController after unbind, or create a fresh controller per run
  3. Check for early access in startup/logging code that reads tranches before the run binds the meter
  4. Catch the error to detect unbound controllers and fall back to a no-op or re-initialization path

Example fix

// before
const controller = createBudgetController();
console.log(controller.tranches); // throws
// after
const controller = createBudgetController();
bindBudgetController(controller, meter);
console.log(controller.tranches);
Defensive patterns

Strategy: try-catch

Validate before calling

// WeakMap binding is not directly observable; track binding yourself
let bound = false;
function bind(c: BudgetController, m: BudgetMeter) { bindBudgetController(c, m); bound = true; }
if (!bound) throw new Error("controller not bound yet");

Try / catch

try {
  const tranches = controller.tranches;
} catch (e) {
  if (e instanceof Error && e.message === "cave_budget_controller_unbound") {
    // re-bind or defer budget access until the run is active
  } else throw e;
}

Prevention

When it happens

Trigger: Calling any BudgetController accessor (tranches, and other methods routed through the private meter()) on a controller that has not had bindBudgetController(controller, meter) called, or after unbindBudgetController(controller) released its binding.

Common situations: Constructing a controller directly instead of through the run lifecycle that binds it; accessing budget state after the run ended and unbound the controller; sharing one controller across two runs where the second bind attempt failed; test code instantiating BudgetController without simulating the bind step.

Understand the failure class

Background: "This is a bug, please report it": internal invariant violations, unreachable panics, and SNH errors explained — this error's family across 47 libraries.

Related errors


AI-assisted analysis of JuliusBrussee/caveman@3ee70a1026 (2026-09-20). Data as JSON: /api/errors/62849c896fe2fc8b. Report an issue: GitHub.

Appendix: source

Thrown at packages/agent/src/budget.ts:830

  get released(): number {
    return this.meter().released;
  }

  get max(): number {
    return this.meter().max;
  }

  get denomination(): BudgetDenomination {
    return this.meter().denomination;
  }

  get tranches(): readonly BudgetTranche[] {
    return this.meter().tranches;
  }

  private meter(): BudgetMeter {
    const bound = controllerMeters.get(this);
    if (bound === undefined) throw new Error("cave_budget_controller_unbound");
    return bound;
  }
}

export function createBudgetController(): BudgetController {
  return new BudgetController();
}

/** Package-internal. Binds a controller to the run that owns its budget. */
export function bindBudgetController(
  controller: BudgetController,
  meter: BudgetMeter,
): void {
  if (controllerMeters.has(controller)) throw new Error("cave_budget_controller_in_use");
  controllerMeters.set(controller, meter);
}

/** Package-internal. Releases the binding when the run ends. */

View on GitHub (pinned to 3ee70a1026)