Account for usage quota in the Forgejo scheduler #257

Merged
lucas merged 1 commit from agent/issue-238-quota-gate into main 2026-11-11 10:41:21 +01:00
Collaborator

Closes: #238

The Forgejo dispatcher started turns regardless of how much of the model provider's subscription window was left; an exhausted quota only showed up as a generic failure comment after the turn had already failed.

What this adds

Quota reader (apps/server/src/forgejo/quota.ts) resolves the provider behind the default model role — the one a dispatcher-created session runs on — and scores the SDK's UsageReports:

  • Limits with scope.windowId === "extra" are dropped, so paid extra-usage credits never count. The exclusion is by id, not by an allowlist of 5h/7d, so a future recurring window is gated automatically.
  • An account's load is the max of its windows (a full weekly blocks even with a fresh 5-hour window); the deck's load is the min across accounts, because the SDK rotates to whichever account still has headroom.
  • The verdict is over when that load reaches OMP_DECK_FORGEJO_QUOTA_MAX_PERCENT (new env entry, default 90, hot-applied — read from process.env at every gate, so a Settings → Environment edit needs no restart).

Gate in ForgejoDispatcher.gate(), placed after every drop decision (so a row that would be dropped never posts a notice) and before an intake slot is taken (so a held row does not occupy one). A held row stays pending and the thread gets one comment naming the measured percentage, the ceiling and — when every offending window reports one — the reset time. The comment id is persisted (migration 025) and the comment is deleted on every exit from the hold: the turn starting, the row being dropped, the row failing.

Release. The hold is lifted by the next wake() for any repository and by a shared timer armed for the reported reset, capped at 30 minutes. Re-reading usage is cheap: the SDK caches reports for 5 minutes. A row still over quota simply re-holds, and the notice is posted only once per hold, so a long hold does not grow a comment per re-check.

Head-of-line. A hold can last hours, so actions gate() completes without a turn — closed, reopened, implementation_abandoned, pushed, status — keep draining behind a held row. Without that, a close queued behind a held comment would not archive its session until the quota recovered.

Fail-open where there is nothing to measure (no default model, a provider without a usage endpoint, no stored account); fail-closed where a measurement was expected but did not arrive, so a broken /usage cannot hand the scheduler an unlimited budget.

Not in scope, as planned: startImplementer (the deck-UI start) stays ungated — it is user-initiated — and mid-turn exhaustion keeps today's generic failure comment.

Verification

  • bun test src/forgejo/quota.test.ts — scoring: credits excluded, reset taken from the offending window and omitted when it has none, minimum across accounts, extra-only report unreadable, plus threshold parsing.
  • bun test src/forgejo/dispatcher.test.ts — hold posts exactly one notice and leaves the row pending; the notice is not reposted on re-gating; flipping the quota dep to ok runs the turn, deletes the notice and clears the stored id; an unreadable verdict holds too; a closed row queued behind a held row still archives its session.
  • bun test in apps/server: 1304 pass, 1 fail — see below.
  • bun run typecheck: one error, see below.

End-to-end against a live deck (set the threshold to 1, mention the agent, watch the hold comment appear and disappear) was not run — this session has no deck instance or connected repository.

⚠️ One pre-existing failure, inherited from main

main does not typecheck and has one failing test, independent of this branch: the merge of #249 and #250 left clearProgressLabel calling the removed this.swapLabels.

src/forgejo/dispatcher.ts(2695,27): error TS2339: Property 'swapLabels' does not exist on type 'ForgejoDispatcher'.
(fail) ForgejoDispatcher own-PR CI > a merged implementation loses the progress label of its issue

Filed as #256 with the one-line fix. This PR deliberately does not include it to stay on topic; it should land separately. (This Forgejo repo runs no Actions workflow, so nothing reports it automatically — the gates are only red when run locally.)

Closes: #238 The Forgejo dispatcher started turns regardless of how much of the model provider's subscription window was left; an exhausted quota only showed up as a generic failure comment after the turn had already failed. ## What this adds **Quota reader** (`apps/server/src/forgejo/quota.ts`) resolves the provider behind the `default` model role — the one a dispatcher-created session runs on — and scores the SDK's `UsageReport`s: - Limits with `scope.windowId === "extra"` are dropped, so paid extra-usage credits never count. The exclusion is by id, not by an allowlist of `5h`/`7d`, so a future recurring window is gated automatically. - An account's load is the **max** of its windows (a full weekly blocks even with a fresh 5-hour window); the deck's load is the **min** across accounts, because the SDK rotates to whichever account still has headroom. - The verdict is `over` when that load reaches `OMP_DECK_FORGEJO_QUOTA_MAX_PERCENT` (new env entry, default 90, hot-applied — read from `process.env` at every gate, so a Settings → Environment edit needs no restart). **Gate** in `ForgejoDispatcher.gate()`, placed after every drop decision (so a row that would be dropped never posts a notice) and before an intake slot is taken (so a held row does not occupy one). A held row stays `pending` and the thread gets one comment naming the measured percentage, the ceiling and — when every offending window reports one — the reset time. The comment id is persisted (migration 025) and the comment is deleted on every exit from the hold: the turn starting, the row being dropped, the row failing. **Release.** The hold is lifted by the next `wake()` for any repository and by a shared timer armed for the reported reset, capped at 30 minutes. Re-reading usage is cheap: the SDK caches reports for 5 minutes. A row still over quota simply re-holds, and the notice is posted only once per hold, so a long hold does not grow a comment per re-check. **Head-of-line.** A hold can last hours, so actions `gate()` completes without a turn — `closed`, `reopened`, `implementation_abandoned`, `pushed`, `status` — keep draining behind a held row. Without that, a close queued behind a held comment would not archive its session until the quota recovered. Fail-open where there is nothing to measure (no default model, a provider without a usage endpoint, no stored account); fail-closed where a measurement was expected but did not arrive, so a broken `/usage` cannot hand the scheduler an unlimited budget. Not in scope, as planned: `startImplementer` (the deck-UI start) stays ungated — it is user-initiated — and mid-turn exhaustion keeps today's generic failure comment. ## Verification - `bun test src/forgejo/quota.test.ts` — scoring: credits excluded, reset taken from the offending window and omitted when it has none, minimum across accounts, `extra`-only report unreadable, plus threshold parsing. - `bun test src/forgejo/dispatcher.test.ts` — hold posts exactly one notice and leaves the row pending; the notice is not reposted on re-gating; flipping the quota dep to `ok` runs the turn, deletes the notice and clears the stored id; an unreadable verdict holds too; a `closed` row queued behind a held row still archives its session. - `bun test` in `apps/server`: 1304 pass, 1 fail — see below. - `bun run typecheck`: one error, see below. End-to-end against a live deck (set the threshold to 1, mention the agent, watch the hold comment appear and disappear) was not run — this session has no deck instance or connected repository. ## ⚠️ One pre-existing failure, inherited from `main` `main` does not typecheck and has one failing test, independent of this branch: the merge of #249 and #250 left `clearProgressLabel` calling the removed `this.swapLabels`. ``` src/forgejo/dispatcher.ts(2695,27): error TS2339: Property 'swapLabels' does not exist on type 'ForgejoDispatcher'. (fail) ForgejoDispatcher own-PR CI > a merged implementation loses the progress label of its issue ``` Filed as #256 with the one-line fix. This PR deliberately does not include it to stay on topic; it should land separately. (This Forgejo repo runs no Actions workflow, so nothing reports it automatically — the gates are only red when run locally.)
The scheduler used to start a turn regardless of how much of the
subscription window was left, so an exhausted quota surfaced as a
generic failure comment on the thread.

Before a turn starts, the dispatcher now reads the usage of the
provider behind the `default` model role and keeps the trigger pending
while the recurring 5-hour/weekly windows are at or above
OMP_DECK_FORGEJO_QUOTA_MAX_PERCENT (default 90). Paid extra-usage
credits never count toward that ceiling, and with several accounts
stored the one with the most headroom decides. The thread gets one
comment naming the measured percentage and the window's reset, deleted
again when the trigger is released.

A hold ends on its own: usage is re-read at the next delivery and at
most 30 minutes after the hold started. Events that need no turn
(close, reopen, base push, CI status) keep draining behind a held row,
so a hold cannot stall session archival for hours. A missing default
model or a provider without a usage endpoint runs ungated; a usage
read that fails or says nothing usable holds the trigger.

Co-Authored-By: Claude Opus 5 via omp-deck
lucas merged commit c179a9539f into main 2026-11-11 10:41:21 +01:00
lucas deleted branch agent/issue-238-quota-gate 2026-11-11 10:41:22 +01:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
lucas/omp-deck!257
No description provided.