Repository navigation
[bug] JOB_APPLY is the last autonomous workflow with no budget pool — reserve() has two callers and neither is the apply path #516
Description
JOB_APPLY is the last autonomous workflow with no budget pool
#502 closed the three coding entry points (f79fb29) — coding_loop_start, POST …/sessions/:sid/run, and the Overseer's delegation in coding-brains.ts, which the ticket had not named. JOB_APPLY was found in the same pass and deliberately left out of scope, because it needs wiring rather than an argument.
#184 promised an admission check at every autonomous entry point. This is the one that is left.
Verified
$ grep -rn "reserve(" workers/api/src | grep -v '\.test\.'
workers/api/src/workflows/coding-session.ts:466 const draw = await reserve(env, userId, budgetId, {
workers/api/src/workflows/agent-loop.ts:88 reserve(this.env, userId, budgetId, { depth, estimatedCostMicros: reserveMicros }),
workers/api/src/lib/delegation-budget-store.ts:283 export async function reserve(
workers/api/src/lib/connectors/supervision.ts:626 // …comment only
Two call sites. Neither is the apply path.
$ grep -c "budget" workers/api/src/workflows/job-apply.ts
0
The workflow does not mention a budget at all — there is no pool to draw from, nothing to settle, and no budgetId to cancel against.
Why it matters as much as the coding ones did
JobApplyWorkflow is a durable Cloudflare Workflow driving a real browser with the owner's BYOK Claude: snapshot → Claude picks one action → act → repeat. It is long-running by design (that is why it is a Workflow rather than a DO request), it is resumable across handoffs, and each iteration is a model call. It is exactly the shape the pool exists for.
The concrete consequence is the one #291's lineage already demonstrated for the Pilot: a run with no pool has no row for requestCancel to act on. An apply run that wedges — on a captcha handoff, a needs_input nobody answers, or an ATS that never settles — spends tokens the owner can neither see attributed nor stop.
Note this is independent of enforcement posture. BUDGET_ENFORCE is currently observe-only by design (d951b5f), so a pool here is about accounting and cancellability, not refusal. Do not treat "enforcement is off" as a reason to skip it.
Acceptance criteria
startJobApplyopens a pool and threadsbudgetIdintoJobApplyWorkflow, the waycoding-session-params.tsdoes forCODING_SESSION.- Each model iteration in the apply loop draws against it, and the run settles on every exit path — including the handoff paths (
challenge,stuck,needs_input), which is where an apply run actually stops. - A wedged or abandoned apply run does not leak its reservation permanently. Note
agent-loop's settle was swallowed once and caused exactly that leak (fixed in50e56ed). workers/api/src/lib/autonomous-budget.test.ts— added byf79fb29and non-vacuous — is extended sojob-apply.tsis in the guarded set. That file already encodes the invariant; this should become one more entry in it, not a parallel test.usage_summaryattributes apply spend to the run, so it is legible next to the coding rows.
Not verified
I have not run an apply end to end — that needs a live runner and a real job URL. The absence of any budget wiring is verified from the source above; the consequence (a wedged run that cannot be cancelled) is inferred from the identical mechanism in the Pilot, which was observed.
Related, kept separate
f79fb29 also found that coding_loop_start / check / stop duplicate start_instance_loop / check_instance_loop / stop_instance_loop — six MCP tools doing the same thing under two names. It rewired rather than deleted, because AC 2 wanted the name to keep working. Collapsing them is a deliberate call and belongs in its own ticket, not here.
Activity
serge-ivo commentedon Aug 12, 2026
Re-verified 2026-08-13, plus one scope narrowing and one thing the ticket should NOT inherit
The finding still holds, exactly as written
Two call sites, neither the apply path. (Line numbers have drifted by a few from the body —
coding-session.ts:466→:459— the finding has not.)Narrowing: apply IS metered. Only the budget is missing.
Worth stating explicitly so nobody widens this ticket into a metering job.
workflows/job-apply.ts:155:That reaches
user-ai.ts, which is the single BYOK choke point and callsrecordUsageat:272/:462. So every apply decision writes anai_usagerow withkind: "apply"and a populatedinstance_id.The reason there is no
applybucket inusage_summary(checked at7d,30dandallon production today —byKindreturns engine, chat, voice, overseer, coding, copilot, pipeline, translate, summary, embedding, and noapply,runorresume) is simply that no apply run has happened in the ledger's lifetime, not that the path is unmetered. The account has aJob Application Assistantinstance (51a3404e…,surfaces:["apply"]) with zero AI rows.That means AC 5 as written ("
usage_summaryattributes apply spend to the run, so it is legible next to the coding rows") is already satisfied at the agent/instance level and should be either struck or restated. If what was meant is per-run attribution — spend grouped by the apply run'sbudgetId/trace — then say so, because that is a genuinely different and larger thing:ai_usagehas no run column, and nothing in the ledger groups by run today. The coding rows do not have it either.Do not inherit the
instanceSpendMicrosassumption without checking itAC 2 says each iteration "draws against" the pool, which in
coding-session.tsmeans theinstanceSpendMicrosbefore/after delta. That function filters toCHARGED_SQL(usage.ts:558), deliberately and correctly — a subscription engine's notional value must not draw a dollar pool.For apply this happens to be fine (every apply row comes from
recordUsage, which hardcodespayer='byok-api'atusage.ts:97, so the delta will be non-zero). But it is worth writing down in the ticket, because on this account the same mechanism makes the coding pool near-inert: measured today, all 449kind:enginerows arepayer: unknown(byPayer.unknown= $9,584.87 of $9,621.22 notional; there is nosubscriptionbucket at all), so the coding pool's settle delta sees essentially none of the real consumption. Apply will not have that problem. Somebody reading the two side by side later should not conclude they behave the same way.One AC I would add
reserve()is the only admission gate, and the guard test added byf79fb29(lib/autonomous-budget.test.ts) is the right home. Make the assertion the general form — "every file underworkflows/that calls a model has areserve()in its admission path" — rather than addingjob-apply.tsto a list. A list is howJOB_APPLYgot missed by #502 in the first place.Enforcement posture, confirmed live
GET /v1/budget/limitson production, 2026-08-13:"enforced": false, all ceilings"tier": "default". So the body's note is current: this is about accounting and cancellability, not refusal. Do not let "enforcement is off" close it.feat(apply): an application opens a spend pool and draws on it every …
serge-ivo commentedon Aug 13, 2026
Shipped in
cc17c1d. CI green, API Worker deploy green,/health200.What landed
lib/apply-decide-budget.ts(new)reserve→ decide →settlein afinally. A refusal becomes a terminalfinish, not a throw, so the loop stops with a reason the human reads on the board.markExhaustedon a real exhaustion but not onnot_found/closed/account_ceiling. Same shape aslib/coding-decide-budget.ts.workflows/job-apply.tsbudgetId+depthonJobApplyParams;deciderouted through the gate.routes/instances-apply.tsopenBudget(env, userId, instanceId)beforeJOB_APPLY.create.lib/autonomous-budget.test.tsJOB_APPLYexemption deleted, and the assertion rewritten into the general form the comment asked for.AC 1–4 delivered. AC 5 was already satisfied and is not implemented: apply has always been metered (
decideAction→user-ai.ts→recordUsage,kind:"apply"); there are no apply rows because no apply run has ever happened. Per-run attribution is a different and larger thing —ai_usagehas no run column and the coding rows do not have it either.The guard follows the predicate rather than a list, per the comment — a list is how
JOB_APPLYgot missed by #502 in the first place — with the walk size and the caller list both asserted (ADR 0002 G1), the same rewritec64d9f5did fordelegation-budget.test.ts.Two things the work found, both worth their own ticket
1.
stop_workdoes not reach an apply run. Checked, since #540 shipped it yesterday and "cannot stop it" is solved elsewhere.stop_work(tool-registry.ts:241→stopOneRun→requestCancel) resolves runs throughagent_loop_runs;startJobApplywrites no such row, andjob-apply.tsnever readsisCancelRequested(grep -c→ 0). Apply has its own cancel channel —POST /:instanceId/tasks/:taskId/cancel→ runner setstask.status = "cancelled"→ the next/browser/snapshotreturnscancelled: true→ the loop returns outcomecancelled— but it works only while the loop is snapshotting. During a handoff the workflow polls/browser/handoff-status, which has no cancellation awareness, so a cancel in that window is unseen for up to 15 minutes. That is exactly the wedged-run window this issue is about. The pool now bounds the spend even when nobody can press stop, which is the half this ticket promised; making stop actually reach it is a separate change.2.
BROWSER_TASKhas the same gapJOB_APPLYhad.workflows/browser-task.tsruns the samerunApplyLoopengine — 12 rounds × 60 steps of BYOK Claude — started fromroutes/instances-browse.tswith no pool. Found by the new general-form guard, which is the point of writing it as a predicate. Recorded as an exemption with that reason rather than fixed, because it is a second binding and a second params type.Both should be filed rather than folded in here.
One behaviour change worth knowing
Each
reserve()incrementsdelegations_usedandsettlenever returns it. The default ceiling is 50 draws; the apply loop is bounded at 12 × 60. So on a long application the pool becomes the effective bound at ~50 model decisions, ending the runblockedwith a readable reason. Deliberate and documented, and raisable viaaccount_budget_limits.delegations(#474) without a deploy. Enforcement posture is unchanged —BUDGET_ENFORCEis still off, so this is accounting and boundedness, not refusal.Verification
All ten CI gates green in the worktree, and run again after rebasing onto
7e23b60:pnpm typecheck(11 projects),pnpm vitest run8,447 tests green, biome 1,174 files, sevencheck-*.mjs,docs:drift,docs:build, consoletsc --noEmit+vite build. No migration.pnpm test:e2eskipped — the diff touches nostore/,agents/coder/webor anything Playwright exercises.ADR 0002 G4: 9 mutations, each proven red then restored — no-pool short-circuit deleted → 2; settles the reservation instead of the delta → 2; settle moved out of
finally→ 3;account_ceilingclosing the pool → 1; refusal wording → 2;createdroppingbudgetId→ 2; wrap + import removed → 2;browser-taskremoved from the exemptions → 2; stalemarkExhaustedlist → 1.Not verified live: no apply run was started (that needs a live runner and a real job URL, and I do not drive the owner's agents). The pool opening is therefore not observable in production yet — the first real apply will be the first
delegation_budgetsrow.Closing: the acceptance criteria as written are met and verified. The two findings above are recorded here rather than silently absorbed.
fix(runs): an apply and a browser task can be stopped, because the ru…