JuliusBrussee/caveman · error · Error
cave_budget_controller_in_use
Error message
cave_budget_controller_in_use
What it means
Thrown by bindBudgetController when the controller is already bound to a meter. The binding map is keyed by controller instance and one controller can be bound to only one run's meter at a time; attempting a second bind (before unbindBudgetController ran) is rejected. This is package-internal API, so user code typically hits it only via a wrapper that mismanages controller lifecycles.
Source
Thrown at packages/agent/src/budget.ts:844
}
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. */
export function unbindBudgetController(controller: BudgetController): void {
controllerMeters.delete(controller);
}
/** What the meter says the runtime may do with the next provider call. */
export type CallPlan =
| { readonly action: "proceed"; readonly reservation: BudgetReservation | undefined; readonly outputTokenCap: number }
| { readonly action: "compact" }
| { readonly action: "stop"; readonly reason: RunStopReason };
/**
* The exhaustion ladder's arithmetic: full allowance, else a clamped allowance
* down to the floor, else exhaustion. `compactionAvailable` lets the caller
* insert the compaction rung between the full and clamped rungs.View on GitHub (pinned to 27d5a3981a)
Solutions
- Create a fresh controller per run with createBudgetController(); never share one across concurrent runs.
- If reuse is required, call unbindBudgetController(controller) in a finally block when each run ends before binding again.
- In wrappers, assert the controller is unbound before starting a run and fail loudly at the wrapper boundary.
Example fix
// before
const shared = createBudgetController();
await Promise.all([runAgent({ budget: b1, controller: shared }), runAgent({ budget: b2, controller: shared })]); // second bind throws
// after
await Promise.all([
runAgent({ budget: b1, controller: createBudgetController() }),
runAgent({ budget: b2, controller: createBudgetController() }),
]); Defensive patterns
Strategy: validation
Validate before calling
// One controller per run; no shared instances.
function freshRun(budget: RunBudget) {
return runAgent({ budget, controller: createBudgetController() });
} Prevention
- Never pass the same controller instance to two runs, concurrent or overlapping.
- If reuse is unavoidable, ensure unbindBudgetController runs in a finally block before rebinding.
- Treat bind errors as lifecycle bugs in your wrapper: fail loudly at the wrapper boundary instead of retrying.
When it happens
Trigger: Passing the same controller instance to two concurrent runs; starting a second run with a controller whose first run never unbound (crashed or leaked binding); a wrapper that binds on every retry without unbinding first.
Common situations: Pooling or caching controller instances across runs to save allocation; re-entering a run loop after a partial failure where cleanup (unbind) was skipped; tests that reuse module-level controllers across sequential runs without unbinding.
Related errors
- cave_budget_controller_unbound
- cave_budget_revoked
- cave_budget_cap_breached
- cave_breaker_retry_requires_budget
- cave_breaker_retry_spend_invalid
AI-assisted analysis of JuliusBrussee/caveman@27d5a3981a (2026-08-15).
Data as JSON: /api/errors/b33fd52e991337b5.
Report an issue: GitHub.