Skip to content

menubar: early quota resets for every provider, not just Claude - #1339

Open
ozymandiashh wants to merge 6 commits into
getagentseal:mainfrom
ozymandiashh:fix/early-reset-all-providers
Open

ozymandiashh wants to merge 6 commits into
getagentseal:mainfrom
ozymandiashh:fix/early-reset-all-providers

Conversation

@ozymandiashh

Copy link
Copy Markdown
Collaborator

Summary

  • menubar: detect early quota resets and say so #1329 shipped early-reset detection wired to one provider. Codex's goodwill
    resets — a vendor restoring a rate-limit window ahead of schedule — are the
    clearest instance of exactly what it watches for, and the refresh lifecycle saw
    every one of them and dropped it, because detectEarlyResets was only called
    from Claude's snapshot capture and passed CapacityDockProvider.claude
    literally. Every Capacity Dock provider now hands the monitor its own windows
    as its fetch succeeds, off the QuotaSummary the popover already draws.
  • Detection, the baseline, the announced-reset record, the notification, the
    twelve-hour dock band and the 30-day history caption are all scoped per
    provider and per window: an early reset on Claude cannot move Codex's state,
    and a disconnect drops only that provider's record. The UserDefaults key and
    record shape are unchanged and the one new field is optional, so a record
    written by the build that shipped this still decodes and a Claude event a user
    has already been notified about is not announced again after updating.
  • Claude and Codex announce today, because theirs are the adapters that report a
    validated window length. Antigravity, ClinePass, Copilot, Cursor, Gemini, Grok,
    Kimi Code and Z.ai run the same path and stay silent until their adapters carry
    one — the same "no validated duration, no opinion" guard that was already in
    the detector. No thresholds and no false-positive guards changed; only the
    provider scope.
  • Windows with no key of their own are identified by the label the adapter
    already shows, slugified, so sibling rows stay apart. Claude keeps the snapshot
    store's keys. usageName no longer doubles the noun, which it would have for
    Codex's "Monthly usage limit" row.
  • feat(codex): forecast the chance of a usage-limit reset, with its range #1327's live "since last reset" clock reads its events from this detector, so
    it stays dormant for Codex until this lands.

Testing

  • cd mac && swift build clean.
  • swift testcannot run on this machine (Command Line Tools only:
    error: no such module 'Testing'). Pre-existing, and true on pristine
    main. The pure logic was verified with a standalone swiftc harness
    instead, built from the real EarlyQuotaReset.swift,
    EarlyQuotaResetMonitor.swift and QuotaPace.swift with minimal stubs for
    SubscriptionSnapshot and UpdateNotifier, mirroring the new suite:
    107 checks, 0 failures. The test file itself is syntax-checked with
    swiftc -parse; CI is the first place it type-checks and runs.
  • Mutation check: 8 mutations, all caught — the usage-noun fix, the state
    defaults key, per-provider state scoping, the cycle ledger's
    one-entry-per-cycle rule, label slugification, the window-name rule, and
    the ledger field's decode compatibility (twice, including a variant patched
    to compile).
  • The suite's existing guards now run against Codex as well as Claude, plus
    cross-provider isolation, Codex dedupe across a relaunch, the stored-record
    compatibility case (paired with a not-yet-announced control, so a record
    that could no longer be found or decoded fails it), and two AppStore
    cases that drive a dock provider's refresh end to end.

Not verified: no notification was delivered and no dock band or hover caption was
seen in a running app. There is no Codex snapshot store to replay the monitor
over the way #1329 replayed Claude's.

Refs #725.

Rebased onto main after #1330 (menubar i18n): the no-doubling rule now lives in the L(...)-routed fallback of usageName, and non-Claude window names reach the catalog through a new %@ limit key (en + zh-Hans). Main's localization scanner passes on this branch (no unrouted literals, no orphaned keys).

The detector shipped wired to one provider. Codex's goodwill resets are the
clearest instance of the thing it was built to catch — a vendor restoring a
rate-limit window ahead of schedule — and the refresh lifecycle saw every one
of them and dropped it, because `detectEarlyResets` was only ever called from
Claude's snapshot capture.

Each Capacity Dock provider now hands the monitor its own windows as its fetch
succeeds, off the `QuotaSummary` the popover already draws. Claude keeps its
existing call, whose window keys are the snapshot store's and must not move;
every other provider identifies a window by the label its adapter already
shows, slugified, which is the only stable name these adapters give one.

Nothing about the thresholds or the guards moved. A window still needs a reset
time and a validated length to produce anything, so Claude and Codex announce
today and Antigravity, ClinePass, Copilot, Cursor, Gemini, Grok, Kimi Code and
Z.ai run the same path in silence until their adapters carry a length. The
monitor's record was already per provider; the band, the dedupe, the baseline
and now the history summary are all scoped with it, so an early reset on one
provider cannot move another's state, and a disconnect drops only its own.
The 30-day pattern needs cycle times to read, and only Claude has a snapshot
file, so the monitor keeps a small per-window ledger of observed cycles beside
its baseline for everyone else. The field is optional, and the defaults key is
untouched, so a record written by the build that shipped this still decodes and
a Claude reset already announced is not announced again after the update.

`usageName` no longer doubles the noun, which it would have for Codex's
"Monthly usage limit" row.
Every false-positive guard from the original suite is now parameterised over
Claude and Codex, alongside both signals and the copy each produces, so a
change that quietly re-narrows the detector to one provider fails here.

Four cases the old suite could not have: an early reset on one provider leaves
the other's baseline, band and dedupe record untouched and each is announced in
its own name; a Codex reset announced once is not announced again across a
relaunch; a state record written in the shape and under the exact key the
feature first shipped with is still read, so an event the user has already been
notified about stays silent — paired with the same record carrying nothing
announced, which must post, or the silence would also be produced by a record
that can no longer be found or decoded; and the cycle ledger keeps one entry per
cycle rather than per fetch, which is what lets a provider with no snapshot file
have a 30-day pattern at all.

Two more drive the AppStore rather than the detector, because that is where the
Claude-only scope actually lived: a dock provider's second fetch produces a band
named after it while Claude's stays empty, and a provider whose windows carry no
duration stays silent.
The design doc now records that every dock provider runs the same detector
under its own record, that a window needs both a reset time and a validated
length to say anything, and names the eight live adapters that report the first
but not the second and are therefore silent. Claude's provider doc says its
snapshot-backed history is the exception, not the rule.

Refs getagentseal#725
Brings in getagentseal#1330's menubar localization, which routed the early-reset
notification, band and history copy through L(...).

One conflict, in EarlyQuotaResetFormat.usageName, where both sides rewrote the
same function. Main replaced the suffix rule with a switch on the four known
English window names, each routed through its own catalog key, falling back to
L("%@ usage", windowNoun(name)). This branch had stopped that fallback doubling
the noun for Codex's "Monthly usage limit" row. Both survive: the four routed
cases are main's, unchanged, and the default branch keeps the no-doubling rule
before routing, so an unknown name whose noun already ends in "usage" reads
through instead of becoming "monthly usage usage". No catalog key changes: the
rule only decides whether "%@ usage" is applied.

Mixing implicit and explicit returns in one switch does not compile, so every
case now returns explicitly.
getagentseal#1330 translates the early-reset copy at render, keyed on the four English
window names Claude produces, and lets any other name read through untouched.
Before this branch no other name could reach that copy. Now every provider's
window does, named by windowName(forLabel:), which composes the label with
" limit" in English. That word is ours, not the provider's, and was the one
piece of new copy on this branch that never reached the catalog: limitName's
default returned it verbatim, so a zh-Hans build said "monthly usage limit".

limitName's default now routes it as "%@ limit", the same shape usageName
already uses for its own default, with the provider's noun substituted and left
untranslated. The name is still composed in English and persisted that way, as
claudeWindowName(forKey:) is, so a language change never leaves a translated
string in a stored event. "Weekly" and "5-hour" compose into names the known
set already has, so the common Codex rows translate in full.

The empty-label fallback "quota window" was the other English literal. Rather
than route copy for a degenerate row, a blank label is now skipped before it is
observed: it has no identity to store under, and two of them would share a key.

New catalog key: "%@ limit" = "%@ limit" (en), "%@ 限额" (zh-Hans), matching the
existing "每周限额" / "5 小时限额" entries.
CI's swift test could not build the test target. Three errors, all in this
file, none visible locally because the target never compiles without the
Testing module:

- The two parameterised tests take EarlyResetProviderCase, which was private,
  so their default-access methods outranked the type they accept. The type is
  now internal, keeping the methods at the access every other test uses; a
  fileprivate type would not have helped, the methods would still outrank it.
- earlyResetHistoryCaptions(for:) has a ProviderFilter and a
  CapacityDockProvider overload, and both have a .claude, so the bare member
  was ambiguous. Spelled CapacityDockProvider.claude there and on the band
  assertion beside it.
- summary(percent:resetsIn:windowSeconds:) inherited the @mainactor suite's
  isolation but is called from the adapter dependencies' @sendable closures.
  It is a pure fixture builder, so it is nonisolated, the same shape as
  CapacityDockProviderQuotaServiceTests.summary(percent:).

Checked by type-checking the file with swiftc in Swift 6 mode with complete
strict concurrency against the built module and a stub Testing module: the
unfixed file reproduces CI's nine errors at the same lines and columns, and
the fixed file and all sixty non-XCTest test files together report none.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant