244 lines
16 KiB
Markdown
244 lines
16 KiB
Markdown
# Usage monitoring, gate & throttle
|
||
|
||
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
|
||
> Last verified against commit `bf56cd8c` (2026-08-24).
|
||
> Drift check: `git log --oneline bf56cd8c..HEAD -- src/ClaudeDo.Worker/Usage src/ClaudeDo.Worker/Queue src/ClaudeDo.Ui/ViewModels/UsagePillViewModel.cs`
|
||
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
|
||
|
||
Covers `src/ClaudeDo.Worker/Usage/`, the queue's throttle/gate integration, per-run token
|
||
accounting, and the UI surfaces (usage pill + usage monitor modal).
|
||
|
||
**Two independent data sources, on purpose.** Limits/gate/throttle come from the OAuth poll
|
||
below; the *analytics* (per-model and per-task breakdown, cost) come from the external
|
||
TokenTracker CLI. Neither one can break the other.
|
||
|
||
## Data source (limits, gate, throttle)
|
||
|
||
`GET https://api.anthropic.com/api/oauth/usage` — an **undocumented** Anthropic endpoint,
|
||
authenticated with the Bearer access token Claude Code itself keeps fresh at
|
||
`~/.claude/.credentials.json`. ClaudeDo reads that token, never refreshes it, never logs it.
|
||
|
||
Because the endpoint is undocumented and can change without notice, **every consumer
|
||
fails open**. That is the single most important invariant here.
|
||
|
||
## Components (`Usage/`)
|
||
|
||
| Type | Role |
|
||
|---|---|
|
||
| `UsageModels` | `UsageBucket` / `UsageLimitRow` / `UsageSnapshot`. `UsageBucket.Utilization` is already a 0–100 percent — compare directly against thresholds, don't rescale. |
|
||
| `ClaudeOAuthUsageClient` | Reads the token, calls the endpoint. Defensive parsing: missing/null buckets → null, missing `limits` → empty list. |
|
||
| `UsageState` | Threadsafe singleton. A failed poll **never** overwrites the last good snapshot — it only sets `LastError`. |
|
||
| `UsageMonitorService` | `BackgroundService`; polls on `usage_poll_interval_seconds` (default 60, clamped to min 15 on config load), one poll at startup. Logs a failure at most once per distinct error message. Broadcasts `HubBroadcaster.UsageUpdated` after **every** tick, success or failure. |
|
||
| `UsageSnapshotBuilder` | Builds the Hub-facing `UsageSnapshotDto` from `UsageState` + `IUsageGate` + `AppSettings`. The one shared place for stale/threshold/gate logic — `WorkerHub.GetUsageSnapshot` and `UsageMonitorService` must not diverge. |
|
||
| `UsageGate` | Hard pause decision → `UsageGateDecision(IsBlocked, Reason)`. |
|
||
| `UsageThrottle` | Pure static staging of parallelism ahead of the gate. |
|
||
| `TranscriptUsageReader` | Reads **one** session's cumulative totals for per-run accounting. **No** aggregation — the date/model/scope roll-up moved to `TokenTracker/`. |
|
||
| `TokenTracker/` | The external analytics backend: cost + per-model/per-task breakdown. Own section below. |
|
||
|
||
Interfaces in `Usage/Interfaces/`: `IUsageClient`, `ITranscriptUsageReader`, `IUsageGate`;
|
||
plus `Usage/TokenTracker/Interfaces/ITokenTrackerClient`.
|
||
|
||
## The gate (hard pause)
|
||
|
||
Thresholds: `AppSettings.UsageGateFiveHourPct` / `UsageGateSevenDayPct` (defaults 80/90).
|
||
Blocked once `five_hour >= UsageGateFiveHourPct` **or** `seven_day >= UsageGateSevenDayPct`
|
||
(`>=`, not `>`). Threshold `0` = that bucket never gates.
|
||
|
||
What it pauses: **only the queue's slot-fill loop** — new queued tasks don't start.
|
||
Unaffected: already-running runs, `RunNow`, `ContinueTask`, interactive ConPTY sessions,
|
||
planning sessions, daily prep (all bypass the queue).
|
||
|
||
**Fail-open**: no snapshot yet, a failed last poll, or an app-settings read error all
|
||
resolve to not-blocked.
|
||
|
||
There is **no persistent pause state**. Recovery is just the queue's 30 s backstop timer
|
||
(`queue_backstop_interval_ms`) re-evaluating the gate on its own once usage drops back
|
||
under the threshold. A blocked↔free transition is logged and broadcast (`WorkerLog`, Warn
|
||
on block / Info on resume) exactly **once per change**, not every tick.
|
||
|
||
## The throttle (staged parallelism)
|
||
|
||
`UsageThrottle.EffectiveSlots(configuredSlots, fiveHourPct, fiveHourThresholds, sevenDayPct,
|
||
sevenDayThresholds)` — pure static, no state. `UsageThresholds(SoftPct, HardPct, GatePct)` is the
|
||
per-bucket triple (same file).
|
||
|
||
Thresholds are **per bucket** (`usage_throttle_five_hour_{soft,hard}_pct` /
|
||
`usage_throttle_seven_day_{soft,hard}_pct`, defaults 50/65 each) because the 5h and 7d windows fill
|
||
at very different rates. Each bucket is staged independently and the **strictest** bucket wins —
|
||
not "whichever is more utilized", so a bucket that is lower but tightly configured can be the one
|
||
that throttles:
|
||
|
||
| Utilization (per bucket) | That bucket's slots |
|
||
|---|---|
|
||
| below soft | full configured `max_parallel_executions` |
|
||
| `>= softPct` | capped at 2 |
|
||
| `>= hardPct` | capped at 1 |
|
||
| `>= gatePct` | 0 — same hard block as `UsageGate` |
|
||
|
||
A threshold of `0` disables that stage for that bucket, and a bucket with no reading (null) never
|
||
throttles. The `0` return is deliberately kept in sync with `UsageGate`'s hard block because both
|
||
read the same gate thresholds — change one, change both.
|
||
|
||
Only **new** slot fills are affected; a run already occupying a slot when the stage tightens
|
||
runs to completion. Same fail-open policy: no snapshot means no throttling.
|
||
|
||
The effective stage (configured vs. effective slots + the decisive bucket, `ThrottleBucket`
|
||
= `"five_hour"` / `"seven_day"`) rides along on `UsageSnapshotDto` purely for UI display.
|
||
It does **not** change what the gate gates on.
|
||
|
||
## Queue integration (`Queue/QueueService`)
|
||
|
||
Per loop tick:
|
||
|
||
1. `GetEffectiveMaxParallelAsync` reads `AppSettings.MaxParallelExecutions` and steps it
|
||
down via `UsageThrottle.EffectiveSlots` against the current `UsageState` snapshot.
|
||
A missing/failed snapshot fails open to the configured value. A **stage change** (not
|
||
every tick) logs once.
|
||
2. Separately, `IUsageGate.EvaluateAsync` — if blocked, the slot-fill loop is skipped
|
||
entirely for that tick.
|
||
|
||
## Per-run token accounting
|
||
|
||
`task_runs` stores four raw token fields: `tokens_in` / `tokens_out` /
|
||
`cache_read_tokens` / `cache_write_tokens`.
|
||
|
||
These are **not** read from the stream-json `result` event's `usage.input_tokens` — that is
|
||
only the uncached remainder of a single API call and undercounts the real prompt size by
|
||
orders of magnitude once caching kicks in.
|
||
|
||
Instead `TaskRunner.ApplyUsageAsync` calls
|
||
`ITranscriptUsageReader.ReadSessionTotalsAsync(sessionId)` — the session transcript's
|
||
cumulative raw totals across every assistant message, located by `{sessionId}.jsonl` — and
|
||
stores the **delta** against prior `task_runs` rows sharing the same `session_id`, so a
|
||
`--resume`'d run doesn't double-count turns already billed to an earlier run.
|
||
|
||
A missing/unreadable transcript leaves all four fields `null`; it never fails the run.
|
||
|
||
`ReadSessionTotalsAsync` **stays** — it is the per-run source of truth and is deliberately *not*
|
||
routed through TokenTracker, whose export has no per-run granularity (it aggregates a whole
|
||
session, which a `--resume`'d task spreads over several runs).
|
||
|
||
### `TranscriptUsageReader` details
|
||
|
||
Locates a single `{sessionId}.jsonl` under `~/.claude/projects/**`, sums the raw `usage` fields of
|
||
every assistant message, deduped by `requestId` (falling back to `message.id`), with a per-file
|
||
length+mtime cache. `<synthetic>`-model lines are skipped — they are not real API calls.
|
||
|
||
There is no history-wide `ReadAsync` any more; `WorkerConfig` is still a constructor parameter
|
||
purely so the DI registration and call sites stayed untouched.
|
||
|
||
## Analytics via TokenTracker
|
||
|
||
The per-model / per-task / cost view comes from **TokenTracker**, an external MIT-licensed CLI
|
||
(`tokentracker-cli`, npm) that reads the same local Claude transcripts. Everything lives in
|
||
`Usage/TokenTracker/`; the pure parts (`SessionHash`, `TokenTrackerArgs`,
|
||
`TokenTrackerExportParser`, `TokenTrackerAggregator`) have no I/O and are unit-tested, only
|
||
`TokenTrackerClient` (processes) and `TokenTrackerService` (cache coordination) touch the world.
|
||
|
||
- **Invocation:** `tokentracker sessions --from <d> --to <d> --no-git --format json`
|
||
(`TokenTrackerArgs`). `--no-git` matters — without it TokenTracker runs `git log` inside every
|
||
session's working directory. We **never** call `tokentracker init`: that would write hooks into
|
||
the user's global `~/.claude/settings.json` and switch on cloud sync. Install is an explicit
|
||
`npm i -g tokentracker-cli`, never `npx`. Resolution goes through the shared
|
||
`ExecutableResolver` because an npm CLI is a `.cmd` shim.
|
||
- **The join:** the export never carries a raw session id, only `session_hash` =
|
||
`sha256("claude\0" + sessionId)` hex, first 24 chars (`SessionHash.ForClaudeSession`,
|
||
mirroring TokenTracker's `lib/session-analytics.js`). That hash set — built from
|
||
`task_runs.session_id` — is also what decides the **ClaudeDo vs. other** scope split. Note this
|
||
is *narrower* than the old cwd-based split: an interactive or planning session has no
|
||
`task_runs` row and therefore counts as "other".
|
||
- **One wide fetch, local filtering:** `TokenTrackerService.WindowDays` = 90. TokenTracker's
|
||
`buildSessionAnalytics` walks the whole history regardless of `--from`/`--to` (v0.88.4 returns
|
||
the same rows either way), so narrowing the request buys nothing while making every range switch
|
||
pay again. Range switching is then pure in-memory filtering in `TokenTrackerAggregator`.
|
||
- **Freshness:** `EnsureFreshAsync(maxAge)`. No export at all → the caller awaits the fetch;
|
||
an export older than **15 minutes** → served immediately while a refresh runs behind the
|
||
caller's back. `RefreshAsync` is single-flight (a second caller waits for the in-flight run
|
||
instead of spawning another process). The probe (CLI present? Node ≥ 20?) is cached until an
|
||
install forces it.
|
||
- **Version check:** the export's `version` field is compared against
|
||
`TokenTrackerExportParser.SupportedFormatVersion` (11). A mismatch is treated as a **failure**
|
||
— the state keeps the last good export and reports the version in `LastError`.
|
||
- **Fail-open everywhere:** `TokenTrackerState` mirrors `UsageState` — a failed or unparseable
|
||
fetch never overwrites a good export, it only sets `LastError`. CLI missing → empty analytics
|
||
plus a hint card in the UI. Nothing here can throw into the queue.
|
||
- **Limits, gate and throttle do *not* come from TokenTracker.** They are the OAuth poll above.
|
||
A broken or absent TokenTracker costs you the cost columns, nothing else.
|
||
|
||
## UI surfaces
|
||
|
||
- **`UsagePillViewModel`** — one shared instance backs the `UsagePill` control in both the
|
||
footer and the Mission Control header. Loads via `GetUsageSnapshotAsync`, updates live off
|
||
`IWorkerClient.UsageUpdatedEvent`. Dot state priority is mutually exclusive:
|
||
**blocked > stale > warn > normal**. `IsThrottled` (effective slots below configured, and
|
||
not gate-blocked) adds a tooltip line naming effective/configured slots + decisive bucket.
|
||
The pill's click handler (`IslandsShellViewModel.OpenUsageMonitor`) **shows the window before
|
||
loading** (`BeginLoad`) — awaiting the load first made the pill feel like a dead click, because
|
||
the first `GetModelUsage` per worker process scans the whole transcript history.
|
||
- **Draggable stage markers** — each of the two real gauges carries three markers (soft/hard/gate).
|
||
`UsageGaugeBar` (`Views/Controls`) draws them against its own width and does the pointer work;
|
||
the math is a pure static, `UsageThresholdDrag` (in the modal VM's file), which keeps
|
||
soft ≤ hard ≤ gate and treats a neighbour of `0` as off. Release fires the row's
|
||
`CommitCommand` → read-modify-write via `GetAppSettings` + `UpdateAppSettings`, so only the
|
||
dragged bucket's three fields change. Plan-dependent `weekly_scoped` gauges are read-only.
|
||
- **Legend = numeric editor.** Under each adjustable bar sit three legend rows whose colour swatches
|
||
match the markers (soft `TextDimBrush`, hard `StatusReviewBrush`, gate `StatusErrorBrush`), each
|
||
with a `NumericUpDown`. `NumericUpDown` has no commit command, so the box's `Tag`
|
||
(`soft`/`hard`/`gate`) plus two code-behind handlers (`LostFocus`, Enter) call the row's
|
||
`CommitSoft`/`CommitHard`/`CommitGate` command. Those run the typed value through the **same**
|
||
`UsageThresholdDrag.Apply` clamp as a drag, so a box can't invert the order and only the edited
|
||
stage moves. ⚠️ The `KeepLastNumber` converter is mandatory on those bindings — see the
|
||
`NumericUpDown` null gotcha in `src/ClaudeDo.Ui/CLAUDE.md`.
|
||
Rows are updated **in place** on each snapshot (keyed by limit kind) so a poll landing mid-drag
|
||
doesn't replace the bound instance.
|
||
- **`UsageMonitorModalViewModel`** — opened from the pill. Renders one gauge **per row** in
|
||
`UsageSnapshotDto.Limits` — deliberately **dynamic**, because the `seven_day_opus` /
|
||
`seven_day_sonnet`-style buckets the raw API returns are plan-dependent and come back
|
||
`null` on plans that don't have them; a fixed gauge layout would break. Also shows model
|
||
usage (`GetModelUsageAsync`, ClaudeDo-vs-Other split per model) and top-task usage
|
||
(`GetTaskUsageAsync`) over a 7d/30d preset or custom range — both with a **cost** column, the
|
||
task table additionally with retries.
|
||
- **Analytics header vs. hint card** — the modal shows exactly one of the two. With TokenTracker
|
||
present: total tokens + total cost, a freshness stamp, and a manual `RefreshAnalytics` button.
|
||
Without it: a hint card explaining what TokenTracker is (local only, no `init`, no cloud sync)
|
||
plus an `Install TokenTracker` button — which is hidden and replaced by a Node-version hint when
|
||
Node < 20 or Node is missing, because the install would just fail. Install progress and every
|
||
failure go through `ErrorReported` into the shell's footer log strip, not a private banner.
|
||
|
||
## Hub surface
|
||
|
||
- `GetUsageSnapshot() -> UsageSnapshotDto` — percentages/limits/`FetchedAtUtc` are null and
|
||
`IsStale=true` when no snapshot has landed yet. `IsStale` also trips on a failed last poll
|
||
or a snapshot older than 3× `usage_poll_interval_seconds`.
|
||
- `GetModelUsage(from, to)` — filters + aggregates the **cached TokenTracker export**
|
||
(`TokenTrackerAggregator.ByModel`). Returns an empty list rather than throwing when TokenTracker
|
||
is unconfigured or no export landed. `Sessions` replaced the old assistant-message count — the
|
||
export has no message granularity.
|
||
- `GetTaskUsage(from, to)` — unchanged source: top consumers from `task_runs` joined to task/list,
|
||
grouped per task. Null token columns count as **0**, never drop the row. `Model` comes from that
|
||
task's most recent run. Sorted by total tokens descending, capped at 100. Cost/retries/
|
||
productive/one-shot are an **enrichment** via the `session_hash` join — a missing export just
|
||
leaves those columns empty, it never triggers a fetch (`GetModelUsage` is what does).
|
||
- `GetTokenTrackerStatus()` — probe + state, as `TokenTrackerStatusDto(Installed, Version, NodeOk,
|
||
NodeVersion, LastFetchedUtc, LastError, FormatVersion, SessionCount)`. Never throws; an
|
||
unconfigured worker comes back as not-installed with a reason.
|
||
- `RefreshTokenTracker()` — forces a fetch, then re-probes and returns the status.
|
||
- `InstallTokenTracker()` — `npm i -g tokentracker-cli`, streaming npm's output line by line into
|
||
`WorkerLog` (npm writes progress to stderr; forwarding it is what keeps a multi-minute global
|
||
install from looking hung), then force-re-probes.
|
||
- `UsageUpdated` event carries the same `UsageSnapshotDto`.
|
||
|
||
## Settings columns
|
||
|
||
`app_settings`: `usage_gate_five_hour_pct` / `usage_gate_seven_day_pct` (80/90),
|
||
`usage_throttle_five_hour_{soft,hard}_pct` / `usage_throttle_seven_day_{soft,hard}_pct` (50/65 per
|
||
bucket). All six clamped 0..100 by `AppSettingsRepository.UpdateAsync`, which does **not** enforce
|
||
soft ≤ hard ≤ gate — the ordering is a UI-side drag constraint, and an out-of-order stored config
|
||
degrades instead of throwing. Worker config: `usage_poll_interval_active_seconds` /
|
||
`usage_poll_interval_idle_seconds`.
|
||
|
||
The gate percentages are editable in **two** places that both write the same `app_settings` row:
|
||
Settings → General (typed) and the usage-monitor gauges (dragged). The throttle stages are
|
||
gauge-only — `SettingsModalViewModel` therefore carries them load→save verbatim so saving Settings
|
||
can't reset a dragged value.
|