Skip to content

[bug] Two autonomous entry points still start model work with no budget pool — #184 promised an admission check at every one #502

Description

@serge-ivo

The promise this misses

#184 ("Autonomous work budget", closed 2026-08-04) scoped itself explicitly:

Admission check at every autonomous entry point (cron, webhook, delivery, delegation) and on retry/replay.

and named the chokepoint:

Natural chokepoint for both: the BYOK/Workers-AI call path that already writes ai_usage.

The check did not land at that chokepoint. It landed in reserve(), which is reached only by callers that were handed a pool. Two autonomous entry points therefore start unattended model work with no pool, no reservation, and no account-ceiling check — the same escape #374 documented and fixed for the browser Loop, still open on other paths.

(This is orthogonal to #485. #485 turned the account ceilings observe-only; these paths would be uncovered either way, and that is precisely why they are worth naming separately.)

Verified coverage of the budget today

accountUsageSince has exactly one enforcement caller — workers/api/src/lib/delegation-budget-store.ts:305 inside reserve(). (lib/connectors/supervision.ts:334,638 and routes/budget.ts:221,284 read it only to report.) reserve() has exactly two callers: workflows/agent-loop.ts:88 and workflows/coding-session.ts:453. Grepped, not assumed.

Gap 1 — MCP coding_loop_start runs up to 50 unbudgeted iterations

workers/mcp/src/instance-tools/coding.ts:43-140. The tool runs the whole loop inline in the MCP server:

  • :84POST /v1/instances/${id}/chat with the objective
  • :105while (state.running && state.iteration < state.maxIterations)
  • :112POST /v1/instances/${id}/loop-decide
  • :139POST /v1/instances/${id}/chat with the returned instruction

Neither endpoint touches the budget. /loop-decide is workers/api/src/routes/instances-chat.ts:134-165: it authorizes the instance, validates the body, calls runLoopDecide, returns. No openBudget, no reserve, no accountUsageSince. /chat reaches AgentDO and never calls reserve() either.

So this is BYOK Claude deciding, and BYOK Claude acting, up to max_iterations (zod .max(50), :30) times, on the far side of every budget guard — the identical shape #374 described for the browser Loop ("/loop-decide reserves nothing and settles nothing. An N-iteration browser Loop is N unbudgeted BYOK Claude calls plus N Engine turns"), which was fixed by routing that button through POST /looploopDriverFor → the Pilot.

The bound that does exist is the iteration cap and the dry_run preview at :65-77, which honestly states the spend commitment. That makes this a bounded leak, not an unbounded one — but it is bounded by a number the caller chooses, not by the account's pool.

Gap 2 — POST /v1/instances/:id/coding/sessions/:sid/run starts a Pilot with no pool

workers/api/src/routes/coding.ts:625-641 creates the CODING_SESSION workflow with params: { instanceId, userId, sessionId, repoId, runnerNode, cloneUrl, branch, token, tokenUsername, goal, driverId } — no budgetId, no depth. Both are optional on the params type (workers/api/src/workflows/coding-session-params.ts:47,49).

The Pilot then skips the budget entirely, workers/api/src/workflows/coding-session.ts:451-452:

const budgetId = event.payload.budgetId ?? null;
if (!budgetId) return decideCodingAction(env, userId, p, { kind: "coding", instanceId });

The comment three lines above (:445-448) states the intent this defeats: "The LLM call is the one place this loop spends money, so it is the one place the budget has to sit. Delegated runs (#159) previously reached the Pilot with a pool id and never drew on it — unbounded spend on exactly the path a supervisor can trigger without a human watching." The delegated path was fixed; the direct one still has no pool to pass.

Reachability: no console or agents/coder/web caller greps out (the Coding tab's Loop goes through POST /loop since #374), and no MCP caller — MCP only uses capture/message/end/restart. It is a documented owner-authenticated API route (it is in the route table in CLAUDE.md), so an agent holding a session token, a script, or a future client reaches it.

Adjacent, worth noting but NOT a defect

lib/pipeline-run-start.ts:83 opens a pool only when pipelineOpensDelegations(pipeline). A cron- or webhook-triggered pipeline whose steps are plain ai_generate therefore runs unbudgeted. That looks deliberate (a pipeline's step count is declared and finite, unlike a loop's), but #184's scope named cron and webhook explicitly, so the owner should confirm it is a choice rather than an oversight.

What to do — cheapest first

  1. Gap 2 is a two-line fix: openBudget(c.env, uid, instanceId) in the /run handler and pass budgetId + depth: 0 into the workflow params, exactly as lib/delegate-instance.ts:72 and routes/tools.ts:957 already do. Everything downstream already handles it.
  2. Gap 1: make coding_loop_start a thin wrapper over the durable path — POST /v1/instances/:id/loop and then poll /loop/:runId — instead of reimplementing the loop in the MCP DO. It inherits the pool, the driver claim ([docs] Reconcile runtime docs with relay-only pags up #208) and merge authority ([safety] Nothing decides whether an agent may merge to main — three unattended merges already happened #314) for free, and the tool stops being a second orchestrator that can drift from lib/loop-orchestrator.ts. It also stops holding an MCP request open for 50 model calls.
  3. The invariant that stops this recurring: a source test asserting that every path which drives a model N times without a human in the loop passes a budgetId. Today the rule is enforced by whichever call sites remembered.

Acceptance criteria

  • Starting a Pilot via POST …/coding/sessions/:sid/run creates a delegation_budgets row and each decide increments delegations_used.
  • coding_loop_start produces an agent_loop_runs row with a budget_id, and coding_loop_status reports iterations from that row rather than from MCP-DO memory.
  • With BUDGET_ENFORCE=1 and a tripped account ceiling, both paths stop with the account_ceiling message instead of continuing.
  • A test enumerates the autonomous entry points and fails when one is added without a pool.

Regression risk

Gap 2's fix makes a previously unbounded path bounded — a long unattended session that used to run freely can now stop at the per-tree limit (50 delegations / $5 charged). That is the intended behaviour, but it will read as a new failure the first time it happens; the message from reserve() already names which limit fired. Gap 1's fix changes coding_loop_start from synchronous-transcript to start-and-poll, which is a breaking change to the tool's return shape — the description at coding.ts:44 promises "Runs the WHOLE loop before returning", and workers/mcp/src/instance-tools.test.ts:734,811 assert against the current shape. Decide that deliberately; I would take it, because holding an MCP call open across 50 BYOK model calls is its own problem.

Found while auditing #485/#478. Related: #374 (same class, browser Loop), #184 (the promise), #159 (the delegated Pilot fix).

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions