Account for usage quota in Forgejo scheduler #238
Labels
No labels
Agent
Ignore
Agent
In Progress
Agent
Planned
Agent
Planning
Agent
Ready
Agent
Triage
Compat
Breaking
Kind
Bug
Kind
Documentation
Kind
Enhancement
Kind
Feature
Kind
Security
Kind
Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
lucas/omp-deck#238
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
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?
Problem
The Forgejo scheduler needs a way to monitor quotas on model providers and halt or delay triggers when quota would be exceeded. Currently, it blindly starts the turn, which then fails and leaves a non-descriptive error comment.
Ideally, it would also create a temporary comment informing the user of that state. This comment would then be removed, once the scheduler does start the turn.
Credits must not count toward the quota. The regular 5-hour/weekly quota must be below a threshold (e.g. 90%) for the scheduler to run turns.
What exists today
ForgejoDispatcher(apps/server/src/forgejo/dispatcher.ts) is the only scheduler:pump()(:474) picks artifacts,drain(key)(:495) walks that artifact's pending rows oldest-first,gate(row)(:533) returnsReady | "deferred" | null, andrunReady/runIntakestart the turn. Nothing in the deck reads provider usage, classifies a 429, deletes a Forgejo comment, or holds a trigger for anything but the 15s checkbox settle window and the implementer intake-slot limit.The data does exist in the SDK, unused:
AuthStorage.usage.reports({provider, signal})returns oneUsageReportper stored account, each withlimits: UsageLimit[]. For Anthropic those areanthropic:5h,anthropic:7d,anthropic:7d:opus,anthropic:7d:sonnet(all with a recurringscope.windowId) plusanthropic:extra("Claude Extra Usage",scope.windowId: "extra") — the paid credit bucket that must not count. Reports are cached for 5 minutes (USAGE_REPORT_TTL_MS), so gating per trigger adds no upstream traffic.Plan
1. Quota reader (
apps/server/src/forgejo/quota.ts, new)No equivalent exists; this is the only new file.
Exports:
quotaThresholdPercent()parsesprocess.env[QUOTA_THRESHOLD_VAR]withNumber.parseInt(…, 10), accepts1..100, otherwise returnsDEFAULT_QUOTA_THRESHOLD_PERCENT. Read per call:applyManagedEnvUpdatesToProcess(apps/server/src/env-store.ts:74-87) writes Settings → Environment edits straight intoprocess.env, so no restart and no caching.readForgejoQuota()steps, in order:model(bridge.createSession({cwd, trigger, placement})), so they run on the configureddefaultmodel role:const registry = await getDeckModelRegistry()(apps/server/src/auth-singleton.ts:26).const settings = await buildDeckSessionSettings(getAgentDir())— exported atapps/server/src/model-config.ts:568; it is the deck's isolatedSettings, not the SDK process singleton.const { model } = resolveRoleModelFull(settings, "default", registry.getAvailable("all"), undefined)from@oh-my-pi/pi-coding-agent/session/role-models.{kind: "ok"}(nothing to measure, never block).const auth = registry.authStorage. Ifauth.usage.providerFor(String(model.provider))isundefinedthe provider has no usage concept at all →{kind: "ok"}.const reports = await auth.usage.reports({ provider: String(model.provider) })insidetry/catch. A throw, orreports === null, →{kind: "unreadable", measuredAt: Date.now()}.reports.length === 0(no stored account reports for that provider) →{kind: "ok"}.report.limits.filter((l) => l.scope.windowId !== undefined && l.scope.windowId !== "extra"), map throughresolveUsedFraction(limit)(exported from@oh-my-pi/pi-ai), dropundefined. An account with no such limit is unmeasurable and is skipped.extrais excluded by id, not by an allowlist of5h/7d, so a future window id (anthropic:7d:haiku) is gated automatically while the credit bucket stays out.{kind: "unreadable", measuredAt: Date.now()}(a report arrived but says nothing usable).usedPercent = Math.round(best * 100).usedPercent < thresholdPercent→{kind: "ok"}.{kind: "over", usedPercent, thresholdPercent, measuredAt: Date.now(), resetsAt}whereresetsAtis the latestlimit.window?.resetsAtamong the best account's limits whose own percent is at or above the threshold — all of them must roll over before that account drops under. Omitted when any offending window reports noresetsAt.2. Threshold env entry (
apps/server/src/env-schema.ts)Append one
ENV_SCHEMAentry next to the other dispatcher-facing vars, following theOMP_DECK_MAINTENANCE_GATE_*shape:No
applyHotUpdatesbranch: the value is read fromprocess.envat each gate, exactly likeOMP_MODELandPI_NO_TITLE.3. Persist the notice comment id
New migration
apps/server/src/db/migrations/025-forgejo-trigger-quota-notice.sql(024 is the highest today), in the style of the existing files:In
apps/server/src/db/forgejo-triggers.ts:Rowgainsquota_notice_comment_id: number | null.StoredForgejoTriggergainsquotaNoticeCommentId: number | null;toStoredmaps it. Deliberately not onForgejoTriggerSummary(@omp-deck/protocol) — it is server-internal and must not widen the wire type.setForgejoTriggerSession(:192):4. Comment post/delete on the Forgejo API (
apps/server/src/forgejo/dispatch-api.ts)postCommentto return the created comment's id:Promise<ApiResult<{ id: number }>>. Forgejo'sPOST …/issues/{n}/commentsreturns the comment object; the implementation at:351alreadyclient.jsons it, so only the type parameter and the return mapping change. Update the existing call sites indispatcher.ts(postNotice:2569,surfaceFailure:2596) and anywhere elsegrep -n "postComment(" apps/server/srcreports — they ignore the payload and keep compiling, but the interface change must be applied to the fake inapps/server/src/forgejo/dispatcher.test.ts.implemented as
client.json<void>("DELETE", repoApiPath(owner, repo, "issues", "comments", String(commentId)), { signal }), mappingstatus === 404to{ok: true, data: undefined}.ForgejoClient.jsonalready treats an empty 204 body as success (client.ts:214).5. The gate (
apps/server/src/forgejo/dispatcher.ts)Dependency and state. Add to
ForgejoDispatcherDeps(:220):Add fields beside
deferred/settleTimers(:412-414):and a module constant next to
CHECKBOX_SETTLE_MS(:204):Check placement. Insert the quota check in
gate()immediately before the intake-capacity block at:784(if (implementation !== null) {), i.e. after routing, addressed-to-agent detection, write-access, workspace matching and PR-head resolution, and before any intake slot is taken. Rationale, both load-bearing: a row that would be dropped anyway must never post a quota notice, and a quota-held row must not sit on an intake slot.holdForQuota(job, verdict)(new private method):job.row.number === 0(watched-branch CI, no thread) post nothing.job.row.quotaNoticeCommentId === null, post the body forverdict.kindviajob.api.postCommentand, on success,setForgejoTriggerQuotaNotice(job.row.id, posted.data.id). A failed post is alog.warnonly — the hold still applies. The notice is posted once and never rewritten while the row stays held, so its body names the measurement time rather than implying live data.this.quotaDeferred.set(row.id, row); this.armQuotaTimer(verdict).Bodies (exact strings, module constants next to the other notice texts):
Timestamps are ISO-8601 UTC (
new Date(ms).toISOString()). Neither body names an account, an email, a model, or a raw provider error: the repository may be public.armQuotaTimer(verdict)(new private method): computedelay = Math.max(1_000, Math.min(verdict.kind === "over" && verdict.resetsAt ? verdict.resetsAt - Date.now() : Number.POSITIVE_INFINITY, QUOTA_RECHECK_MAX_MS)). Ifthis.quotaTimeris already armed, leave it (one shared timer serves every held row; quota is global). On fire: clearquotaTimer, and for every row inquotaDeferredremove it fromthis.deferredandthis.gated, clearquotaDeferred, thenthis.pump(). The hold is indefinite — a row that is still over quota simply re-defers and re-arms.dispose()(:450) mustclearTimeout(this.quotaTimer)alongside the existing sweep and settle timers.Releasing the notice. In
gateSafely(:513), aftergate(row)settles — including thecatchpath that callsfailInternal— delete the notice whenever the result is not"deferred":reads
row.quotaNoticeCommentId, returns when null, callsapi.deleteComment(owner, repo, id)(alog.warnon failure, never a trigger failure) andsetForgejoTriggerQuotaNotice(row.id, null). One site covers every exit: the turn starting, the row being dropped, and the row failing.drainre-reads rows fromlistPendingForgejoTriggers()on each iteration, so the row handed togateSafelyalways carries the id written by the previous pass.6. Keep turn-free events moving while an artifact is held
Quota holds can last hours, and
pump()skips any artifact that has a deferred row (:480-482), so without this step aclosedevent queued behind a held comment row would never archive its session, andpushed/statusrows would never enqueue their follow-ups. These actions consume no quota:gate()dispatches them at:535-548tohandleLifecycle,handleAbandon,handleBasePush,handleCommitStatusand returnsnull.Add a module constant:
Replace the row-picking logic in
drainandhasUngatedwith one helper so both agree (a disagreement makespump()'sfinallyre-pump forever):hasUngated(key)(:470) becomesthis.nextDrainable(key) !== undefined.drain(:495) takesconst row = this.nextDrainable(key)instead of its inlinefind, and on"deferred"continues the loop instead of returning when the row is inquotaDeferred(the hold is already registered byholdForQuota, so the nextnextDrainablecall restricts itself to turn-free rows); a non-quota deferral (checkbox settle, intake capacity) keeps today'sreturn.pump(:480) computesblockedfrom non-quota deferrals only:new Set([...this.deferred.values()].filter((r) => !this.quotaDeferred.has(r.id)).map(artifactKey)), so a newly arrived lifecycle event on a held artifact still gets drained.Consequence to accept: a
closedprocessed during a hold archives the session the held turn would have used;resolveSessionthen creates a fresh one when the turn finally runs. That is the same outcome as a close arriving while a turn is queued today.Not in scope:
startImplementer(:2632), the deck-UI implementer start, stays ungated — it is user-initiated, not scheduler-initiated. Mid-turn quota exhaustion also stays as today's genericagentfailure comment.Critical files & anchors
apps/server/src/forgejo/dispatcher.ts—pump:474,drain:495,gateSafely:513,gate:533, intake-capacity block :784 (insertion point),settleCheckboxBurst:820 (the deferral pattern to copy),dispose:450,postNotice:2560.apps/server/src/db/forgejo-triggers.ts—Row/toStored:32-69,setForgejoTriggerSession:192 (setter shape),listPendingForgejoTriggers:175.apps/server/src/forgejo/dispatch-api.ts—DispatchApiinterface :15-95,postCommentimpl :351.apps/server/src/model-config.ts—buildDeckSessionSettings:568, the isolatedSettingsthe quota reader must use.apps/server/src/env-schema.ts—ENV_SCHEMAentries andvalidateEnvValue:349.Verification
Working directory
apps/serverunless noted.apps/server/src/forgejo/quota.test.tsexercising the pure scoring on syntheticUsageReport[](factor the step 4-8 computation into an exportedscoreUsageReports(reports, thresholdPercent, nowMs): QuotaVerdictso it is testable without auth storage):anthropic:5hat 0.5 andanthropic:extraat 0.99 →ok(credits excluded);anthropic:7dat 0.94, threshold 90 →overwithusedPercent: 94andresetsAtfrom that limit's window;ok(minimum across accounts);extra→unreadable.Run:
bun test src/forgejo/quota.test.ts.apps/server/src/forgejo/dispatcher.test.tsusing its existing harness (fakeapi,depsoverrides) withdeps.quotareturningoverthenok:pending, the fake API recorded onepostCommentwhose body contains94%and90%, and the row'squotaNoticeCommentIdis the posted id;deps.quotatookand callingwake(): the turn runs, the row reachesdone, and the fake API recorded adeleteCommentfor that id;action: "closed"is processed while the hold is active (its session is archived) and the held row still has not run.Run:
bun test src/forgejo/dispatcher.test.ts.OMP_DECK_FORGEJO_QUOTA_MAX_PERCENTto1in Settings → Environment (no restart), then mention the agent in a comment on an issue of that repo. Expected: no session starts, the trigger showspendingin Settings → Forgejo, and the issue gains the hold comment naming1%. Set the value back to90and either wait out the re-check (≤30 min) or post another comment to force awake(). Expected: the hold comment disappears and the turn runs.bun run typecheckat the repo root (thepostCommentsignature change must leave no stale callers).Assumptions & contingencies
100means "hold only a fully exhausted account"; there is no separate off switch. If an off switch turns out to be needed, add0as "gate disabled" inquotaThresholdPercent()rather than a second variable./usageoutage proves common enough to freeze the scheduler in practice, change theunreadableverdict tookin one place (readForgejoQuota's catch) rather than adding a bypass in the dispatcher.resolveRoleModelFullwith no configured role. If it returns no model on a deck that clearly has a default model, resolve the provider instead fromregistry.getAvailable("all")[0]filtered byhasConfiguredAuth, and keep the "no model → ok" fallback.nextDrainablebecomesrows[0],pump'sblockedgoes back to all deferrals — and accept that an artifact is frozen for the duration of the hold.Also make sure that credits don't count toward the quota. The regular 5-hour/weekly quota must be below a threshold (e.g. 90%) for the scheduler to run turns.
clearProgressLabelcalls the removedthis.swapLabels#256