Skip to content

[bug] JOB_APPLY is the last autonomous workflow with no budget pool — reserve() has two callers and neither is the apply path #516

Closed

Description

@serge-ivo

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

  1. startJobApply opens a pool and threads budgetId into JobApplyWorkflow, the way coding-session-params.ts does for CODING_SESSION.
  2. 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.
  3. 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 in 50e56ed).
  4. workers/api/src/lib/autonomous-budget.test.ts — added by f79fb29 and non-vacuous — is extended so job-apply.ts is in the guarded set. That file already encodes the invariant; this should become one more entry in it, not a parallel test.
  5. usage_summary attributes 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

  1. serge-ivo commented on Aug 12, 2026

    @serge-ivo
    ContributorAuthor

    Re-verified 2026-08-13, plus one scope narrowing and one thing the ticket should NOT inherit

    The finding still holds, exactly as written

    $ grep -rn "reserve(" --include="*.ts" workers/api/src | grep -v '\.test\.'
    workers/api/src/workflows/agent-loop.ts:88          reserve(this.env, userId, budgetId, { depth, estimatedCostMicros: reserveMicros }),
    workers/api/src/workflows/coding-session.ts:459     const draw = await reserve(env, userId, budgetId, {
    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 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:

    decide: (p) => step.do(`s${n++}-decide`, retry, () => decideAction(env, userId, p, { kind: "apply", instanceId })) as Promise<ApplyDecision>,

    That reaches user-ai.ts, which is the single BYOK choke point and calls recordUsage at :272 / :462. So every apply decision writes an ai_usage row with kind: "apply" and a populated instance_id.

    The reason there is no apply bucket in usage_summary (checked at 7d, 30d and all on production today — byKind returns engine, chat, voice, overseer, coding, copilot, pipeline, translate, summary, embedding, and no apply, run or resume) is simply that no apply run has happened in the ledger's lifetime, not that the path is unmetered. The account has a Job Application Assistant instance (51a3404e…, surfaces:["apply"]) with zero AI rows.

    That means AC 5 as written ("usage_summary attributes 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's budgetId/trace — then say so, because that is a genuinely different and larger thing: ai_usage has no run column, and nothing in the ledger groups by run today. The coding rows do not have it either.

    Do not inherit the instanceSpendMicros assumption without checking it

    AC 2 says each iteration "draws against" the pool, which in coding-session.ts means the instanceSpendMicros before/after delta. That function filters to CHARGED_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 hardcodes payer='byok-api' at usage.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 449 kind:engine rows are payer: unknown (byPayer.unknown = $9,584.87 of $9,621.22 notional; there is no subscription bucket 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 by f79fb29 (lib/autonomous-budget.test.ts) is the right home. Make the assertion the general form — "every file under workflows/ that calls a model has a reserve() in its admission path" — rather than adding job-apply.ts to a list. A list is how JOB_APPLY got missed by #502 in the first place.

    Enforcement posture, confirmed live

    GET /v1/budget/limits on 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.

  2. serge-ivo commented on Aug 13, 2026

    @serge-ivo
    ContributorAuthor

    Shipped in cc17c1d. CI green, API Worker deploy green, /health 200.

    What landed

    File Change
    lib/apply-decide-budget.ts (new) The gate — reserve → decide → settle in a finally. A refusal becomes a terminal finish, not a throw, so the loop stops with a reason the human reads on the board. markExhausted on a real exhaustion but not on not_found / closed / account_ceiling. Same shape as lib/coding-decide-budget.ts.
    workflows/job-apply.ts budgetId + depth on JobApplyParams; decide routed through the gate.
    routes/instances-apply.ts openBudget(env, userId, instanceId) before JOB_APPLY.create.
    lib/autonomous-budget.test.ts The JOB_APPLY exemption 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_usage has 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_APPLY got missed by #502 in the first place — with the walk size and the caller list both asserted (ADR 0002 G1), the same rewrite c64d9f5 did for delegation-budget.test.ts.

    Two things the work found, both worth their own ticket

    1. stop_work does 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 through agent_loop_runs; startJobApply writes no such row, and job-apply.ts never reads isCancelRequested (grep -c → 0). Apply has its own cancel channel — POST /:instanceId/tasks/:taskId/cancel → runner sets task.status = "cancelled" → the next /browser/snapshot returns cancelled: true → the loop returns outcome cancelled — 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_TASK has the same gap JOB_APPLY had. workflows/browser-task.ts runs the same runApplyLoop engine — 12 rounds × 60 steps of BYOK Claude — started from routes/instances-browse.ts with 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() increments delegations_used and settle never 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 run blocked with a readable reason. Deliberate and documented, and raisable via account_budget_limits.delegations (#474) without a deploy. Enforcement posture is unchanged — BUDGET_ENFORCE is 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 run 8,447 tests green, biome 1,174 files, seven check-*.mjs, docs:drift, docs:build, console tsc --noEmit + vite build. No migration. pnpm test:e2e skipped — the diff touches no store/, agents/coder/web or 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_ceiling closing the pool → 1; refusal wording → 2; create dropping budgetId → 2; wrap + import removed → 2; browser-task removed from the exemptions → 2; stale markExhausted list → 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_budgets row.

    Closing: the acceptance criteria as written are met and verified. The two findings above are recorded here rather than silently absorbed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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