Skip to content

Officers: per-instance Claude usage from the transcript, pushed to HQ - #592

Merged
ZacxDev merged 3 commits into
mainfrom
officer-claude-usage
Nov 11, 2026
Merged

ZacxDev merged 3 commits into
mainfrom
officer-claude-usage

Conversation

@ZacxDev

@ZacxDev ZacxDev commented Nov 11, 2026 •

Copy link
Copy Markdown
Owner

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:

  1. ccd: GET /v1/usage?since=<unix seconds> (cmd/ccd/usage_route.go). Bearer-authenticated like /v1/transcript. Reads every *.jsonl under <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.
  2. muster: the officer usage collector (internal/officers/usage, wired in internal/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) with since = now - 25h and PUTs the result to HQ with the officer's own token. officerFleet.Stop pushes once more just before scaling an instance to zero.

Counting rules (ccd)

  • since is truncated DOWN to the UTC hour and clamped to at most 48 h before now, on an hour boundary. The oldest bucket is therefore always a whole hour. This matters because HQ replaces a bucket on every push.
  • api_calls / tokens come from assistant records, subagent (sidechain) records included. Each API call is counted once with the existing per-call rule: key = message.id + input + cache_read + cache_creation, each field takes its max, and isApiErrorMessage and <synthetic> records are skipped. The call is bucketed at the hour of its first record. The rule moved out of turnResult.addUsage into callLedger (cmd/ccd/callusage.go), which the turn usage and the hourly count both use.
  • turns are distinct non-empty promptIds of non-sidechain user records, bucketed at the hour first seen.
  • The answer is sorted by hour, then session. A bucket with zero calls and zero turns is omitted, and buckets is [], 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}]}
  • account is the instance row's cc_account at push time, or "" for a pool on the LLM proxy (officers.ProxyAgent).
  • If there are more than 1000 buckets, only the newest 1000 are sent, and the cut is logged.
  • The fake HQ (hqtest) implements the route with a strict decoder: pool values, hours on the hour, 413 past 256 KiB or 1000 buckets, and the upsert. UsageAbsent makes it play an HQ without the route.

Failure handling

  • Nothing is retried within a tick; the next tick's push covers it.
  • A ccd without the route (404/405, which maps to ErrUsageUnsupported) is skipped and logged once per instance.
  • HQ 404 is logged once per officer, and pushes continue.
  • Every attempt is counted on muster_officer_usage_push_total{result}, with results pushed, ccd_unsupported, ccd_failed, hq_not_found, hq_refused, hq_unavailable and skipped. The metric is added to the metrics ledger. HQ refusals are logged by status, never by body.
  • One collect runs at a time, so a tick's push and a pre-stop push can never reach HQ out of order.
  • The pre-stop push is bounded at 10 s and never fails the stop.

Tests

Change-scoped runs only, done locally; CI runs the full suite.

  • cmd/ccd/usage_route_test.go pins the whole answer as literal JSON over a synthetic fixture whose token values are pairwise distinct within each bucket. It covers:
    • a multi-block call counted once, at its max output;
    • the same id with a different input side counted as two calls;
    • api-error and <synthetic> records skipped;
    • sidechain calls counted, and sidechain prompts not counted as turns;
    • a nested subagent file attributed to its parent session;
    • records before since excluded;
    • since 10:30 still counting a 10:05 record;
    • a partial trailing line and an undecodable line skipped;
    • an old-mtime file not read;
    • 401 without a bearer, and 400 on a missing or bad since;
    • the 48 h clamp, measured with the clock off the hour and on it.
  • internal/officers/usage/collect_test.go runs against the fake HQ through the real client and covers:
    • exact PUT bytes for a tiered officer's judgement and routine instances and for an untiered officer;
    • down and absent instances never read;
    • unsupported ccd skipped and logged once;
    • HQ 404 tolerated, logged once per officer, and recovered once the route appears;
    • the cap keeping the newest buckets;
    • a failing flush pushing nothing.
  • internal/api/officer_usage_test.go covers the server wiring:
    • the instance is read by its own row, and the row's account is in the exact body;
    • Stop reads the instance while it is still running, then stops it;
    • a failing read or a down HQ does not fail Stop;
    • the gateway sentinel maps to the quiet skip;
    • a proxy pool reports no account.
  • internal/agentgateway/usage_test.go covers the turn bearer, the since query and 404/405 → unsupported.

Mutation results

Each mutant was run with -count=1, restored by copy, and its checksum re-verified:

mutant result
since hour-truncation removed red: both ccd answer tests (since 10:30, the 10:00 bucket gone)
dedup key = message id only red: both ccd answer tests and the existing TestExtractTurnSumsTheTurnsUsageOncePerAPICall (the shared helper)
output added to the dedup key red: both ccd answer tests (api_calls 3)
sidechain check dropped from turns red: TestUsageRouteCountsOnlyTheWindowsHours (turns 2)
pre-Stop flush removed red: TestStopPushesTheInstancesUsageBeforeScalingItToZero (no read)
flush moved after the scale-down red: same test (read @stopped)
collector's Up check dropped red: TestATickPushesEachUpInstanceExactlyTheContractBody (the down instance was read)
bucket cap keeps the oldest red: TestOverTheBucketCapTheNewestAreSent

Verifying 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)

  • Yellow, fixed: the call ledger was per file. A call copied into several transcripts (a forked subagent, a branched or resumed session) was counted once per file, and a prompt copied into another session's file was counted again there.
    • ccd now keeps ONE ledger and one prompt set for the whole scan.
    • Each call and prompt is owned (session and hour) by its first record in sorted relative-path order, so the owner is stable between pushes.
    • New test TestUsageRouteCountsACallInSeveralFilesOnce: red at 7669838, green now.
    • Mutants all red on that test: per-file ledger restored; reverse file order; no sort (the walk's order); prompt set keyed per session; meta and compaction-summary records counted as turns. The no-sort mutant survived until a cross-project case was added.
  • Green, done: isMeta and isCompactSummary user records are no longer turns.
  • Docs: the jq recipe now reads every transcript in sorted order and dedups across all of them.
    • It reproduces every bucket of ccd's answers on both test fixtures.
    • An hour ccd omits reads as all zeros.
    • The old per-session input reports a DIFF.
    • The earlier "message ids are unique, so the two agree" was false and has been removed.

Deploy notes

  • Officer instance pods need the new claude-code-agent image, i.e. a restart onto it, before /v1/usage answers. Until then each instance is skipped with one log line.
  • HQ's route can land before or after this change. Until it does, HQ answers 404 and pushes are dropped, with one log line per officer.
  • No new environment variable and no schema change.
  • Caveat: account is the account at push time, so hours from before a failover inside the 25 h window are reported on the new account.
  • Caveat: hours older than 25 h when an instance comes back up are never backfilled.

Not run locally

The DB-backed and whole-suite tests were not run here, by policy; CI runs them.

🤖 Generated with Claude Code

ZacxDev and others added 3 commits November 10, 2026 23:49
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>
@ZacxDev
ZacxDev marked this pull request as ready for review November 11, 2026 08:26
@ZacxDev
ZacxDev merged commit c281806 into main Nov 11, 2026
19 checks passed
@ZacxDev
ZacxDev deleted the officer-claude-usage branch November 11, 2026 08:26
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