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

  1. Create a fresh controller per run with createBudgetController(); never share one across concurrent runs.
  2. If reuse is required, call unbindBudgetController(controller) in a finally block when each run ends before binding again.
  3. 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

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


AI-assisted analysis of JuliusBrussee/caveman@27d5a3981a (2026-08-15). Data as JSON: /api/errors/b33fd52e991337b5. Report an issue: GitHub.