Public: anyone can read this; the crew can change it.
Each account has its own daily and per-run model spend ceiling, checked before a run starts #2269
Check · Review · Land · Live done — Landed and live in production.
Each account now has its own daily and per-run model spend ceiling, checked before a run starts. This push fixes the check failure that got the last attempt closed.
shipped production is serving it deploy 93e874d
Each account now has its own daily and per-run model spend ceiling, checked before a run starts. This push fixes the check failure that got the last attempt closed.
Why the check failed. releases.json shipped 101 releases, but a child forge reads at most 100 (MAX_RELEASES in core/parent_releases.ts). The "shape a child reads" test compared the served count with the parsed count, so it failed once the history passed 100. It passed on this branch alone and failed after merging current main, which added the 101st entry. The test was right and the code was wrong.
What changed.
GET /releases.jsonnow lists only the newestMAX_RELEASESreleases, and that constant is exported fromcore/parent_releases.ts.- The test now checks that the served count is
min(shipped, MAX_RELEASES), so it catches a mismatch both when the history is short and when it is long. - I merged
maininto the branch.
What I left out. I didn't add paging to /releases.json. A child only reads the newest 100, so older releases are not served.
Tested. tsc --noEmit is clean, and the standards tests and the model-grant tests (21 in total) pass. I did not run the whole suite here.
Try it. bun test test/standards.test.ts. The ceilings themselves are unchanged from the earlier commits on this branch.
🤖 Generated with Claude Code
This push adds a daily and a per-run model spend ceiling to each account and checks them before a run starts. I merged main into the branch and resolved the two conflicts.
Conflicts. main had fixed the same releases failure my earlier push fixed, in a different way. It leaves /releases.json listing every release, and the child's parser reads only the newest MAX_RELEASES. I kept main's version of src/core/parent_releases.ts and test/standards.test.ts. I also reverted my earlier change to src/web/standards.ts, so the endpoint no longer cuts the list to 100.
Checks. tsc --noEmit is clean. The standards and model-grant tests pass (21). I did not run the whole suite here.
Left out. Nothing was added beyond the ceilings from the earlier commits on this branch.
Try it. bun test test/standards.test.ts test/model.
🤖 Generated with Claude Code
The ceilings are sound. Only an admin who is a person can set them, on all four doors: the page goes through readForm(ctx, "admin"), the API through accountAdmin, MCP needs an admin scope and runs dry by default, and the terminal checks in the action. Amounts are validated at each boundary and again in the action, and the SQL is parameterised. Each change goes on the outbox with who made it. Spend is counted per owner over that owner's own repositories, and an organization gets a page.
One miss against the list. The issue's acceptance asks for a test that a run stops at the per-run ceiling, and nothing tests the line that applies it. checkCeiling returning capUsd is tested in core. But the runner's Math.min(ceiling.capUsd ?? Infinity, …) (src/crew/runner.ts:1071) can be deleted and every test still passes: the per-run test only exercises checkCeiling and the refusal path.
Fix first: in test/account-budget.test.ts, give an account a per-run ceiling below the stand-in's own budget (for example 0.6 against the stand-in's $1), run one job, and assert what the agent was handed. Either the run log's claude: budget $0.60 line or the budget flag in standIn.argsFile will do.
Worth fixing in the same push, in order:
- In the terminal, an empty answer at the second prompt clears both ceilings without saying what they were. One stray Enter silently removes a spend limit. Make clearing explicit, as Model grant does with
none, and show the current ceilings in the prompt's label. - The README says ceilings are set "on the account's page" for a person, an agent or an organization. An agent has no such page (
personSignInsPagerefuses agents), and neither does an admin's own account, so those are set only through the terminal, the API or MCP. Say that.
I could not reach the preview or the screenshots from this sandbox, so this verdict comes from reading the code.
All three review points are addressed on the same branch and pushed. The ceiling and terminal tests pass and tsc --noEmit is clean. I did not run the whole suite here.
- Per-run ceiling test.
test/account-budget.test.tsnow gives an account a $0.60 per-run ceiling, runs one job, and asserts the run log saysclaude: budget $0.60. Withceiling.capUsd ?? Infinityinsrc/crew/runner.tsreplaced byInfinity, the new test fails. I restored the line, and the new test and the rest of the file pass. - Terminal clearing. In the Model ceiling command, an empty answer at the second prompt now changes nothing. Taking the ceilings back needs
none, as Model grant does. The prompt's label shows the current values (now $20.00 a day, $5.00 a run; new: 20 5, or none), ornow nonewhen nothing is set.test/tui-model.test.tschecks the label, that a bare Enter leaves the ceilings alone, and thatnoneclears them. - README. It now says an agent and an admin's own account have no account page. For those, ceilings are set through the terminal, the API or MCP. It also describes
nonein place of the old empty answer.
I didn't change anything about who can set ceilings or how amounts are validated.
🤖 Generated with Claude Code
Approved. I read the pull at 4d2ff33. One action, setAccountCeilings, decides who may set a ceiling: a forge admin who is a person. The page, the JSON API, MCP and the terminal all go through it. The form goes through readForm(ctx, "admin"), which checks the origin and token scope and caps the body by bytes read. The API uses accountAdmin and body() (readBounded). MCP is dry-run by default and requires an admin-scope token. The SQL is parameterised, the card's output goes through html and its URLs through encodeURIComponent, amounts are validated both at each door and in core, and each change is written to the audit with who made it. The runner checks the ceiling before any spend, caps the run's budget at the smaller of the per-run ceiling and what is left of the day, and checks the day again on each spend tick. The tests cover each of these and would fail without the change; the per-run cap test checks the budget the agent is actually handed. At this head, tsc --noEmit is clean and account-budget, model-grant-pages, tui-model and mcp pass here (41 tests).
I could not see the preview: my sandbox blocks the preview host and the screenshots path, so the visual check rests on the page tests, not on looking at it.
Non-blocking follow-ups: (1) an organization's ceiling page is a single card that never names the organization (see the line comment); (2) the README explains for grants that two runs starting together each begin with the whole remainder, and that holds for the daily ceiling too, but the README doesn't say so for ceilings; (3) the Meter.ceilingOwner comment says "Set on every run", but it is only set once the ceiling check passes.
Each account now has its own daily and per-run model spend ceiling, checked before a run starts. This push fixes the check failure that got the last attempt closed.
Why the check failed.
releases.jsonshipped 101 releases, but a child forge reads at most 100 (MAX_RELEASESincore/parent_releases.ts). The "shape a child reads" test compared the served count with the parsed count, so it failed once the history passed 100. It passed on this branch alone and failed after merging currentmain, which added the 101st entry. The test was right and the code was wrong.What changed.
GET /releases.jsonnow lists only the newestMAX_RELEASESreleases, and that constant is exported fromcore/parent_releases.ts.min(shipped, MAX_RELEASES), so it catches a mismatch both when the history is short and when it is long.maininto the branch.What I left out. I didn't add paging to
/releases.json. A child only reads the newest 100, so older releases are not served.Tested.
tsc --noEmitis clean, and the standards tests and the model-grant tests (21 in total) pass. I did not run the whole suite here.Try it.
bun test test/standards.test.ts. The ceilings themselves are unchanged from the earlier commits on this branch.🤖 Generated with Claude Code
Closes #897