diff --git a/CHANGELOG.md b/CHANGELOG.md index 790c155a7..26df8e4e1 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 @@ -58,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/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 } 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