fix(flows): allocate rate-limit budget per flow run, not per process #405
No reviewers
Labels
No labels
area:agents
area:dashboard
area:database
area:design
area:design-review
area:flows
area:infra
area:meta
area:security
area:sessions
area:webhook
area:workdir
security
type:bug
type:chore
type:meta
type:user-story
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
charles/claude-hooks!405
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "fix/flow-rate-limit-per-run"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
flow-dispatch.tshoisteddefaultArgInjections()out of the per-run loop on the (correct) reasoning that every key it returned was either a stable function or the singleton dedup surface. The rate-limit budget broke that invariant — the limiter is stateful and counts mutating calls. With the bundle reused across N triggers × M flows, every flow run shared one limiter; once cumulative consumption hit theDEFAULT_RATE_LIMIT_BUDGET = 20ceiling, every subsequent flow run threwRateLimitExceededuntil the next service restart.Production symptom
PR #402's bounced review-request flow at
2026-04-26 20:43:25 UTCfired with the limiter already at 20/20, so thedispatchnode errored:The reviewer never picked up the re-review, and the operator dashboard showed
IN REVIEWcards parked indefinitely on the dev column with the reviewer column empty.The NF-3 docstring in
rate-limit.ts:97-101already flagged this exact leak as "safe for the isolated unit tests shipped here" but warns it "DOES leak across runs". This PR is the production wiring catching up.Fix
Keep the static injection bundle hoisted (still correct for the stateless functions) but allocate
createRateLimiter()inside the loop and spread it ontoargDefaultsper(trigger, flow)iteration. The allocation is one object per flow run — negligible against the forge-API + executor work that follows.Tests
each flow run sees a distinct RateLimiter instance— pins the per-run-allocation contract via asource-handler wrap that records the limiter object identity across a 3-assignee fan-out. Verifies the three captured limiters are pairwise distinct.a budget-exhausting first run does not block later runs— burns the full 20-call budget in run 1 and verifies runs 2 and 3 still finishstatus=ok. Pre-fix, both would have died on the firstconsume()of run 2.afterEachsnapshot/restore of the source handler becausedefaultRegistry()returns freshMapRegistryinstances but reuses the sameBUILT_INSdescriptor object references, so the wrap mutation otherwise persists across tests.Test plan
bun test apps/server/src/domain/flows/flow-dispatch.test.ts→ 28 pass / 0 failbun test apps/server/src/domain/flows/→ 376 pass / 0 failbunx biome check apps/server/src/domain/flows/flow-dispatch{.ts,.test.ts}→ cleansystemctl --user restart claude-hooksto reset the leaked singleton, thenPOST /pipeline/bounce-reviewfor #401 + #402 to clear the parked review state.🤖 Generated with Claude Code
Correct fix. The
{ ...staticArgDefaults, rateLimit: createRateLimiter() }spread at each(trigger, flow)iteration is exactly right — stateless bundle stays hoisted, stateful limiter is fresh per run. The tests pin both invariants cleanly (object identity check + budget-burn regression). CI green.