feat(codex): forecast the chance of a usage-limit reset, with its range - #1327
feat(codex): forecast the chance of a usage-limit reset, with its range#1327ozymandiashh wants to merge 13 commits into
Conversation
The reset forecast needs a record of past resets, and getagentseal#725 forbids the client fetching one. The maintainer's exception is narrow and written down: the dataset is refreshed by a GitHub Action in the repo, never by the client at runtime. So the record lives here, as a file. 44 resets and 9 credit grants from codex-reset.com's public timeline, a community tracker that is not operated by or endorsed by OpenAI. Four fields survive the import - id, instant, type, reset kind - and every text field upstream carries is dropped before anything is written. The file is a table of times, not a copy of anyone's posts, and it carries its own attribution in the header. The refresh script is split so the transform and the guard rails are pure and testable: it refuses a payload that is not a list of events, a timestamp that goes backwards, a duplicate id, a field outside the four, and - the one that matters most - a response holding fewer resets than the committed file, so a truncated upstream cannot quietly delete history. SwiftPM resources have to live inside the target directory, so the record exists twice and the script writes both copies; a test pins them together byte for byte. The workflow runs every six hours, validates the written file through the client's own parser, and opens or updates a pull request. It never pushes to main. If the organization bars Actions from opening pull requests it falls back to the pushed branch plus a comparison URL in the step summary, the way the star-history workflow already does.
A pure module: a history and a `now` go in, probabilities come out. No clock of its own, no I/O, no network - the arithmetic runs over the file the previous commit added. The model is the empirical conditional distribution of inter-reset waits, which for a fully observed record is what Kaplan-Meier reduces to. Waits from the last 120 days count three times as much, because both public trackers report the cadence sped up and the record agrees: 201 hours mean across the whole record against 116 across the last 120 days. Deep in the tail the at-risk set thins to one or two waits, where a raw share is 0 or 1 and reads as certainty, so the estimate is shrunk toward a memoryless rate in proportion to how thin the set is. The range is a Wilson interval on the unweighted at-risk counts. Unweighted on purpose: recency weighting and shrinkage are judgements about the data, not extra observations, and neither may narrow the range. It is then widened, never narrowed, to contain the point. The hour-of-day prior is the observed density in San Francisco, applied to the hazard and averaged across the horizon. In this record nothing lands between 02:00 and 07:00 Pacific and 95% lands between 07:00 and 23:00, any day - the weekday spread is flat enough that treating weekends differently would describe a pattern the record does not show. Two guards keep the prior from overclaiming: the per-hour multiplier is floored, so a probability is never taken to exactly zero, and every estimate is capped short of certainty, because multiplying a hazard by up to 24 otherwise manufactures a 100% that 44 events cannot support. Since the prior can lift a six-hour window more than the 24-hour window containing it, the 24-hour figure is raised to at least the six-hour one. Local resets - the early-reset detector (getagentseal#1320) and banked credits (getagentseal#1322) - are an input, not an import, so neither has to merge first. With none, the forecast conditions on the global record. The confidence label is earned, not asserted. `backtest()` rebuilds the model from each prefix, asks it at fixed offsets for the chance the next reset lands within 24 hours, and scores it against what happened and against the same prefix's base rate. On the record we ship the model does NOT beat the base rate - 0.132 Brier against 0.129 over 220 probes - so the label is "low", and the test asserts that rather than letting a future refresh quietly upgrade it. Every rendered sentence carries its range, says "chance", and never says "expected" or "will". A missing or unusable record produces a stated reason, never a zero.
A block under the table rather than a row in it: the sentences are far longer than any window label and would stretch the Window column past every other provider's row. `--format json` carries the numbers structurally under `providers[].resetForecast`, so nothing downstream has to parse English to get the probability and its range. Attached only to a connected Codex provider. Every other provider comes back untouched, and a machine that is not signed in to Codex is not told about capacity it cannot use. `collectQuota` gains no reader, no request and no timeout budget: the forecast is arithmetic over a bundled file.
The menubar cannot read this off the CLI payload. `codeburn status --format menubar-json` carries cost, sessions, providers and pricing but no quota block at all - the menubar's quota comes from its own native adapters - so unlike pricing, the forecast has to be computed here, over a byte-identical copy of the same committed record. The Swift model is a deliberate mirror of `src/reset-forecast.ts`, constant for constant and word for word, and the test suite pins the rendered sentences to the literal output of the TypeScript module on the same fixture. Two surfaces: the Codex quota hover card, through the footer lines it already draws, and the Plan tab, beside the pace captions that answer the same question one step nearer. The notification is off by default, and it is the only quota notice here that is. The others report something that already happened; this one reports a probability, and a probability that arrives uninvited is a worse trade. The user picks a threshold, default a 50% chance within six hours. It fires at most once per crossing and re-arms only once the estimate falls back under the threshold, so it cannot nag; the fired state is persisted the way the subscription snapshots are, so a relaunch does not repeat it; a moved threshold or a new reset starts a new crossing; a failed refresh is no opinion rather than a re-arm; and a stale record or an estimate already past the end of the record never fires at all. The text says what it is before it says a number - a statistical estimate from public reset history, not an announcement from OpenAI - and it has no button, no link and no side effect. Nothing in this feature spends, redeems, requests or refreshes anything.
The model is only defensible if a reader can check it, so the page gives the arithmetic, the guards, the source and its provenance, and the backtest numbers - including the uncomfortable one: on the record we ship, conditioning on elapsed time does not beat the base rate, which is why the shipped confidence label is "low". It also states the exception this feature runs under, verbatim: the dataset is refreshed by a GitHub Action in the repo, never by the client at runtime. Refs getagentseal#725 rather than Closes: that issue is a five-part epic and this is one slice of its surfaces, the same call getagentseal#1320 and getagentseal#1322 made.
…IX paths Two CI failures on getagentseal#1327, both ours. macOS `swift test`: five tests in CodexQuotaSummaryTests assert Codex's `footerLines` exactly, and got three lines instead of one. Appending the forecast there was the wrong call, not a stale expectation. `QuotaSummary.footerLines` is the adapter's normalized output, consumed verbatim by the Capacity Dock, the hover card and those tests; an estimate derived from a public record is not something the account reported, and stacking it in there puts words in the adapter's mouth and couples every consumer to a feature none of them asked for. getagentseal#1322 adds Codex lines to the same array, so the collision was going to get worse. `QuotaSummary` gains `forecastLines`, defaulted empty, so every other adapter's construction site and every other provider's summary are untouched. The hover card draws it under its own divider, below the provider's own facts. The five existing tests are unchanged and now cannot be affected: the Codex `footerLines` array is built from the credit rows alone. Windows `test-platforms`: the dataset-parity test compared `datasetPaths()` against paths written with '/'. The separator is '\' there, so two identical path lists compared unequal - getagentseal#1291's lesson again. The expected side is now built with `join`, the invariant is pinned as tail segments rather than as a spelling, and a case with a Windows path exercises the splitter on both separators. The byte-identical check also normalizes line endings and compares the parsed documents, because a Windows checkout can hand back CRLF for a file the refresh script wrote with '\n'.
…ecord The forecast has two inputs with very different freshness needs, and only one of them was being met. The distribution of waits comes from the bundled record and is stale by a release cycle - Action, pull request, merge, release, update. That is fine: 44 waits do not change shape in a week and a 3.6-day median does not move because the file is four days old. The last-reset clock is the other input, and it was frozen at the last release. A forecast that still believes the last reset was six days ago when one landed this morning is not slightly wrong; it is wrong in the direction that matters, and it is the direction that tells someone to spend down quota that is not coming back. The public trackers learn of a reset in about two minutes. This machine can learn of it in one refresh cycle, and now does. `CodexResetForecastLocalEvents` reads what the two sibling features already persist and maps them onto the `LocalResetEvent` seam the model has had since it was written. It reads rather than imports: neither branch is on main, so a code dependency would make this one unmergeable until they land. The persisted records are decoded through private mirror types carrying only the two fields the forecast needs, and a missing file, a missing key, a wrong shape or an unreadable date is no opinion rather than an error - with neither feature installed the loader returns nothing and the forecast conditions on the global record exactly as before. Worth recording, because both differ from what you would guess: - getagentseal#1320 persists to UserDefaults, one JSON record per provider under `codeburn.quota.earlyReset.state.<providerID>`, seconds-since-1970, not a file in the cache directory. Only `latestEvent` is retained, which is the one this needs. Its `detectedAt` is used, never `scheduledResetAt` - the latter is when the cut-short cycle would have reset, which is in the future and is not when anything happened. - getagentseal#1322's store keeps `firstSeenAt`, not `grantedAt`; `grantedAt` only exists on that branch's transient event and never reaches disk. So the timestamp errs late by up to one refresh cycle, never early. - getagentseal#1320 currently only records Claude. Until it also records Codex, the early-reset half of this is correct and dormant; the banked half works today. The Codex filter is enforced twice regardless, by storage key and by the id inside the record, because an Anthropic early reset moving the Codex clock would wreck the forecast silently. The sentence now names the clock it is reading: "12h since the reset observed on this machine at 14:30" rather than "since the last global reset", mirrored in the TypeScript module so the two stay identical. The CLI is untouched: `src/quota/*` persists nothing between invocations, so there is no local store for it to read and `codeburn quota` stays conditioned on the bundled record.
getagentseal#1329 (early quota resets) and getagentseal#1328 (banked Codex resets) both landed, so the two features this forecast reads from are now on main. Conflicts, all resolved as unions: - CHANGELOG.md, the usual: both sides' entries kept in their sections. - SettingsView.swift, the seam this branch flagged. Main folded the notification switches into one `Section("Notifications")` (138ef34), so the forecast's opt-in notice and its threshold picker move into that section rather than adding a fourth one. The standalone "Codex Reset Forecast" section is gone. - AppStore.swift, five hunks: both announcers, both post-fetch observe calls, both stores cleared on disconnect, and the Codex summary now carrying main's banked-reset footer line alongside this branch's `forecastLines`. Nothing from either side dropped. - HeatmapSection.swift: main replaced `resetCreditsLabel` with `CodexBankedResetPresentation`; that deletion is kept and this branch's forecast rows stay. - src/quota/index.ts: main's `notes` and this branch's `resetForecast` are both on `QuotaCommandProvider`. They stay separate on purpose - `notes` is for facts the provider reported, `resetForecast` is an estimate over a public record, and folding one into the other is what the footer-lines fix earlier on this branch was about. With both features on main, the local-event loader now decodes through their real `Codable` types - `EarlyQuotaResetMonitor.ProviderState`, `EarlyQuotaResetEvent` and `CodexBankedResetState` - instead of the private mirror structs it carried while they were unmerged. Drift is now a compile error rather than a silent misread. It still reads the two stores by address rather than calling them, because neither exposes a read this can use: `CodexBankedResetStore.load()` is async and hard-wired to the real cache path with nothing to inject, and the forecast is evaluated synchronously from view bodies; and `EarlyQuotaResetMonitor.visibleEvent` applies the twelve-hour dock-visibility window, which is the wrong filter here - a reset twenty hours ago is no longer worth a band in the dock and is still exactly what "since last reset" should count from. The `UserDefaults` key prefix is still a literal, because `EarlyQuotaResetMonitor` is @mainactor and its static cannot initialise a nonisolated one; a test asserts the two strings are equal, so drift fails there instead of silently reading nothing.
The forecast's distribution of waits was frozen at whatever shipped in the build: Action, pull request, merge, release, update. That mattered less than the last-reset clock the previous commit fixed - 44 waits do not change shape in a week - but it is still a record days behind a tracker that learns of a reset in two minutes. getagentseal#725 exception granted by the maintainer on 2026-09-13: a client-side, first-party, conditional fetch of the reset-history dataset from this repository on GitHub, at most hourly, carrying no user data; no request is ever made to codex-reset.com or any third party from the client. The workflow now publishes twice. Every 30 minutes it pushes the dataset to `data/codex-reset-history`, which is what installed clients read. Weekly it opens or updates the pull request to main, which keeps the copy bundled in releases recent - the offline fallback, and the floor a fetch can only improve on. It still never pushes to main, and it has no `push` trigger, so pushing the data branch cannot retrigger it. The client side, and the reasons for each choice: - `api.github.com`, because `UpdateChecker` already contacts it every two days. `raw.githubusercontent.com` would have been a host this app has never spoken to, so the contents API is used instead with the raw media type. - A conditional GET with the stored ETag. The common answer is a 304 with no body: one request against the unauthenticated 60-per-hour limit. - At most once an hour, on the refresh that already runs. No new timer. The attempt is detached, so nothing in the UI waits on a network call, and it only happens while Codex is connected. - No credential, no cookie, no account id, no plan, no usage. An Accept, a product User-Agent, and an ETag when there is one. Cookies are refused explicitly. - A fetched record is validated against the same rules the workflow applies - schema, monotonic timestamps, the two event types, and nothing beyond the four fields - and adopted only if it is strictly newer than what is held. Validation runs on the raw bytes, not the decoded value, because `Decodable` silently drops keys it does not know and a record carrying post text would otherwise decode cleanly. - Every failure is silence: offline, rate limited, 500, timeout, a malformed body, a body that validates but is older. `Retry-After` pushes the next attempt out rather than sleeping. The bundled record is always there, so the forecast is never worse than it was before this existed. The switch is in the consolidated notifications section and defaults on, unlike the forecast notice: this makes a number already on screen more accurate rather than adding an interruption. The CLI honours `CODEBURN_PRICING_SNAPSHOT_ONLY`, the knob that already pins pricing to its bundled snapshot, rather than inventing a second offline switch, and `codeburn quota` now prints which copy it read and when that copy was built. The set of hosts this client contacts is unchanged: api.github.com, and nothing else. codex-reset.com is contacted only by the Action.
…tion "Refresh reset history from GitHub" is a data-refresh switch, not a notification, and it was sitting under Notifications because that is where the consolidation on main put everything Codex-shaped. A reviewer would flag it, and rightly. It moves to its own "Reset Forecast Data" section, which is also the natural place for the fact a user actually wants when they look at that switch: which copy of the record is in use and when it was built. `CodexResetForecastPresentation.datasetLine` produces that line, in the same words and the same raw ISO instant `codeburn quota` prints, so the two surfaces cannot disagree about which record is feeding the number. An undated record still says which copy it is rather than rendering an empty date. The opt-in notice toggle and its threshold picker stay in Notifications, where they belong: those do post notifications. The preference key is unchanged, so nothing migrates and nobody's setting moves.
**macOS `swift test` did not compile.** Three fake transports in `CodexResetForecastTests.swift` mutated a captured `var` from inside a `@Sendable` closure, which Swift 6 rejects outright. They record through an actor now. Worth saying why this got past me: `swift test` cannot run on this host (CLT-only, no `Testing` module), so `swift build` never compiles the test target and the whole class of bug is invisible locally. The harness now builds with `-strict-concurrency=complete` and is itself clean under it, so the next one is caught before push rather than by CI. **vitest hung for the full 30s on every Node leg.** "Survives an unwritable cache directory" pointed at `/proc/definitely/not/writable`, which was a bad idea: that path means something different on every platform, and on the Linux runner it did not fail - it never returned. Two fixes, because both were wrong. The production path is now unstallable. Cache reads and writes are bounded by `CACHE_IO_TIMEOUT_MS` and fall back to "no cache", so a read-only mount, a synthetic filesystem or a stalled network mount cannot wedge a command - and `codeburn quota` is one the macOS menubar blocks on. The timer is unref'd so it can never hold a CLI process open. The test no longer relies on a magic path. It creates a directory this user genuinely cannot write to, having first established that the precondition holds - root ignores mode bits and Windows has none - and skips with a reason when it does not, the way the permission tests in `tests/cache-refresh-lock.test.ts` guard. It asserts the observable outcome: an answer, from the fetch, with no throw, in under the cache deadline. It finishes in milliseconds.
getagentseal#1330, the menubar i18n work, landed: `defaultLocalization`, en and zh-Hans catalogs, a language picker, and a scanner test that reads the source and fails when a user-facing literal never reaches the catalog. One conflict, `mac/Package.swift`, resolved as a union: the dataset's `.copy("Resources/CodexResetHistory")` and the two `.process` entries for the `.lproj` bundles all stay, and `defaultLocalization: "en"` is kept. The two declarations are independent - one carries data the model reads, the others carry strings NSBundle resolves per localization. What the merge actually implied was the rest of this commit. Main's scanner found seven user-facing literals in the forecast's Settings section; the forecast sentences, the notification copy and the dataset line were not literals at a call site, so the scanner did not see them, but they are just as visible to a zh-Hans user. All of it now goes through `L(...)`, with 29 keys added to both catalogs. Points worth reviewing rather than skimming: - **The English output is byte-identical**, which is why the character-for-character parity checks against `src/reset-forecast.ts` still pass. In `en` the key IS the copy, so `L()` is the identity there. Parity is an `en` property and is documented as one: the CLI is English-only by the catalog's own stated policy. - **No `yes`/`no` key.** The working-hours tail would have made one, and a translator handed "yes" has no sentence to work with. There are two whole-sentence keys instead, differing only in that word. - **No pluralising suffix.** "wait"/"waits" was built with a `\(plural)` hole; that is an English rule. Two keys now. - **The full stop rides the last clause** (`low confidence.`), so no bare "." key exists for anyone to guess at. - **The notification body is one literal, not a `+` chain.** The scanner reads source rather than running it, so a concatenated key is only half-visible to the tooling meant to prove every key is translated - it reported exactly that, and it was right. - **Specifier order is preserved in Chinese.** The first draft read more naturally by putting the elapsed time after the reset it counts from, which silently swaps two `%@` and would have substituted the wrong way round. The zh strings are phrased to keep English argument order. Verified against main's own tooling: `LocalizationSourceScanner` reports zero unrouted literals across all of `mac/Sources`, every key the sources request resolves in the catalog, and the catalog rules the suite checks - matching key sets, English identity, argument-specifier order, literal `%%` counts, no empty values, no key that is only specifiers - all hold. The Settings picker did not move the sections; "Reset Forecast Data" still sits between Notifications and Terminal.
|
Heads-up for review order: the live "since the last reset" clock in this PR reads Codex early-reset events from the #1329 detector, which until now only ever watched Claude. #1339 extends the detector to every provider with quota windows (Claude and Codex announce today; the others are wired but stay silent until their adapters report a window duration). Without #1339 the forecast still works on the global record and on banked-credit events, but its local early-reset input stays dormant for Codex. Merge order that makes the clock fully live: #1339, then this. |
Main's "no catalog entry is dead weight" test reported the two whole-sentence forecast keys as orphaned. They exist in both catalogs; the problem was the call site. The variant was picked into a `let template` by a ternary over strings and then handed to `L(template, ...)`, and the localization scanner reads source rather than running it, so it could not see either string as a literal argument of `L(...)`. Each variant is now written out in full as the first argument of its own `L(...)` call, on either side of the ternary, with the seven arguments hoisted into named locals so the two calls stay identical apart from the key. Specifier order is unchanged, so the catalog entries and the English output are exactly as before. Checked the rest of this branch for the same shape: no other `L(` in the Swift it touches takes a non-literal first argument. Verified by running both directions of main's coverage logic through its own `LocalizationSourceScanner`: zero unrouted user-facing literals, zero catalog keys without a literal call site, zero requested keys without a catalog entry, and the catalog's 656 keys match the 656 the sources request exactly.
|
Closing. 6,200 lines and two parallel implementations for a forecast whose own backtest (Brier 0.1324 against a 0.1288 base rate) does not beat guessing. The always-show-a-range framing is the right instinct. If the model earns real skill on more data, a CLI-only line at a tenth of this size is the shape I would review. |
Summary
codeburn quotanow estimates the chance of an OpenAI Codex usage-limit reset landing soon, and never prints that number without its range. A Codex section readsReset forecast: 24% chance in the next 24h (10 to 51%), 4% in 6h (1 to 14%). 12h since the last global reset; typical wait 2.2d. Working hours in SF: yes., with the numbers also carried structurally underproviders[].resetForecastin--format json. The same two lines appear in the macOS Codex quota hover card and in the Plan tab, beside the pace captions that answer the same question one step nearer.The model is a pure module,
src/reset-forecast.ts. Empirical conditional survival over the inter-reset waits — Kaplan-Meier reduced to its uncensored form — weighting the last 120 days three times as heavily because the cadence sped up (mean wait 201h across the whole record, 116h across the last 120 days), shrunk toward a memoryless rate where the at-risk set thins to one or two waits, ranged by a Wilson interval on the unweighted counts, and tilted by the observed hour-of-day density in San Francisco. In the record shipped here nothing lands between 02:00 and 07:00 Pacific and 95% lands between 07:00 and 23:00, any day; the weekday spread is flat enough that treating weekends differently would describe a pattern the record does not show.The honesty rules are the feature. The prior can tilt a probability but never takes it to exactly zero; no estimate is allowed to reach certainty, because multiplying a hazard by up to 24 otherwise manufactures a 100% that 44 events cannot support; the 24-hour figure is never reported below the six-hour one inside it; a missing or unusable record produces a stated reason rather than a zero; a record older than 14 days is called stale on the line itself; and the wording is "chance", never "expected" and never "will".
confidenceis earned by a walk-forward backtest, and on this record it comes outlow. The backtest rebuilds the model from each prefix, asks it at fixed offsets for the chance the next reset lands within 24 hours, and scores it against what happened and against the same prefix's base rate. Over 220 probes the model scores Brier 0.1324 against the base rate's 0.1288 — conditioning on elapsed time adds nothing across these 44 events. So the shipped label islow, a test asserts exactly that, and a second test proves the label can reachmoderateon a record with a real cadence, so the mechanism is live rather than hard-wired. If a future refresh of the record flips the result, that test fails and somebody looks at the claim instead of quietly upgrading it.The dataset is published from this repo.
src/data/codex-reset-history.jsoncarries 44 resets and 9 banked-credit grants from codex-reset.com's public timeline — a community tracker, not operated by or endorsed by OpenAI — as ids, instants, a type and a reset kind, with every text field dropped before anything is written..github/workflows/refresh-codex-reset-history.ymlpushes it to adata/codex-reset-historybranch every 30 minutes and opens a weekly pull request to keep the copy bundled in releases recent. It never pushes tomain, and has nopushtrigger, so pushing the data branch cannot retrigger it.Two inputs, two freshness stories, and the second one is the one that mattered. The distribution of waits can be days old without harm: 44 waits do not change shape in a week. The last-reset clock cannot, and it was frozen at the last release. It now comes from this machine's own signals — the early-reset detector (menubar: detect early quota resets and say so #1329) and the banked-credit watcher (feat(codex): notify when OpenAI banks a limit reset, and show it everywhere quota is shown #1328) — so a reset that lands here moves the clock within one quota refresh cycle. With neither having fired, it falls back to the dataset's newest reset. On top of that, the dataset itself is refreshed at runtime:
One host —
api.github.com, which the app already contacts for update checks;raw.githubusercontent.comwould have been a new one, so the contents API is used instead. A conditional GET with the stored ETag, so the usual answer is a 304 with no body and one request against the unauthenticated 60-per-hour limit. At most once an hour, on the refresh that already runs, detached so nothing in the UI waits on it, and only while Codex is connected. No credential, no cookie, no account id, no plan, no usage. A fetched record is adopted only if it passes the same validation the workflow applies — on the raw bytes, becauseDecodablewould silently drop atextfield — and is strictly newer than what is held. Offline, rate-limited, malformed or switched off, the record compiled into the build is used exactly as before. The set of hosts this client contacts is unchanged:api.github.com, and nothing else.A macOS notification, opt-in and off by default — the only quota notice here that is, because the others report something that already happened and this one reports a probability. You pick a threshold (default a 50% chance within six hours). It fires at most once per crossing and re-arms only once the estimate falls back under the threshold; the fired state persists the way the subscription snapshots do, so relaunching cannot repeat it; a moved threshold or a new reset starts a new crossing; a failed refresh is no opinion rather than a re-arm; a stale record or a wait already past the end of the record never fires at all. The text says what it is before it says a number — "A statistical estimate from public reset history, not an announcement from OpenAI" — and it carries no button, no link and no side effect. Nothing in this feature spends, redeems, requests or refreshes anything.
The menubar mirrors the model in Swift because it cannot read this off the CLI payload.
codeburn status --format menubar-jsoncarries cost, sessions, providers and pricing but no quota block at all; the menubar's quota comes from its own native adapters. So unlike pricing, the forecast has to be computed there, over a byte-identical copy of the same committed record (SwiftPM resources must live inside the target directory, so the file exists twice and a test pins them together). The two implementations are mirrored constant for constant, and three tests pin the Swift sentences to the literal output of the TypeScript module on the same fixture.Local resets are a seam, not a dependency. When this machine has seen a reset itself — the early-reset detector (menubar: detect early quota resets and say so #1320) or a banked-credit grant (feat(codex): notify when OpenAI banks a limit reset, and show it everywhere quota is shown #1322) — and it is more recent than the public record, "since last reset" counts from yours and the sentence says so. It is an input, so neither PR has to merge first; with no local events the forecast conditions on the global record exactly as it does today. Worth flagging for whoever merges: this adds a Settings section at the same two anchors in
GeneralSettingsTabthat menubar: detect early quota resets and say so #1320 and feat(codex): notify when OpenAI banks a limit reset, and show it everywhere quota is shown #1322 both use, so expect a textual conflict there if more than one lands — the resolution is to keep all the sections.docs/codex-reset-forecast.mdgives the arithmetic, the guards, the provenance, the backtest numbers and a section on what the forecast is not.Refs #725rather thanCloses: that issue is a five-part epic and this is one slice of its surfaces.Testing
npx tsc --noEmitis clean.npx vitest run tests/reset-forecast.test.ts tests/reset-forecast-dataset.test.ts tests/quota-codex-reset-forecast.test.ts tests/quota.test.ts tests/quota-providers.test.ts tests/quota-codex-refresh.test.ts→ 130 passed. 78 of those are new: 47 on the model, 24 on the dataset and the refresh transform, 7 on the command wiring.cd mac && swift build→ Build complete!, exit 0, no warnings from the new files. The first run failed — two staticISO8601DateFormatters tripped StrictConcurrency's non-Sendable global check — and was fixed by building parsers where they are used, the pattern every other subscription service in the target already follows.codex-reset.com/api/timelinehas never been called from this branch, so the exact live response shape is unverified. The normalizer is tolerant about where the upstream puts its fields and strict about what comes out, and the workflow fails loudly rather than committing a bad file.Edge cases covered as their own tests, on both sides: empty history, a single reset, two resets, a stale record, an undated record, clock skew in both directions (
nowbefore the last reset, and a record dated in this machine's future), a wait longer than any in the record, the empty 01:00-to-08:00 Pacific window, the local-event fallbacks (absent, older, future-dated, unparseable), range containment in both directions, the probability ceiling, the recency weighting, the 24h-contains-6h rule, and every threshold-hysteresis case: first crossing, silence above, re-arm below, exactly at the threshold, a new reset, a moved threshold, a failed refresh, a stale record, and a relaunch over a round-tripped state.swift testcannot run on this machine —error: no such module 'Testing', which reproduces on files this branch does not touch and on pristinemain, the same limitation #1320 and #1322 reported.CodexResetForecastTests.swift(52 tests, swift-testing, matching the neighbouring pace-presentation suite) was syntax-checked withswiftc -parse, and the logic was executed instead through a standaloneswiftcharness built from the real source files against small stubs for the app-only dependencies, mirroring every assertion in the suite: 163/163 pass, including the character-for-character parity with the TypeScript output.Mutation-checked: 57 mutations, all 57 caught. The first pass left six survivors, each of which was a real hole and produced an additional test — a clock-skew guard masked by the guard that runs before it, a confidence floor no fixture was small enough to exercise, recency weighting that every flat-cadence fixture was blind to, a probability ceiling pinned on only one of the two sides, staleness tests that used the threshold constant symbolically and so moved with the mutation, and an upward range-widening branch that needed a burst-then-outage record to reach at all. The list covers both range widenings, the Wilson interval, the hour-prior floor, shrinkage, every clock and local-event guard, all five crossing-detector rules, the notice's disclaimer and its range, the render block and its attribution, and eight rules in the refresh script including the one that refuses a response holding fewer resets than the committed file.
Not verified: no notification was delivered and no surface was seen in a running app; the workflow has never executed, and
gh pr createmay be barred org-wide, in which case it falls back to a pushed branch plus a comparison URL in the step summary, the waystar-history.ymlalready does.