Skip to content

feat: mark a weekly quota window spent faster than its own clock - #451

Merged
omartelo merged 2 commits into
mainfrom
feat/weekly-pace-marker
Sep 4, 2026
Merged

omartelo merged 2 commits into
mainfrom
feat/weekly-pace-marker

Conversation

@omartelo

@omartelo omartelo commented Sep 4, 2026 •

Copy link
Copy Markdown
Collaborator

Forty percent of a week gone on day two and forty percent gone on day six draw
exactly the same bar today, and only one of them runs out early. This adds the
one thing the gauge was missing to tell them apart.

What lands

  • internal/quota/pace.go — markAhead(Window, time.Time) Window, pure, no I/O
    and no clock of its own. It places the current cycle by rolling ResetsAt —
    the next reset, the only timestamp either API gives — back whole windows
    until it lands at or before now, which stays right however many cycles that
    reset sits ahead. Ahead is true when percent - elapsed/period*100 >= 15.
  • Window.Ahead bool \json:"ahead,omitempty"`on the struct, mirrored by hand infrontend/src/lib/api-types.ts(no codegen — rootCLAUDE.md`).
  • QuotaGauge.tsx draws it as a lucide trending-up at size-3 after the
    window's name, with a tooltip. It sits in the label column so the two
    fixed-width numeric columns stay untouched and three stacked gauges keep
    ending in the same place, and it carries no colour of its own — the row
    already wears usageColor. The owner picked this over the two alternatives
    from a two-theme mockup.
  • docs/ceilings.md and a CHANGELOG.md [Unreleased] entry.

Only weekly, and silent for a day

The five-hour window is out on purpose: it turns over too fast for a rate to
mean anything — the first spend in a fresh window is "ahead" almost by
definition — and the package's five-minute cache is a large fraction of five
hours while it is nothing against a week. Model-scoped caps are weekly and are
paced like the account-wide one. Codex's monthly free-tier window is not weekly
and is never marked.

Nothing is marked in the first 24 hours after a reset: the elapsed share is near
zero there and almost any use at all reads as far ahead. An elapsed == 0 guard
does not cover that — a reading taken an hour in has a small elapsed, not a zero
one.

The three constants are borrowed, and the diff says so

15 points, 24 hours, weekly-only: all three are calibration measured by the
claude-swap project (src/claude_swap/pace.py, its issue #125). That
calibration is the only thing taken from there; the code is ours. Each constant
carries the why in its comment, and docs/ceilings.md records that nothing
here has been tuned against a lich user's own accounts — plus the trap it sets:
a window 90% spent on day one is deliberately unmarked.

Invariant 5 — every provider

This is pure derivation over a Window that already exists, applied once in
Service.Plans rather than per provider, so it holds for anything reporting a
weekly window without knowing this code exists. That is Claude Code (weekly_all
and weekly_scoped, both weeklyWindow) and Codex (secondary_window, which
reports limit_window_seconds: 604800 — confirmed against the fixture in
quota_test.go, so no ceiling had to be written for a shape that didn't match).
The other six providers have no plan gauge at all, for the reason already in
docs/ceilings.md: they run on the user's own API keys and there is no plan to
report. No gap to record.

Tests

internal/quota/pace_test.go pins each boundary as a literal and mutates
N±1 around it — a test that reads aheadPoints keeps passing after someone
edits aheadPoints, which is the exact change it exists to catch. Covered:
weekly ahead (64/65/66 against a flat 50% expected), weekly on pace, one second
short of the grace vs. the grace exactly elapsed, the five-hour window, the
monthly window (the case that actually pins the weekly-only guard — the grace
alone already excludes the 5h one), a model-scoped cap, a reset three cycles
further out, a reset in the past, an absent reset, an unparseable one, and a
window with no reported length. Each case also asserts markAhead changed
nothing but the mark. All three constants were mutation-checked: each one moved
turns the suite red.

Gate

gofmt -l . clean · go vet ./... clean · go test ./... green (31 packages) ·
biome check . clean (17 pre-existing a11y warnings, none new) ·
vitest run green (114 files / 1368 tests, cache cleared first) · tsc -b and
vite build clean · GOOS=linux|darwin|windows build + vet clean.

The render path is not covered by the node-environment suite; AheadMark is the
same tooltip-inside-tooltip composition LockedReading already ships in this
file.

A second commit, and it is not this feature

os-tests (windows-latest) went red on this branch in internal/relay, a
package this feature does not touch (git diff main -- internal/relay was empty
at that point). It is a real product bug, reproduced and fixed here rather than
re-run:

errandOfLocked picks "the oldest message delivered to this session" by
comparing time.Time. Windows' clock only moves every ~15.6ms, so two hand-offs
inside one tick carry the same timestamp — Before is false both ways and the
winner falls to Go's randomised map iteration. turnErrand decides which errand
a finished turn belongs to by the same comparison. So on Windows, with two
errands open, lich reply "<answer>" could send the answer home to the wrong
sender and both senders would read a confident wrong report.

Hand-offs are now counted; deliverySeq is the order itself rather than a
reading of it. Reproduced on Linux by truncating the service's clock to 16ms:
TestReplyWithoutATicketAnswersTheOldestErrandDelivered failed 22/200
before and 0/200 after. The permanent guard drops the clock question
entirely — two tickets sharing a delivered timestamp exactly, resolved 50
times over — which is red on the timestamp comparison and green on the counter.
Gate re-run whole after it, cross-compile loop included.

Happy to split this into its own PR off main if you would rather review it
apart from the feature — say the word and I will move it.

https://claude.ai/code/session_01EtriuLefbWGY9wRt4HbkQc

Forty percent of a week gone on day two and forty percent gone on day six
draw exactly the same bar, and only one of them runs out early. The gauge
had no way to tell them apart.

Window.Ahead is derived from the reading lich already takes — no extra
request, and neither provider reports it. The window's start is its next
reset rolled back whole windows until it lands at or before now, which is
the only way to place a cycle from the one timestamp the APIs give, and
stays right however many cycles that reset sits ahead.

Only weekly windows are paced, the account-wide one and the model-scoped
caps alike. The five-hour one is out on purpose: it turns over too fast
for a rate to mean anything, and the package's five-minute cache is a
large fraction of five hours while it is nothing against a week. Nothing
is marked in the first day after a reset, where the elapsed share is near
zero and almost any use at all would read as far ahead.

The fifteen-point threshold and the twenty-four-hour grace are the
claude-swap project's measurements, which is the one thing borrowed from
there; docs/ceilings.md says so, and says what a marker that never
appears most likely means.

Claude-Session: https://claude.ai/code/session_01EtriuLefbWGY9wRt4HbkQc
…apart

An answer that names no ticket closes the oldest message delivered to that
session. "Oldest" was read off time.Time, and on Windows the clock only
moves every ~15.6ms — two hand-offs inside one tick carry the same
timestamp, Before() is false both ways, and the winner falls to Go's
randomised map iteration. About one run in ten, the answer went home to
the wrong sender and both senders read a confident wrong report.

The bug is the product's, not the test's: the same tie decides which
errand a finished turn belongs to (turnErrand), so a Windows user with
two errands open could have either one closed.

Hand-offs are now counted. deliverySeq is the order itself rather than a
reading of it, so no clock's resolution can flatten it, and both places
that mean "oldest delivered" compare it.

Measured with the service's clock truncated to 16ms, which is what the
Windows runner has: TestReplyWithoutATicketAnswersTheOldestErrandDelivered
failed 22 times in 200 before, 0 in 200 after. The new test drops the
clock question entirely — two tickets sharing a delivered timestamp
exactly, resolved 50 times over, which is red on the timestamp comparison
and green on the counter.

Claude-Session: https://claude.ai/code/session_01EtriuLefbWGY9wRt4HbkQc
@omartelo
omartelo force-pushed the feat/weekly-pace-marker branch from 8a9d5fc to bc6b57e Compare September 4, 2026 22:35
@omartelo
omartelo merged commit 4cb7d79 into main Sep 4, 2026
4 checks passed
@omartelo
omartelo deleted the feat/weekly-pace-marker branch September 4, 2026 22:41
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