From a182404e31ef5860e986a6d2b3383fd83d5bcf68 Mon Sep 17 00:00:00 2001 From: omartelo Date: Fri, 4 Sep 2026 17:45:37 -0300 Subject: [PATCH 1/2] feat: mark a weekly quota window spent faster than its own clock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 13 +++ docs/ceilings.md | 12 +++ frontend/src/components/QuotaGauge.tsx | 31 +++++- frontend/src/lib/api-types.ts | 3 + internal/quota/pace.go | 87 +++++++++++++++++ internal/quota/pace_test.go | 127 +++++++++++++++++++++++++ internal/quota/quota.go | 8 +- 7 files changed, 276 insertions(+), 5 deletions(-) create mode 100644 internal/quota/pace.go create mode 100644 internal/quota/pace_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 790c155a7..7eb64d5c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `claude setup-token` is one, and so is every provider lich reads no plan from. +- **A weekly quota window now says when it is being spent faster than its own + clock.** 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 + marks the first: when spend runs at least fifteen points past the share of the + window that has elapsed, the window carries a marker, on the footer tooltip and + on the Settings screen alike. It is derived from the reading lich already takes + — no extra request, and no provider reports it — so it appears for every + provider that reports a weekly window, which today is Claude Code and Codex. + Only weekly windows are paced: a five-hour window turns over too fast for a + rate to mean anything. Nothing is marked in the first day after a reset, where + almost any use at all would read as far ahead of a share that is still near + zero. + - **A conflicting pull request now names the files it conflicts on.** GitHub publishes one word — the pull request conflicts with its base — and never which files, so the way to find out was opening the PR on github.com or a diff --git a/docs/ceilings.md b/docs/ceilings.md index fa017299a..077241569 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -478,6 +478,18 @@ work when nobody knows it and that the call site never shows. The mechanism and those logins and never writes them: it does not refresh the token, so an expired one reads as signed out until the provider's own CLI rotates it. A reading is cached for five minutes because both endpoints rate-limit hard — the number on screen is up to that old, and nothing on it says so. +- **The pace marker is borrowed calibration, and it is silent far more often than it is wrong** + (`internal/quota/pace.go`): the two numbers that decide when a weekly window is marked as spending ahead — + fifteen percentage points past the elapsed share, and no marking at all in the first twenty-four hours after + a reset — are the claude-swap project's measurements (`src/claude_swap/pace.py`, its issue #125), not lich's + own. Nothing here has been tuned against a lich user's accounts. The consequence to know before debugging a + marker that "never appears": a window ninety percent spent on the first day of its cycle is deliberately + unmarked, because just after a reset the elapsed share is near zero and almost any use at all would read as + far ahead. Only the weekly window is paced, so the five-hour one and Codex's monthly free-tier window carry + no marker however they are spent. The verdict is derived from the window's own reset time, which is the next + reset and never the start — the start is that time rolled back whole windows — so a provider that stops + reporting a reset time silently drops the marker rather than the gauge. + - **Two more fields of Claude's usage payload are read no further than measuring them** (`internal/quota/claude.go`, `limits[].severity` and the top-level `extra_usage`/`spend` blocks): every `severity` observed on a live account reads `"normal"`, so its scale — what a non-normal value looks like, diff --git a/frontend/src/components/QuotaGauge.tsx b/frontend/src/components/QuotaGauge.tsx index 65ed3fb64..b2aae0d2f 100644 --- a/frontend/src/components/QuotaGauge.tsx +++ b/frontend/src/components/QuotaGauge.tsx @@ -1,3 +1,4 @@ +import { TrendingUp } from "lucide-react" import type { QuotaWindow } from "@/lib/api-types" import { formatWindow, timeLeft } from "@/lib/quota/quota-format" import { cn } from "@/lib/utils" @@ -31,7 +32,12 @@ interface QuotaGaugeProps { export function QuotaGauge({ window: quota, now, stacked }: QuotaGaugeProps) { const length = formatWindow(quota.seconds) const left = timeLeft(quota.resetsAt, now) - const label = length ? `${quota.label} · ${length}` : quota.label + const label = ( + <> + {length ? `${quota.label} · ${length}` : quota.label} + {quota.ahead && } + + ) const bar = (
- {label} + {label} {reading} {left && ` · ${left}`} @@ -62,7 +68,7 @@ export function QuotaGauge({ window: quota, now, stacked }: QuotaGaugeProps) { } return (
- {label} + {label} {bar} {reading} {left} @@ -70,6 +76,25 @@ export function QuotaGauge({ window: quota, now, stacked }: QuotaGaugeProps) { ) } +// AheadMark is the pace marker: this weekly window is being spent faster than +// its own clock runs. It sits in the label column rather than beside the +// percentage because the two numeric columns are fixed-width so that stacked +// gauges end in the same place, and it carries no colour of its own — the row +// already wears usageColor, and a hue here would say something the ramp does +// not. +function AheadMark() { + return ( + + }> + + + + Spending ahead of this window's pace. + + + ) +} + // LockedReading replaces the bare percentage when the provider says a window // is locked regardless of how full it reads — the percentage alone would look // like headroom that spending cannot actually reach. The reason travels to a diff --git a/frontend/src/lib/api-types.ts b/frontend/src/lib/api-types.ts index 27e703411..30c58d9bb 100644 --- a/frontend/src/lib/api-types.ts +++ b/frontend/src/lib/api-types.ts @@ -494,6 +494,9 @@ export interface QuotaWindow { /** Present only when the provider says this window is locked regardless of * its percentage; the reason travels to the tooltip verbatim. */ lockedReason?: string + /** lich's own reading, not the provider's: this weekly window is being spent + * faster than its own clock runs. Only weekly windows carry it. */ + ahead?: boolean } /** internal/quota.Plan — one provider's quota reading. Windows are empty for diff --git a/internal/quota/pace.go b/internal/quota/pace.go new file mode 100644 index 000000000..4330d6f30 --- /dev/null +++ b/internal/quota/pace.go @@ -0,0 +1,87 @@ +package quota + +import "time" + +// Pace is what a weekly window's percentage cannot say on its own: a window 40% +// spent on day two and one 40% spent on day six draw exactly the same bar, and +// only one of them is going to run out. Ahead marks the first — spend running +// far enough past the share of the window that has already elapsed that the +// rate, not the number, is the thing worth seeing. +const ( + // aheadPoints is how far past the elapsed share spend has to run before the + // window is marked. Anything tighter marks an ordinary heavy afternoon in an + // otherwise light week, and a marker that is on most of the time is a marker + // nobody reads. + aheadPoints = 15 + // aheadGrace is how long after a reset no window is marked. Just after one, + // the elapsed share is near zero and expected spend with it, so almost any + // use at all reads as far ahead — a false positive, not a warning. An + // `elapsed == 0` guard does not cover this: a reading taken an hour into a + // fresh window has a small elapsed, not a zero one. + aheadGrace = 24 * time.Hour +) + +// markAhead marks w when its spend runs ahead of the window's own clock. Pure: +// `now` is a parameter so the verdict is testable and no reading depends on a +// clock read somewhere else. +// +// Only the weekly window is paced — the account-wide one and the model-scoped +// caps alike, which are weekly too. The five-hour window is left out on purpose: +// it turns over too fast for a rate to mean anything, since 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. +// +// The 15-point threshold and the 24-hour grace are calibration measured by the +// claude-swap project (`src/claude_swap/pace.py`, its issue #125). The numbers +// are what is borrowed from there; the code is lich's own. +func markAhead(w Window, now time.Time) Window { + if w.Seconds != weeklyWindow { + return w + } + start, ok := windowStart(w, now) + if !ok { + return w + } + elapsed := now.Sub(start) + if elapsed < aheadGrace { + return w + } + expected := elapsed.Seconds() / float64(w.Seconds) * 100 + w.Ahead = float64(w.Percent)-expected >= aheadPoints + return w +} + +// windowStart derives when the current cycle began. ResetsAt is the only +// timestamp either provider reports and it is the *next* reset, never the start, +// so the start is that reset rolled back whole windows until it lands at or +// before now — which stays right however many cycles ahead a reset time sits, +// rather than assuming it is exactly one. +// +// False for a window with no parseable reset or no length: with no cycle to +// place, there is no pace to read, and an unmarked window is the honest answer. +func windowStart(w Window, now time.Time) (time.Time, bool) { + if w.Seconds <= 0 { + return time.Time{}, false + } + reset, err := time.Parse(time.RFC3339, w.ResetsAt) + if err != nil { + return time.Time{}, false + } + period := time.Duration(w.Seconds) * time.Second + cycles := int64(1) + if until := reset.Sub(now); until > period { + cycles = int64((until + period - 1) / period) + } + return reset.Add(-time.Duration(cycles) * period), true +} + +// pace marks every window of a reading. One call site for every provider, +// because the verdict is derived from a Window alone: a provider that starts +// reporting a weekly window is paced without knowing this exists. +func pace(plans []Plan, now time.Time) { + for _, p := range plans { + for i, w := range p.Windows { + p.Windows[i] = markAhead(w, now) + } + } +} diff --git a/internal/quota/pace_test.go b/internal/quota/pace_test.go new file mode 100644 index 000000000..99a7ab1e6 --- /dev/null +++ b/internal/quota/pace_test.go @@ -0,0 +1,127 @@ +package quota + +import ( + "testing" + "time" +) + +// paceNow is the clock every case here is written against; markAhead takes it +// as a parameter, which is the whole reason none of this needs a real one. +var paceNow = time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC) + +// resetIn spells a window's next reset as the providers report it: RFC 3339, +// that many seconds after paceNow. +func resetIn(seconds int) string { + return paceNow.Add(time.Duration(seconds) * time.Second).Format(time.RFC3339) +} + +// The boundaries are pinned as literals on purpose, and every case sits one +// step either side of one: a test that reads aheadPoints or aheadGrace keeps +// passing after someone edits them, which is the exact change it exists to +// catch. 604800 is a week, 302400 half of one (so the expected share is a flat +// 50%), and 86400 the grace. +func TestMarkAhead(t *testing.T) { + tests := []struct { + name string + window Window + want bool + }{{ + name: "weekly at half its clock, one point under the threshold", + window: Window{Seconds: 604800, Percent: 64, ResetsAt: resetIn(302400)}, + want: false, + }, { + name: "weekly at half its clock, exactly on the threshold", + window: Window{Seconds: 604800, Percent: 65, ResetsAt: resetIn(302400)}, + want: true, + }, { + name: "weekly at half its clock, one point over the threshold", + window: Window{Seconds: 604800, Percent: 66, ResetsAt: resetIn(302400)}, + want: true, + }, { + name: "weekly spending under its own clock", + window: Window{Seconds: 604800, Percent: 40, ResetsAt: resetIn(302400)}, + want: false, + }, { + name: "a second short of the grace, spent to the top", + window: Window{Seconds: 604800, Percent: 100, ResetsAt: resetIn(604800 - 86399)}, + want: false, + }, { + name: "the grace exactly elapsed, spent to the top", + window: Window{Seconds: 604800, Percent: 100, ResetsAt: resetIn(604800 - 86400)}, + want: true, + }, { + name: "the five-hour window is never paced", + window: Window{Seconds: 18000, Percent: 100, ResetsAt: resetIn(3600)}, + want: false, + }, { + name: "the free tier's monthly window is not weekly, so it is not paced", + // 2592000 is Codex's 30-day window: past the grace, and half spent at + // half its clock, so only the weekly-only guard keeps it unmarked. + window: Window{Seconds: 2592000, Percent: 90, ResetsAt: resetIn(1296000)}, + want: false, + }, { + name: "a model-scoped weekly cap is paced like the account-wide one", + window: Window{Label: "Fable", Seconds: 604800, Percent: 65, ResetsAt: resetIn(302400)}, + want: true, + }, { + name: "a reset three cycles further out still places the current one", + window: Window{Seconds: 604800, Percent: 65, ResetsAt: resetIn(302400 + 3*604800)}, + want: true, + }, { + name: "a reset already in the past marks nothing", + window: Window{Seconds: 604800, Percent: 100, ResetsAt: resetIn(-3600)}, + want: false, + }, { + name: "no reset time, nothing to pace against", + window: Window{Seconds: 604800, Percent: 100}, + want: false, + }, { + name: "an unparseable reset time marks nothing", + window: Window{Seconds: 604800, Percent: 100, ResetsAt: "next tuesday"}, + want: false, + }, { + name: "a window whose length the provider never reported", + window: Window{Percent: 100, ResetsAt: resetIn(302400)}, + want: false, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := markAhead(tt.window, paceNow) + if got.Ahead != tt.want { + t.Errorf("Ahead = %v, want %v", got.Ahead, tt.want) + } + got.Ahead = tt.window.Ahead + if got != tt.window { + t.Errorf("markAhead changed more than the mark: %+v, want %+v", got, tt.window) + } + }) + } +} + +func TestPaceMarksEveryWindowOfEveryPlan(t *testing.T) { + plans := []Plan{{ + Provider: "claude", + Windows: []Window{ + {Label: "Session", Seconds: 18000, Percent: 90, ResetsAt: resetIn(3600)}, + {Label: "Weekly", Seconds: 604800, Percent: 66, ResetsAt: resetIn(302400)}, + }, + }, { + Provider: "codex", + Windows: []Window{ + {Label: "Weekly", Seconds: 604800, Percent: 40, ResetsAt: resetIn(302400)}, + }, + }} + pace(plans, paceNow) + + want := []bool{false, true, false} + got := []bool{ + plans[0].Windows[0].Ahead, + plans[0].Windows[1].Ahead, + plans[1].Windows[0].Ahead, + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("marks = %v, want %v", got, want) + } + } +} diff --git a/internal/quota/quota.go b/internal/quota/quota.go index ad6a580a9..8b50288a5 100644 --- a/internal/quota/quota.go +++ b/internal/quota/quota.go @@ -109,7 +109,8 @@ var accountVars = append([]string{ // provider does not report one. ResetsAt is RFC 3339, empty when unreported. // Active is the provider's own verdict on which window is the binding one, when // it reports one at all. LockedReason is non-empty only when the provider says -// this window cannot be spent past regardless of its percentage. +// this window cannot be spent past regardless of its percentage. Ahead is +// lich's own reading of the rate rather than the provider's — see pace.go. type Window struct { Label string `json:"label"` Seconds int `json:"seconds"` @@ -117,6 +118,7 @@ type Window struct { ResetsAt string `json:"resetsAt,omitempty"` Active bool `json:"active,omitempty"` LockedReason string `json:"lockedReason,omitempty"` + Ahead bool `json:"ahead,omitempty"` } // Plan is one provider's quota reading. Provider is a providers.Registry id, so @@ -250,7 +252,9 @@ func (s *Service) Plans(sessionID string) []Plan { return cached.plans } plans := []Plan{s.claudePlan(account), s.codexPlan(account)} - s.cache[key] = reading{plans: plans, at: s.now()} + at := s.now() + pace(plans, at) + s.cache[key] = reading{plans: plans, at: at} return plans } From bc6b57e14c16549b64ec767641ba2518cae6e800 Mon Sep 17 00:00:00 2001 From: omartelo Date: Fri, 4 Sep 2026 19:33:01 -0300 Subject: [PATCH 2/2] fix: order errands by delivery, not by a clock that cannot tell them apart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 13 +++++++++++++ internal/relay/relay.go | 30 +++++++++++++++++++++--------- internal/relay/relay_test.go | 30 ++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7eb64d5c5..26df8e4e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 prompt is written once its agent is at a prompt to take it, rather than into a setup script still installing the checkout. The Enter stays yours. +### Fixed + +- **On Windows, an answer that named no ticket could close the wrong request.** + `lich reply ""` and `reply_to_session` without a ticket close the + oldest request delivered to that session — but "oldest" was read off the + clock, and Windows' only moves every ~15.6ms, so two requests handed to one + session inside a single tick carried the same timestamp and the tie was broken + at random. Roughly one time in ten, with two errands open, the answer went + home to the wrong sender, and both senders read a confident wrong report. + Delivery order is now counted rather than timed, so no clock's resolution can + flatten it. Linux and macOS have a fine enough clock that this was never + reachable there. + ## [0.44.0] - 2026-09-03 ### Added diff --git a/internal/relay/relay.go b/internal/relay/relay.go index 700930fdf..394abeaf3 100644 --- a/internal/relay/relay.go +++ b/internal/relay/relay.go @@ -241,12 +241,19 @@ type ticket struct { created time.Time // delivered is when the message started going into the target's PTY — zero // while the ticket is still waiting out the target's setup script. Turn - // accounting reads it twice over: a turn observed on the target says nothing - // about an undelivered message, and when one turn ends with several errands - // open, the oldest delivery is the one that turn belongs to. + // accounting reads it to tell an undelivered message apart from a live one; + // which of two live ones came first is deliverySeq's question, never this + // timestamp's. delivered time.Time - done chan struct{} - answer string + // deliverySeq is the order this ticket was handed off in, counting from one. + // Two errands can share a delivered timestamp — Windows' clock only moves + // every ~15.6ms, and two messages land inside one tick easily — and "the + // oldest delivery" then fell to Go's randomised map iteration, closing the + // wrong errand about one time in ten. A counter is the order itself rather + // than a reading of it, so no clock's resolution can flatten it. + deliverySeq uint64 + done chan struct{} + answer string // attended is how many callers are blocked on this ticket right now. An // answer that lands while nobody is waiting has nowhere to be returned, so @@ -290,6 +297,8 @@ type Service struct { // outside s.mu. See announceInbox. announceMu sync.Mutex tickets map[string]*ticket + // deliveries counts hand-offs, and stamps each ticket's deliverySeq. + deliveries uint64 // state is the last thing each session reported, so a delivery knows whether // it is landing in the middle of a turn. Only sessions the relay has heard // about appear; an unknown one is treated as not working, which is what a @@ -493,6 +502,8 @@ func (s *Service) handOff(id string, t *ticket, kind, message string) error { // on nothing; its done, and the skip meant for the turn already running is // spent on the turn that carries the answer. t.delivered = s.now() + s.deliveries++ + t.deliverySeq = s.deliveries s.mu.Unlock() if err := s.deliver(t.targetID, message); err != nil { @@ -824,8 +835,9 @@ func (s *Service) Reply(replierID, ticketID, answer string) error { } // errandOfLocked is the errand an answer that named no ticket belongs to: the -// oldest message actually delivered to this session that is still open. Called -// under s.mu. +// first message actually delivered to this session that is still open — first +// by hand-off order (deliverySeq), which is the order itself and not a clock +// reading of it. Called under s.mu. // // Oldest-delivered is the same rule turnErrand closes a turn by, and for the // same reason: every provider queues typed input, so a session handed several @@ -845,7 +857,7 @@ func (s *Service) errandOfLocked(replierID string) (string, error) { if t.targetID != replierID || t.delivered.IsZero() { continue } - if oldest == nil || t.delivered.Before(oldest.delivered) { + if oldest == nil || t.deliverySeq < oldest.deliverySeq { oldestID, oldest = id, t } } @@ -1090,7 +1102,7 @@ func (s *Service) turnErrand(sessionID string) (string, *ticket) { if !t.sawBusy { continue } - if oldest == nil || t.delivered.Before(oldest.delivered) { + if oldest == nil || t.deliverySeq < oldest.deliverySeq { oldestID, oldest = id, t } } diff --git a/internal/relay/relay_test.go b/internal/relay/relay_test.go index 438d48c33..1d06c8152 100644 --- a/internal/relay/relay_test.go +++ b/internal/relay/relay_test.go @@ -1434,6 +1434,36 @@ func TestReplyWithoutATicketAnswersTheOldestErrandDelivered(t *testing.T) { } } +// TestErrandOrderSurvivesAClockThatCannotTellThemApart pins the tie the +// timestamp could not break. Windows' clock only moves every ~15.6ms, so two +// hand-offs inside one tick carry the same delivered time; ordering on that +// timestamp then fell through to Go's randomised map iteration and closed the +// wrong errand about one run in ten on the Windows runner. +// +// The two tickets here share a delivered time exactly, which is the case the +// runner hits by accident: any ordering that reads the clock has nothing left +// to choose by, and only the hand-off counter still answers. +func TestErrandOrderSurvivesAClockThatCannotTellThemApart(t *testing.T) { + svc := newRelay(workspace(), newFakeTerminal("s1", "s2", "s3"), &fakeEvents{}) + tick := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC) + svc.tickets = map[string]*ticket{ + "second": {fromID: "s3", targetID: "s2", delivered: tick, deliverySeq: 2}, + "first": {fromID: "s1", targetID: "s2", delivered: tick, deliverySeq: 1}, + } + + // Run it more than once: one pass can pick the right entry out of a map by + // luck, and luck is the thing being tested away. + for i := 0; i < 50; i++ { + got, err := svc.errandOfLocked("s2") + if err != nil { + t.Fatalf("errandOfLocked: %v", err) + } + if got != "first" { + t.Fatalf("errand = %q, want the first delivered", got) + } + } +} + // TestReplyWithoutATicketNeedsAnErrandItCanName proves the guess is refused // rather than made badly: a caller with no session of its own has nothing to // look the errand up by, a session nobody asked anything has none, and a task