You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
#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:
:84 — POST /v1/instances/${id}/chat with the objective
:105 — while (state.running && state.iteration < state.maxIterations)
:112 — POST /v1/instances/${id}/loop-decide
:139 — POST /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 /loop → loopDriverFor → 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:
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
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.
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).
The promise this misses
#184 ("Autonomous work budget", closed 2026-08-04) scoped itself explicitly:
and named the chokepoint:
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
accountUsageSincehas exactly one enforcement caller —workers/api/src/lib/delegation-budget-store.ts:305insidereserve(). (lib/connectors/supervision.ts:334,638androutes/budget.ts:221,284read it only to report.)reserve()has exactly two callers:workflows/agent-loop.ts:88andworkflows/coding-session.ts:453. Grepped, not assumed.Gap 1 — MCP
coding_loop_startruns up to 50 unbudgeted iterationsworkers/mcp/src/instance-tools/coding.ts:43-140. The tool runs the whole loop inline in the MCP server::84—POST /v1/instances/${id}/chatwith the objective:105—while (state.running && state.iteration < state.maxIterations):112—POST /v1/instances/${id}/loop-decide:139—POST /v1/instances/${id}/chatwith the returned instructionNeither endpoint touches the budget.
/loop-decideisworkers/api/src/routes/instances-chat.ts:134-165: it authorizes the instance, validates the body, callsrunLoopDecide, returns. NoopenBudget, noreserve, noaccountUsageSince./chatreachesAgentDOand never callsreserve()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-decidereserves 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 throughPOST /loop→loopDriverFor→ the Pilot.The bound that does exist is the iteration cap and the
dry_runpreview 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/runstarts a Pilot with no poolworkers/api/src/routes/coding.ts:625-641creates theCODING_SESSIONworkflow withparams: { instanceId, userId, sessionId, repoId, runnerNode, cloneUrl, branch, token, tokenUsername, goal, driverId }— nobudgetId, nodepth. 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: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/webcaller greps out (the Coding tab's Loop goes throughPOST /loopsince #374), and no MCP caller — MCP only usescapture/message/end/restart. It is a documented owner-authenticated API route (it is in the route table inCLAUDE.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:83opens a pool only whenpipelineOpensDelegations(pipeline). A cron- or webhook-triggered pipeline whose steps are plainai_generatetherefore 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
openBudget(c.env, uid, instanceId)in the/runhandler and passbudgetId+depth: 0into the workflow params, exactly aslib/delegate-instance.ts:72androutes/tools.ts:957already do. Everything downstream already handles it.coding_loop_starta thin wrapper over the durable path —POST /v1/instances/:id/loopand 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-onlypags 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 fromlib/loop-orchestrator.ts. It also stops holding an MCP request open for 50 model calls.reserve/settleper iteration. Rejected as the primary fix — it is a third implementation of loop budgeting, and [bug] The Coding tab's Loop is still a browser poll — so merge authority, the driver claim and the spend budget all miss it #374's lesson was that the answer is one driver, not more guards bolted onto each copy.budgetId. Today the rule is enforced by whichever call sites remembered.Acceptance criteria
POST …/coding/sessions/:sid/runcreates adelegation_budgetsrow and eachdecideincrementsdelegations_used.coding_loop_startproduces anagent_loop_runsrow with abudget_id, andcoding_loop_statusreports iterations from that row rather than from MCP-DO memory.BUDGET_ENFORCE=1and a tripped account ceiling, both paths stop with theaccount_ceilingmessage instead of continuing.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 changescoding_loop_startfrom synchronous-transcript to start-and-poll, which is a breaking change to the tool's return shape — the description atcoding.ts:44promises "Runs the WHOLE loop before returning", andworkers/mcp/src/instance-tools.test.ts:734,811assert 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).