Repository navigation
fix(agent): bound concurrent model calls and contain a failing sub agent - #58
Conversation
|
Thanks for this PR — a process-wide LLM concurrency limiter wired into the sender factory, plus switching the review sub-agents to a. Summary This PR bounds all in-flight model calls through a shared b. Good points
Main Issues None. Additional Notes
|
|
Thanks for handling this. I added Limiter a while ago and never used it up actually and you uses it in the right place. and I have a suggestion.
Would you be okay if I make some more updates on this branch? |
|
Before and after numbers from running this branch in production for a day, plus the The limiter worksRate-limit failures on my instance, by day, counted from the Zero. Not reduced, eliminated. Every day for the previous two weeks had between 28 and What the unbounded version costOver the life of this instance, 1,141 activities:
The 429 failures alone account for 71% of all input tokens this instance has ever spent. Why it compounds rather than merely wastesA rate-limited review does not reach So this branch is not only a fix for 429 noise. It breaks the input side of that loop. One note for reviewers
EnvironmentProval 0.2.12 with this branch applied, published image otherwise, GitLab CE 18.9 |
Oh that proves everything 400M token is crazy, I should make a quick update. Thank you for the additional data |
- Add new `concurrenty` column on `model_provider` table - add `loadLimiter()` function for create new `Limiter` instance - new Concurrency formfield on dashboard(/model-provider/:id)
|
Thanks @Decipher — this push cleanly replaces the process-wide a. Summary This follow-up swaps the env-var limiter for a DB-backed one: migration 0046 adds a nullable b. Good points
Main Issues None. Additional Notes
|
| @@ -1,3 +1,10 @@ | |||
| import { debug } from "../../util/log.js"; | |||
There was a problem hiding this comment.
⚫️ [PROBLEM] debug import drags DB side effects and a module cycle into the limiter unit test
This push adds import { debug } from "../../util/log.js" at apps/api/src/agent/llm/concurrency.ts:1, and that import pulls a heavy graph into what was a dependency-free module: log.ts:12 imports ActivityService, log.ts:27 instantiates it at top level, activity.service.ts:15 imports db/index.js whose line 5 does new Database(process.env.DB_FILE_NAME ?? "local.db") at module load, and activity.service.ts:17 imports factory.ts, which imports this file (factory.ts:5).
Two consequences: concurrency.test.ts no longer runs in isolation — merely importing the SUT transitively opens/creates a SQLite file — and a module cycle factory → concurrency → log → activity.service → factory now exists with top-level side effects inside it, making evaluation order fragile.
Consider keeping the limiter dependency-free: accept an optional logger callback (factory wires in debug), or copy the tiny NODE_ENV-guarded console.debug helper locally instead of importing util/log.js.
|
Thanks @Decipher — this follow-up adds targeted a. Summary The push threads a b. Good points
Main Issues
Additional Notes
|

Fixes #55.
When a sub agent throws during review,
Promise.allinrunPullRequestReviewrejects on the first failure while the siblings keep running. The rejection propagates out of thetry, so thefinallycallsworkspace.clean()and deletes the checkout those siblings are still reading. Each one then fails every tool call withWorkspace is not loaded., and because the agent loop returns tool errors to the model as tool result content rather than aborting, each orphan keeps stepping towardmaxSteps. Those steps are billed throughonUsagelike any other.Two changes
Bound concurrent model calls.
Limiteralready existed inapps/api/src/agent/llm/concurrency.tsand was referenced nowhere. This wraps the sender returned bycreateSender, so one module level limiter bounds the plan agent, the review sub agents, the writing agent, the reply agents and the issue agents together. The value comes fromPROVAL_LLM_CONCURRENCYand defaults to 4, because the ceiling is a property of the user's provider plan rather than of Proval.The sender is deliberately the place to do this rather than the sub agent fan out. On one burst I saw ten activities running together, most of them PR Reply rather than PR Review, and a reply agent never reaches the review fan out. Bounding the fan out alone leaves those unbounded and the provider still returns 429.
Contain the failure. The sub agent fan out becomes
Promise.allSettled, and the first rejection is rethrown only after every sub agent has settled. The review fails exactly as before and the error reported to the user does not change, but no sub agent is left running against a cleaned workspace.Limiterhad no test coverage, so this adds some: the concurrency bound, slot release when a task throws, and the unboundednullmaximum.Scope
The review still fails when a sub agent fails. This is containment, not recovery.
A circuit breaker in the agent loop, giving up after a number of consecutive identical tool errors, would be the complementary change. That is a behaviour change rather than a fix, so I left it out.
Verification
Against
0.2.10:bun run typecheckreports 0 errors across api, web and client.bun testreports 68 pass, 0 fail, 65 of them pre-existing and 3 new.bunx prettier --checkis clean on all four files.bunx eslintreports one warning inreview/index.ts, a non-null assertion that predates this branch and sits outside the diff.What I have not done is measure the saving on a live review that fails partway. The mechanism is read from the source and the containment is covered by the behaviour of
allSettled, so I would describe the token figures as reasoned rather than observed.Configuration
PROVAL_LLM_CONCURRENCYExisting deployments do not need any change, because the default of 4 is at or above the review unit count of most merge requests.