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
- Ensure the controller is created and consumed within the run lifecycle that calls bindBudgetController before any budget access
- If reusing a controller across runs, re-bind it via bindBudgetController after unbind, or create a fresh controller per run
- Check for early access in startup/logging code that reads tranches before the run binds the meter
- 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
- Always create controllers through the run lifecycle that binds them
- Never read budget properties outside an active run
- In tests, mirror the production bind/unbind sequence in setup/teardown
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
- cave_budget_cap_breached
- cave_budget_controller_in_use
- cave_budget_revoked
- A dynamic Strands scope requires the native agent…
- budget is below CCR storage minimum
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)