Monthly budget can be exceeded when several turns run in parallel
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 71
- Forks
- 64
- Avg merge
- 15h 38m
- Merged PRs (30d)
- 66
Description
In short: the budget is checked once per turn, before the provider call, against the sum of turns that already finished. Nothing holds a place for turns still running, so several turns that start close together all pass the check with the same balance, and the project ends the month over its limit. Lines are from main at 5e6b6fc.
What I see in the code
assertTurnAllowed()blocks only ifspentCents >= monthlyLimitCents: https://github.com/theam/facility/blob/5e6b6fc356af3d5da976bd5570ce2950612694db/services/api/src/insights/costs.ts#L29-L45spentCentsissum(turn_usage.cost_cents)for the month. Running turns have no row yet: https://github.com/theam/facility/blob/5e6b6fc356af3d5da976bd5570ce2950612694db/services/api/src/insights/costs.ts#L102-L125- The dispatcher runs the check and goes on to the engine, with no lock, reservation or per-project concurrency limit: https://github.com/theam/facility/blob/5e6b6fc356af3d5da976bd5570ce2950612694db/services/api/src/turns/dispatcher.ts#L145
- Usage is written only after the turn ends: https://github.com/theam/facility/blob/5e6b6fc356af3d5da976bd5570ce2950612694db/services/api/src/turns/dispatcher.ts#L337-L348 and https://github.com/theam/facility/blob/5e6b6fc356af3d5da976bd5570ce2950612694db/services/api/src/turns/dispatcher.ts#L455-L468
WorkerTurnGuardis one turn per worker process, not per project: https://github.com/theam/facility/blob/5e6b6fc356af3d5da976bd5570ce2950612694db/services/api/src/worker-task-protection.ts#L131-L146
README.md:92-94 says a running call may finish and is accounted afterwards. That covers one turn, not N parallel turns each spending the same last dollars.
Scenario
A project has 5 USD left. Three stories get a message at the same time and three workers pick them up. Each sees 5 USD left and starts. Each turn costs 4 USD. The month ends 7 USD over the limit. More workers and longer turns make the gap bigger.
Relation to #399
Same root: the number the check uses is not the real spend. #399 is spend never booked; this is spend booked too late. A reservation taken before the call also covers the dead-worker case, because the cost is held before the engine starts.
Proposal
Like a card payment: authorize first, capture later.
- Before the call, insert a reservation with an estimated cost. A simple per-model estimate could be a starting point.
- Block if
limit - spent - active reservations < estimate. Check and insert in one transaction, or under a per-project advisory lock ascatalog.ts:326does.
This makes the limit stricter near the end of the month, which is the goal. - When the turn ends, replace the reservation with the real cost.
- A reservation never settled (worker died) is captured at the reserved amount, not released.
A good estimate is hard for long agent turns. If that is a blocker, a simpler first step could be to allow only one running turn per project once the remaining budget is below a threshold.
Questions
- Is the soft limit intended? If yes, it may be worth mentioning in the docs that parallel turns can go over it.
- If not, would you accept a PR in this direction? I can do it, with the tests AGENTS.md asks for.
Thanks for putting Facility out early. It is a good base to build on.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with services/api/src/insights/costs.ts and the dispatcher paths at services/api/src/turns/dispatcher.ts, then read the WorkerTurnGuard and the transaction or locking pattern in agents/catalog.ts. Check AGENTS.md for required tests. Done means concurrent turns cannot all spend the same remaining budget, reservations are settled after completion, and abandoned reservations follow the stated capture behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- api, backend, database, distributed-systems
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100