Preserve empty scoped quota array through load, merge, and sidebar normalization#108
Open
iceteaSA wants to merge 5 commits into
Open
Preserve empty scoped quota array through load, merge, and sidebar normalization#108iceteaSA wants to merge 5 commits into
iceteaSA wants to merge 5 commits into
Conversation
There was a problem hiding this comment.
6 issues found and verified against the latest diff
Confidence score: 3/5
- In
packages/core/src/accounts.ts,normalizeQuotadrops emptyscopedarrays on persistence round-trips, which breaks thefetchOAuthQuotaSnapshotcontract thatscopedis always an array and can cause downstream consumers to mis-handle quota state — preserve empty arrays during normalization and add a round-trip regression test before merging. - In
packages/opencode/src/tui.tsx(ScopedQuotaRow), bar segment coloring is driven by percentage-only tone while the scoped window already carries aseverity, so the visual state can contradict the reported status and mislead users — routeseverityintoquotaBarSegmentsso the bar and percentage text stay consistent. - In
packages/opencode/src/sidebar-state.ts, duplicated scoped-normalization logic formain.quotaand fallback accounts increases the chance of drift as the newscopedshape evolves, leading to inconsistent behavior between account paths — extract a shared normalization helper and use it in both places. - In
packages/core/src/tests/scoped-quota.test.ts, key regression paths are still untested (dedupe by id vs display name,group: 'weekly'guard, and coexistence with legacyfive_hour/seven_daywindows), so subtle parsing regressions could ship undetected — add these targeted cases before merge or track them as immediate follow-up test debt.
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
A downstream reader keys off scoped-key PRESENCE: an empty [] means anthropic-auth owns the quota and no carve-out is visible, distinct from no scoped data on the snapshot. normalizeQuota and mapScopedWeeklyLimits both dropped empty arrays to undefined, which collapsed the whole quota object when scoped was the only key and silently broke that contract. Tracing confirms routing is unaffected: quotaSnapshotModelScopeIsExhausted does quota?.scoped?.find(...), which returns undefined for both [] and undefined — the predicate resolves identically, so this is a pure persistence-layer change.
…ndows visible Pre-scoped sidebar code never emitted "5h: — 7d: —" — those dashes were always real values or omitted. When a model has no five_hour/seven_day data but a scoped window is visible, the line used to read "5h: — 7d: — Fa: 100%"; now it reads "Fa: 100%". The partial-dash case (one of the two present) is preserved.
GPT review surfaced a second site where empty-[] was conflated with missing-data. mergeAccountRuntimeState compares quotaSnapshotCheckedAt(existing) vs quotaSnapshotCheckedAt(incoming); the existing helper only summed per-window checkedAt, so a windowless {scoped:[]} incoming snapshot read as checkedAt=0 and was treated as stale — preserving the old (exhausted-Fable) quota on disk instead of writing the empty array.
Fix: stamp a top-level checkedAt on the snapshot at fetch time, propagate it through normalizeQuota so it survives save/load, and include it in quotaSnapshotCheckedAt's Math.max. For normal five_hour+seven_day snapshots the top-level stamp equals the per-window stamps so the merge behavior is unchanged; for a windowless empty-scoped snapshot it now yields the real timestamp and the empty array lands.
Same one-liner bug as the core quota normalizer, second file: normalizeAccountQuota dropped an empty scoped:[] inside the Array.isArray guard, collapsing the whole quota object to null when scoped was the only key. Unconditional assignment preserves the empty array; the OUTER Array.isArray guard means pre-feature inputs without a scoped key are unaffected.
b149c3b to
fc8c7b7
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Rebased onto current
main(the scoped weekly quota limits feature is now merged). This reduces the PR to two correctness fixes on top of that feature — both are latent bugs in the current implementation.1. Empty
scoped: []is dropped on load and lost in the runtime-state mergefetchOAuthQuotaSnapshotalways returnsscopedas an array, but two paths drop it when empty, which matters because an empty array is a meaningful state (the API returned no model-scoped carve-out) distinct from "no scoped data":normalizeQuotadidif (scoped.length) quota.scoped = scoped. When a persisted quota has an emptyscopedand nofive_hour/seven_day, the whole quota object then collapses toundefinedvia theObject.keys(quota).lengthguard. Fixed with an unconditionalquota.scoped = scopedinside the existingArray.isArray(value.scoped)guard (so pre-feature data with noscopedkey is unaffected).mergeAccountRuntimeStatecompares freshness viaquotaSnapshotCheckedAt, which returned0for a windowless{scoped:[]}snapshot — so the merge treated it as stale and resurrected the previous (e.g. exhausted) scoped window instead of writing the empty array. Fixed by adding a top-levelcheckedAttoOAuthQuotaSnapshot(stamped infetchOAuthQuotaSnapshot, preserved innormalizeQuota, included inquotaSnapshotCheckedAt'smax). For a normal snapshot the top-level stamp equals the per-window stamps, so the freshness comparison is unchanged; a windowless empty-scoped snapshot now carries its real timestamp.Routing is unaffected: an empty
scoped: []behaves identically toundefinedinquotaSnapshotModelScopeIsExhausted/getScopedQuotaWindowForModel(.find()over[]and the optional-chain overundefinedboth yield no window), and no routing predicate reads the new top-levelcheckedAt. A regression test locks this invariant.2. Collapsed sidebar shows
5h: — 7d: —placeholders when only a scoped window is visiblegetCollapsedQuotaSummaryunconditionally emitted the5h/7dsegment even when both are null, producing e.g.5h: — 7d: — Fa: 100%. Now the5h/7dsegment is emitted only when at least one is present; when both are null (reachable only because a scoped window is visible) the line is just the scoped segment. The all-windows-present and single-present (dash-for-absent) cases are byte-identical.Tests
Red-first on the current code for every fix: empty-
[]round-trip (scoped-only and alongsidefive_hour), the windowless-{scoped:[]}merge-freshness repro, top-levelcheckedAtround-trip, the sidebarnormalizeAccountQuotaempty-array case, the collapsed-summary placeholder omission, plus a routing-invariant lock (scoped: []is not treated as exhausted). All suites green — core 6, opencode 768, pi 47, e2e 2; typecheck and lint clean.Greptile Summary
This PR surfaces model-scoped weekly quota carve-outs (e.g., the Fable-only promo window) by parsing
limits[]from/api/oauth/usage, persisting them asquota.scoped[], and rendering them in the collapsed summary alongside the existing 5h/7d windows. Scoped windows are intentionally excluded from routing, policy, and killswitch logic.mapScopedWeeklyLimitsfilters forkind:\"weekly_scoped\"+group:\"weekly\", requires a non-emptydisplay_name, deduplicates by model id/slug, and always returns an array (neverundefined). A new top-levelcheckedAtonOAuthQuotaSnapshotensures windowless empty-scoped snapshots win freshness comparisons inmergeAccountRuntimeStateinstead of being treated as stale (T=0).getCollapsedQuotaSummarynow omits the\"5h: — 7d: —\"prefix when both windows are absent and only scoped data is visible; the fix from the previous thread is present and tested.quotaSnapshotPassesPolicyandkillswitchPassesPolicyiterate only['five_hour', 'seven_day']; a fully-exhausted scoped window does not mark an account dead.Confidence Score: 5/5
Safe to merge. The additive nature of the change (scoped windows are parsed and displayed but never gate routing or killswitch decisions) limits blast radius, and the freshness/merge logic for empty-scoped snapshots is correctly guarded by the new top-level checkedAt field.
All changed paths are additive or narrowing: the existing five_hour/seven_day parse/persist/render paths are untouched, policy and killswitch checks are explicitly constrained to those two windows, and the new top-level checkedAt correctly handles the windowless-empty-scoped merge scenario. Edge cases (malformed limits, non-finite percents, absent display_name, empty arrays through save/load) are all handled and tested. No routing, auth, or data-loss risk is introduced.
No files require special attention. The tui.tsx rendering changes noted in previous review comments are outside this diff; the padding alignment concern there is tracked separately.
Important Files Changed
Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart TD A["/api/oauth/usage response"] --> B["fetchOAuthQuotaSnapshot"] B --> C["mapUsageWindow x2"] B --> D["mapScopedWeeklyLimits"] D --> E["AccountScopedQuotaWindow[]"] E --> F["scoped: []"] B --> G["checkedAt: now"] C --> H["OAuthQuotaSnapshot"] F --> H G --> H H --> I["normalizeQuota"] I --> J["OAuthAccount.quota"] J --> K["quotaSnapshotCheckedAt"] K --> L["mergeAccountRuntimeState"] J --> M["projectQuotaToSidebar"] M --> N["getCollapsedQuotaSummary"] J --> R["quotaSnapshotPassesPolicy / killswitchPassesPolicy"]%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% flowchart TD A["/api/oauth/usage response"] --> B["fetchOAuthQuotaSnapshot"] B --> C["mapUsageWindow x2"] B --> D["mapScopedWeeklyLimits"] D --> E["AccountScopedQuotaWindow[]"] E --> F["scoped: []"] B --> G["checkedAt: now"] C --> H["OAuthQuotaSnapshot"] F --> H G --> H H --> I["normalizeQuota"] I --> J["OAuthAccount.quota"] J --> K["quotaSnapshotCheckedAt"] K --> L["mergeAccountRuntimeState"] J --> M["projectQuotaToSidebar"] M --> N["getCollapsedQuotaSummary"] J --> R["quotaSnapshotPassesPolicy / killswitchPassesPolicy"]Reviews (4): Last reviewed commit: "test: neutral rationale comment on empty..." | Re-trigger Greptile