Skip to content

menubar: early-reset notification for Codex - #1339

Merged
iamtoruk merged 10 commits into
getagentseal:mainfrom
ozymandiashh:fix/early-reset-all-providers
Sep 17, 2026
Merged

iamtoruk merged 10 commits into
getagentseal:mainfrom
ozymandiashh:fix/early-reset-all-providers

Conversation

@ozymandiashh

@ozymandiashh ozymandiashh commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Codex gets the same early quota-reset notification Claude gets. After each successful Codex fetch or bootstrap, its windows go to the existing EarlyQuotaResetMonitor under provider id codex. A bootstrap only seeds a baseline, and disconnecting Codex clears its record. No dock band, no caption, no new polling.
  • Codex windows have no keys of their own, so they are keyed by a pre-localization storageLabel when the row has one (the monthly credit row). Otherwise they use the display label, which is a period or model name and does not translate. That way a language switch or the · limit reached suffix can't drop the baseline at the moment the reset happens.
  • When a provider reports absolute usage, signal 2 requires the absolute figure to fall too. A spend-cap increase drops the ratio but not the usage, so it stays silent. Claude is percent-only and keeps the ratio test unchanged.
  • The detector's type doc now spells out that fixed windows are enforced by the windowSeconds contract, not by the anchor inequality.

Testing

  • cd mac && swift build clean.
  • Early-reset test file typechecked under Swift 6 strict concurrency and run locally against the built module with a stub Testing module (the machine has no swift-testing): 42/42. Three new tests drive refreshCodexReportingSuccess() end to end. Mutations checked: removing the Codex refresh call or dropping storageLabel fails them.
  • CI swift test.

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.

@iamtoruk iamtoruk left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replayed all 3,274 Claude snapshots on my machine through both branches. Identical: 2 detections, both the Sep 1 incident, one notification. Claude is safe.

The rest I cannot check, because nothing but Claude keeps quota-window history on disk here. Before this merges I need your own replay numbers per provider, and the false-positive check on scheduled resets.

Three things first.

Only Codex can actually fire. Kimi, Gemini, Copilot, Antigravity and Cursor all pass windowSeconds: nil. That is a lot of code for one provider. Convince me it should not be Codex-only.

windowKey(forLabel:) slugifies a localized string. CreditLimit.shortLabel is L("Monthly usage limit"), so on zh-Hans the slug is empty and the key becomes "window". Switching language drops the baseline and the 30-day pattern. Two non-Latin sibling rows collide and the seen set silently drops one. The comment saying it never collides is not true off Latin.

shortLabel also appends "· limit reached", so the key flips exactly across at-limit to reset. That is the goodwill reset the changelog leads with, and it cannot fire.

Also: anchoredToNewCycle is vacuous when resetsAt tracks now + length, the inequality holds for any gap. Claude's four fixed windows were the guard. Now anything with a duration goes through. And a Codex spend-cap increase drops percent with resetsAt unmoved, which is signal 2. Want tests for both.

For the UI: before and after screenshots of the Capacity Dock detail popover with the early-reset band, the menubar quota hover card with the history caption, and the notification itself.

Three detector points from the getagentseal#1339 review:

- Storage identity is no longer the display label. The Codex credit
  row's label localizes and appends '· limit reached', so on zh-Hans the
  slugified key went unstable (language switch drops the baseline,
  translated siblings collide) and the key flipped exactly at the limit
  boundary — where the goodwill reset the feature exists to announce
  happens. QuotaSummary.Window gains storageLabel (pre-localization,
  state-free), the credit row sets it, and the monitor keys and names
  the window from it when present.

- Signal 2 requires absolute usage to fall when the provider reports
  it (usedUnits on the reading). A Codex spend-cap increase — limit
  raised 100->1000, usage rose, ratio collapsed 90%->9.5% — satisfies
  every ratio test and is not a goodwill reset. Percent-only providers
  (Claude) keep the ratio test.

- The signal-1 anchor keeps its original semantics (a successor cycle
  began after the last look at the one it cut short) and the type doc
  now states its limit honestly: a rolling window's re-anchor satisfies
  it vacuously, and rolling windows are excluded by the windowSeconds
  contract — an adapter that cannot vouch for a fixed cycle passes no
  duration and the window gets no opinion. The doc names the adapters
  that vouch today.

Tests: spend-cap silence, cleared-counter-with-units firing, percent-
only parity, rolling tracker excluded by the contract, sub-tolerance
creep, backwards successor, and storage-label key stability.
@ozymandiashh

Copy link
Copy Markdown
Collaborator Author

All three detector points are fixed in d23f019, the replay numbers follow, and the Codex-only question gets a direct answer below.

1. windowKey / shortLabel — both real, both fixed. Storage identity is no longer the display label. QuotaSummary.Window gains storageLabel (pre-localization, state-free); Codex's credit row sets it ("Monthly usage limit", a constant) while the popover keeps the localized, · limit reached-aware label. The monitor keys and names the window from storageLabel when present. So: no empty slug on zh-Hans, no baseline dropped on a language switch, no sibling collision, and — the one that matters — no key flip at the limit boundary, which was exactly where the flagship case lived. Pinned by storageLabelStabilizesTheKey.

2. anchoredToNewCycle — you were right that it was vacuous for that shape, and I've stopped pretending otherwise. The anchor keeps its original semantics (a successor cycle began after the last look at the one it cut short — this is what rejects a same-cycle nudge, and my first replacement attempt broke exactly those tests, which is how I learned the anchor was load-bearing). What a pair of readings cannot prove is now the type doc's first paragraph: a rolling re-anchor's implied start is always "now", so the anchor holds for any gap, and the exclusion of rolling windows is the windowSeconds contract itself — an adapter that cannot vouch for a fixed cycle passes no length and the window gets no opinion. Tests pin the contract (rollingTrackerNeedsTheContract — the indistinguishable pair stays silent because no duration is vouched), sub-tolerance creep, and a backwards successor.

3. Codex spend-cap increase → signal 2 — fixed on absolutes. CreditLimit already carries used/limit; the reading now carries usedUnits when the provider reports one, and signal 2 requires the absolute figure to fall too. Your scenario (limit raised, ratio collapsed, usage not) is silent; a real cleared counter falls in both ratio and units and still fires; percent-only providers (Claude) keep the ratio test unchanged. Tests for all three.

Your replay request — my numbers. This machine's Claude store: 1,052 snapshots (Sep 8–16), 493 five-hour readings with 14 scheduled rollovers, 559 seven-day with 1 — 0 events, 0 false positives on scheduled resets, replayed through this branch's detector. (No early reset appears in my store; your Sep 1 incident predates its horizon.) For Codex: there is no on-disk quota-window history to replay anywhere — persisting windows for other providers is what this PR adds, so pre-merge replay for them is not a thing that exists; what I can offer from this machine is that its Codex account does early-reset for real (banked-reset credits recorded Sep 14) and its zcode account gave a 5-hour early reset today.

"Convince me it should not be Codex-only." Directly: the set that can ever fire is exactly the set whose adapter vouches for fixed cycling — Claude and Codex today, zcode the moment #1347 lands (its payload carries the unit/count enum a fixed duration derives from; follow-up commit there). That is three providers that demonstrably do this in practice, one of which (Claude) already shipped through this exact plumbing in #725 — a Codex-only version would delete nothing but the third provider's future. Everything provider-specific about the feature was already generic in #725's shipped shape (notification, band, hover caption, dedupe ledger); this PR's real content is handing non-Claude windows to that path and the per-provider scoping, not new surface area per provider.

UI screenshots (dock band, hover card with the history caption, the notification) follow as a separate comment on a build with an injected announcement.

ozymandiashh added a commit to ozymandiashh/codeburn that referenced this pull request Sep 15, 2026
…t enum

The 5-hour and weekly windows are fixed cycles — the unit enum says so —
so the adapter vouches for their lengths (windowSeconds from the same
metadata the labels come from, never inferred from the label text).
This is what the early-reset monitor's windowSeconds contract needs
(getagentseal#1339): an adapter that cannot vouch for fixed cycling passes no
length and its windows get no opinion. zcode accounts do early-reset
for real — one did today on the machine this was tested on.
@ozymandiashh

ozymandiashh commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator Author
1-dock-band 2-hover-card-history

@iamtoruk

Copy link
Copy Markdown
Member

Thanks for the screenshots. They show a build from before 13 Sep though: the "Runs out in" and "in reserve" lines were removed from the quota rows on main that day (a858014), and your branch is cut from before it. So they do not tell me what this PR looks like on the current app.

Also a decision on our side that changes the shape of this PR: the twelve-hour dock band and the 30-day "last 3 resets came early" caption are coming out of main. An early reset is a one-time event and the notification already says it. I am landing that removal today.

What I want from this PR after that:

  1. Merge main into your branch.
  2. Drop the band and caption plumbing, so this is "Codex gets the same early-reset notification Claude gets". The detector fixes from d23f019 stay.
  3. Cut the changelog entry to two sentences.
  4. Fresh screenshots of the notification on the current build, or the test output if you cannot trigger one.

Then it is a small change and I will take it.

@iamtoruk

Copy link
Copy Markdown
Member

The band and caption removal is on main now (#1452). Rebase onto that and the rest of my comment above stands.

…entseal#1452

main dropped the early-reset dock band and history caption (getagentseal#1452), so this
branch drops its per-provider band, caption and cycle-ledger plumbing too, and
wires only Codex into the notification Claude already has. The detector fixes
from d23f019 stay: storageLabel identity, the usedUnits spend-cap guard and
the anchor contract doc.
@ozymandiashh ozymandiashh changed the title menubar: early quota resets for every provider, not just Claude menubar: early-reset notification for Codex Sep 16, 2026
@ozymandiashh

Copy link
Copy Markdown
Collaborator Author

Merged main (with #1452) and cut it down to what you asked for, in 1837f96:

  1. Main merged. Conflicts resolved in main's favour, so the band, the caption and all of their plumbing are gone. That includes this branch's per-provider band and caption state and the cycle ledger that fed the caption.

  2. Codex only. The one new call site is Codex's refresh and bootstrap. They hand their windows to the same monitor Claude uses, and a Codex disconnect clears Codex's record. The Kimi/Gemini/Copilot/Antigravity/dock-adapter wiring is gone. The detector fixes from d23f019 stay: storageLabel identity for the credit row, the usedUnits spend-cap guard and the anchor contract doc.

  3. Changelog is two sentences.

  4. Test output. I can't trigger a real early reset on demand, so here are the tests. Three new ones drive refreshCodexReportingSuccess() end to end with a stubbed fetcher:

    • codexRefreshAnnounces: weekly window at 80% with 18h left, then a new cycle. One notification, Codex quota reset early / Codex's weekly limit reset 18h early. You're back to 100%.
    • creditRowSurvivesLimitReached: the credit row goes from · limit reached to cleared with the reset time unchanged. One Codex quota cleared early, because the key no longer flips at the boundary.
    • codexWithoutDurationIsSilent: the same cleared counter with no validated window length. Nothing.

    I checked that the tests can fail. Removing the Codex refresh call breaks the first and second, and dropping storageLabel breaks the second. The full early-reset file passes locally (42/42), and CI's swift test is the real gate.

Diff against main: +187/−18 in sources and changelog, the rest is tests.

windowName(forLabel:) matched five nouns; only "limit" is reachable from
any label Codex produces.
@iamtoruk
iamtoruk merged commit 1e6d43d into getagentseal:main Sep 17, 2026
11 checks passed
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.

2 participants