Repository navigation
Officers: per-instance Claude usage from the transcript, pushed to HQ - #592
Merged
Merged
Conversation
ccd counts its own transcripts (every *.jsonl under the config dir's projects/, mtime >= since; a subagent file counts on its parent session) into buckets of (session, UTC hour): API calls and their tokens, subagent calls included, and turns (distinct non-sidechain promptIds). since is truncated DOWN to the hour and clamped to 48 h, so the oldest bucket is always whole (HQ replaces a bucket on every push). Complete lines only, decoded with a lean struct that never touches the content; no transcript text in the answer. The per-API-call rule (one call per message id + input side, each field's maximum, API-error and <synthetic> records skipped) moves out of turnResult.addUsage into callLedger, which both the turn usage and the hourly count now use: one rule, one place. The bucket wire type is cctranscript.UsageBucket / UsagePage, shared with muster-server's reader (agentgateway cannot import podagent). Tests: cmd/ccd/usage_route_test.go pins the whole answer as literal JSON over a synthetic fixture with pairwise-distinct token values (multi-block call once at its max output; same id + different input side = two calls; api-error and <synthetic> skipped; sidechain call counted, sidechain prompt not a turn; nested subagent file on the parent session; pre-window records excluded; since 10:30 counts a 10:05 record; partial trailing line and an undecodable line skipped; an old-mtime file not read; 401; 400 on a missing or bad since; the 48 h clamp measured at a clock off the hour and on it). Mutation checks (each run with -count=1, restored by copy, checksums re-verified): - since truncation removed (start := since.UTC()): red, TestUsageRouteCountsCallsTokensAndTurnsPerSessionHour and TestUsageRouteCountsOnlyTheWindowsHours (answer since 10:30, the 10:00 bucket gone). - dedup key = message id only: red, both answer tests (10:00 bucket input 13, one call) AND the existing TestExtractTurnSumsTheTurnsUsageOncePerAPICall — the shared helper is the one rule both paths run. - output added to the dedup key: red, both answer tests (10:00 api_calls 3). - sidechain check dropped from the turn count: red, TestUsageRouteCountsOnlyTheWindowsHours (11:00 turns 2). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
internal/officers/usage is a collector run from EnableOfficers for every
declared officer, with no configuration (off, with one boot line, only when
the agent gateway cannot read usage). Every 10 minutes, for each instance
the wake loop's own view (officerFleet.Instances) says exists and is up, it
reads the instance's ccd GET /v1/usage (agentgateway.Gateway.Usage, the
instance's turn bearer) with since = now - 25 h and PUTs it to HQ with the
officer's own token (officers.Client.PutUsage):
PUT {hq_url}/officers/{id}/usage
{"instance", "pool": routine|judgement|"", "account", "generated_at",
"buckets": [{session, hour, turns, api_calls, input_tokens,
output_tokens, cache_read_tokens, cache_creation_tokens}]}
The account is the instance row's cc_account at push time, "" for a pool on
the LLM proxy (officers.ProxyAgent). officerFleet.Stop pushes once more
before scaling an instance to zero (usage.Collector.Flush, at most 10 s,
never failing the stop). One collect runs at a time, so a tick's push and a
pre-stop push can never land at HQ out of order. Over 1000 buckets: the
newest 1000 are sent, logged.
Failures are never retried within a tick: an older ccd (404/405 ->
agentgateway.ErrUsageUnsupported) is skipped, logged once per instance; HQ
404 is logged once per officer and pushes continue; everything is counted on
muster_officer_usage_push_total{result} (metrics ledger updated). HQ
refusals are logged by status, never body.
hqtest gains the route (strict decoder, pool values, hours on the hour, 413
past 256 KiB / 1000 buckets, upsert, UsageAbsent for an HQ without it).
docs/officers.md: "Claude usage to HQ", with a jq recipe that recomputes
one hour from a session's transcripts by the same rules (checked against the
ccd test fixture: both hours match ccd's answer exactly).
Tests: internal/officers/usage (exact PUT bytes for a tiered officer's
judgement and routine instances and an untiered one; down and absent
instances never read; unsupported skipped and logged once; HQ 404 tolerated,
logged once per officer, recovers; the cap keeps the newest; a failing flush
pushes nothing and recovery is logged), internal/api/officer_usage_test.go
(the server wiring: the instance read by its own row, the row's account in
the exact body; Stop reads the instance while still running, then stops it;
a failing read or a down HQ does not fail Stop; ErrUsageUnsupported maps to
the quiet skip; the proxy pool reports no account),
internal/agentgateway/usage_test.go.
Mutation checks (each with -count=1, restored by copy, checksums
re-verified):
- pre-Stop flush removed: red, TestStopPushesTheInstancesUsageBeforeScalingItToZero
(reads "", want one read while running).
- flush moved after Provisioner.Stop: red, same test (read the instance
"@stopped").
- collector's Up check dropped (exists only): red,
TestATickPushesEachUpInstanceExactlyTheContractBody (the down -2 read).
- bucket cap keeping the oldest: red, TestOverTheBucketCapTheNewestAreSent
(sent hours 0..999).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…d order Audit round on #592 (yellow): the call ledger was per FILE while the prompt set was per scan, so a call whose records sit in several transcript files (a forked subagent, a branched or resumed session copying what they continue from) was counted once per file. The same held for a prompt copied into another SESSION's file, which the per-session prompt set counted again there. Now GET /v1/usage keeps ONE ledger for the whole scan (same key: message id + input side, max merge over every copy) and one prompt set keyed by promptId alone. Each call and prompt is OWNED (session and hour) by its first record in scan order, and the files are read in SORTED relative-path order (plain byte order), so the owner is stable between scans — HQ replaces a bucket on every push, and a flapping owner would move counts between sessions. The directory walk's own order is lexical per directory and differs from that ("-w/" is visited before "-w-x/"), which the jq recipe's `LC_ALL=C sort` could not reproduce; the explicit sort is what both now agree on. Also (green): user records with isMeta or isCompactSummary (the field names internal/cctranscript already decodes) are no longer turns. docs/officers.md: the dedup rule states the scan-wide ledger and ownership, and the jq recipe now reads every transcript under projects/ in sorted order, dedups calls and prompts across all of them and picks the owner by first record. The previous recipe's "message ids are unique, so the two agree" was false and is gone. Checked against ccd's own answers (both rigs dumped by a throwaway test, not committed): every bucket matches exactly (sess-a 10/11, sess-b 12, sess-c 10, sess-d 11, sess-e 11), an hour ccd omits (sess-d 10) reads all zeros, and the control — the old per-session input, sess-d's files only — reports a DIFF (api_calls 2). Test: TestUsageRouteCountsACallInSeveralFilesOnce, a fixture with the same msg id in two subagent files of one session (d2, the fork's copy carrying the larger output), the same msg id and prompt across two top-level session files (d1, c1), a call shared across two project directories (d5), and a meta and a compaction-summary record. Pairwise-distinct token values; literal answer. Red -> green: at 7669838's usage_route.go the new test is RED (sess-c 10:00 api_calls 4; a sess-d 10:00 bucket with the copied c1 and d1; sess-d 11:00 turns 3); green at this commit, with the four earlier usage-route tests unchanged and green. Mutation checks (-count=1, restored by copy, checksum re-verified each time), all red on TestUsageRouteCountsACallInSeveralFilesOnce: - per-file ledger restored (file ordinal in the key): sess-c 10:00 api_calls 4, sess-d 10:00 and sess-d 11:00 api_calls 2. - reverse file order: d1 and c1 owned by sess-d (sess-d 10:00 bucket), d5 by sess-d, sess-e gone. - no sort (the walk's order): d5 owned by sess-d (api_calls 2), sess-e gone. Before the d5 case was added this mutant SURVIVED — the walk is lexical too, and agreed with the sort on the first fixture. - prompt set keyed per session again: a sess-d 10:00 bucket with turns 1. - isMeta/isCompactSummary not excluded: sess-d 11:00 turns 3. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Per-officer-instance Claude usage, measured from each instance's own Claude Code transcripts and pushed to the officer's HQ, per session and UTC hour: API calls, the four token fields, and turns.
Two halves:
GET /v1/usage?since=<unix seconds>(cmd/ccd/usage_route.go). Bearer-authenticated like/v1/transcript. Reads every*.jsonlunder<CLAUDE_CONFIG_DIR>/projects/(any depth, mtime >= since). A subagent's nested transcript counts on its parent session. Complete lines only, decoded with a lean struct that never decodes content. No transcript text appears in the answer or in logs.internal/officers/usage, wired ininternal/api/officer_usage.go). It runs for every declared officer with no new configuration. Every 10 minutes it reads each up instance's ccd through the agent gateway (Gateway.Usage) withsince = now - 25hand PUTs the result to HQ with the officer's own token.officerFleet.Stoppushes once more just before scaling an instance to zero.Counting rules (ccd)
message.id+ input + cache_read + cache_creation, each field takes its max, andisApiErrorMessageand<synthetic>records are skipped. The call is bucketed at the hour of its first record. The rule moved out ofturnResult.addUsageintocallLedger(cmd/ccd/callusage.go), which the turn usage and the hourly count both use.promptIds of non-sidechainuserrecords, bucketed at the hour first seen.bucketsis[], never null.ccd answer:
{"since":"2000-01-03T10:00:00Z","buckets":[{"session":"sess-a","hour":"2000-01-03T10:00:00Z","turns":1,"api_calls":1,"input_tokens":11,"output_tokens":29,"cache_read_tokens":1301,"cache_creation_tokens":701}]}The HQ contract (implemented exactly as specified)
PUT {hq_url}/officers/{officer_id}/usage(the path is built like every other officer route,url.PathEscaped),Authorization: Bearer <officer token>:{"instance":"<agent name>","pool":"routine|judgement|","account":"<cc account now, or \"\">","generated_at":"<RFC3339 UTC>", "buckets":[{"session":"..","hour":"<on the hour>","turns":0,"api_calls":0,"input_tokens":0,"output_tokens":0,"cache_read_tokens":0,"cache_creation_tokens":0}]}accountis the instance row'scc_accountat push time, or""for a pool on the LLM proxy (officers.ProxyAgent).hqtest) implements the route with a strict decoder: pool values, hours on the hour, 413 past 256 KiB or 1000 buckets, and the upsert.UsageAbsentmakes it play an HQ without the route.Failure handling
ErrUsageUnsupported) is skipped and logged once per instance.muster_officer_usage_push_total{result}, with resultspushed,ccd_unsupported,ccd_failed,hq_not_found,hq_refused,hq_unavailableandskipped. The metric is added to the metrics ledger. HQ refusals are logged by status, never by body.Tests
Change-scoped runs only, done locally; CI runs the full suite.
cmd/ccd/usage_route_test.gopins the whole answer as literal JSON over a synthetic fixture whose token values are pairwise distinct within each bucket. It covers:<synthetic>records skipped;internal/officers/usage/collect_test.goruns against the fake HQ through the real client and covers:internal/api/officer_usage_test.gocovers the server wiring:internal/agentgateway/usage_test.gocovers the turn bearer, thesincequery and 404/405 → unsupported.Mutation results
Each mutant was run with
-count=1, restored by copy, and its checksum re-verified:TestExtractTurnSumsTheTurnsUsageOncePerAPICall(the shared helper)TestUsageRouteCountsOnlyTheWindowsHours(turns 2)TestStopPushesTheInstancesUsageBeforeScalingItToZero(no read)@stopped)TestATickPushesEachUpInstanceExactlyTheContractBody(the down instance was read)TestOverTheBucketCapTheNewestAreSentVerifying HQ's numbers
docs/officers.md("Claude usage to HQ") includes a jq recipe that recomputes one session-hour from the transcripts by the same rules. I checked it against ccd's answers on both test fixtures, and every bucket matches.Audit round 1 (93aeda7)
TestUsageRouteCountsACallInSeveralFilesOnce: red at 7669838, green now.isMetaandisCompactSummaryuser records are no longer turns.Deploy notes
claude-code-agentimage, i.e. a restart onto it, before/v1/usageanswers. Until then each instance is skipped with one log line.accountis the account at push time, so hours from before a failover inside the 25 h window are reported on the new account.Not run locally
The DB-backed and whole-suite tests were not run here, by policy; CI runs them.
🤖 Generated with Claude Code