The question that cannot be answered
"What did Chess coder 2 cost me this week?" The owner runs seven Repo Coder instances against seven different repositories. The Usage page cannot tell him about any one of them, and its by-agent breakdown lists sixteen rows labelled with a raw UUID.
Measured
usage_summary(range: "7d"), 2026-08-12. byAgent contains two different kinds of key:
agent_coder_repo 1330 calls label "Repo Coder" ← agents.id
agent_coder_lead 310 calls label "Coder Lead" ← agents.id
2dff5c62-59f0-4d2d-b0a6-b1db5c879c46 160 calls label "tmux Operator" ← agents.id (a UUID agent)
5fab318d-2850-45a4-982c-958765c7261e 155 calls label "5fab318d-…" ← agent_instances.id (Coder Lead)
bd43f4de-ef35-4051-bdec-43f8571414a1 84 calls label "bd43f4de-…" ← agent_instances.id (Chess coder 2)
cda75e28-cace-4958-ac3e-6a7528e6b719 60 calls label "cda75e28-…" ← agent_instances.id (Heartfull (tmux))
…13 more instance-id rows, every one labelled with its own key…
Sixteen rows are keyed by an instance id and carry no name. Every one of them corresponds to an instance in my_instances. So the same agent appears twice — once as its template with a proper label, once (or several times, one per instance) as an unlabelled UUID.
Mechanism — fully traced
Why the label is the UUID. workers/api/src/routes/usage.ts:41-49 resolves the agent per row:
SELECT COALESCE(u.agent_id, i.agent_id) AS agent_id, … a.name AS agent_name
FROM ai_usage u
LEFT JOIN agent_instances i ON i.id = u.instance_id
LEFT JOIN agents a ON a.id = COALESCE(u.agent_id, i.agent_id)
and lib/usage.ts:511 falls back to the key when the join found no name:
byAgent: sortByCost(maps.agent).map((b) => ({ ...b, label: b.key === "unassigned" ? "Unassigned" : opts.agentNames?.[b.key] || b.key })),
The COALESCE handles the case it was written for — a row with only instance_id. It cannot help a row where agent_id is set to something that is not an agent.
Who writes an instance id into agent_id. The Durable Object's storage meter:
routes/instances.ts:213-215 initialises an instance's DO with agentId: instanceId — correct, that is the DO's own identity.
agent-do.ts:153-166 builds the meter from it: const meter = platformAi && userId ? { db: this.env.DB, userId, agentId } : null; — where agentId is state.agentId, i.e. the instance UUID for an instance DO.
EngineMeter (agent-storage/base.ts:16-21) declares both agentId and instanceId. The only construction site above sets agentId only; instanceId is never populated.
agent-storage/vectors.ts:315 and agent-storage/summaries.ts:143 then record:
{ userId: this.meter.userId, agentId: this.meter.agentId, instanceId: this.meter.instanceId, model: …, kind: "embedding" | "summary" }
→ ai_usage.agent_id = <instance uuid>, ai_usage.instance_id = NULL.
With instance_id null the agent_instances join has nothing to join on, and with agent_id set the COALESCE prefers it. Both joins miss. The row is orphaned by construction.
Why chat rows are fine, and why that hid this. agent-think.ts:783 records { kind: "chat", instanceId: state.agentId } — the same value, passed in the right field. Those rows have agent_id NULL, the COALESCE resolves them through agent_instances, and they land on the properly-labelled template row. Same for the Pilot (coding-session.ts → decideCodingAction(…, { kind: "coding", instanceId })). So the big-ticket spend attributes correctly and only the platform-paid embedding/summary crumbs are orphaned — which is why this reads as cosmetic until you try to answer a question with the page.
byKind for the same window: embedding 393 calls, summary 73 — consistent with the 16 orphaned rows' call counts.
The second half: there is no per-instance figure at all
Fixing the labels makes the page tidy. It still cannot answer the owner's question, because byAgent groups by template: agent_coder_repo's 1,330 calls are seven instances working on seven repositories, summed. The ledger has the data — ai_usage.instance_id is populated on every chat and coding row — and nothing surfaces it.
What to do, cheapest first
- Populate
instanceId on the meter. agent-do.ts:160-161 → { db: this.env.DB, userId, agentId, instanceId: agentId } for an instance DO (the DO knows which it is; the same state.agentId is the instance id). Every future embedding/summary row then resolves through the existing COALESCE, with no query change. One line.
- Backfill or tolerate. Existing rows keep an instance id in
agent_id forever. Either a migration (UPDATE ai_usage SET instance_id = agent_id, agent_id = NULL WHERE agent_id IN (SELECT id FROM agent_instances)) or a second LEFT JOIN agent_instances i2 ON i2.id = u.agent_id in the route, resolving through it when the agents join misses. I would do the query fix, not the migration: it is reversible, it fixes the history the owner is looking at right now, and ai_usage is observability rather than a ledger of record.
- Add
byInstance. aggregateUsage already builds five maps by the same into() helper (lib/usage.ts:470-482); a sixth on r.instance_id, labelled from agent_instances.config.name, is a few lines and is the thing that actually answers "what did Chess coder 2 cost?". The MCP usage_summary tool passes the payload straight through, so it arrives there for free.
- Make it hard to regress. A row whose
agent_id matches no agents.id is a bug, not a display state. Assert it in usage.test.ts against a fixture containing an instance id in agent_id, and — better — narrow the type so a call site cannot pass an instance id as agentId by accident. The EngineMeter interface having both fields, with only one ever set, is what made this invisible.
Alternatives rejected
- Just improve the label fallback (show "unknown agent" instead of the UUID). Hides the defect and loses the attribution — the row belongs to a named instance and the platform knows which.
- Drop
agent_id from ai_usage and always resolve through instance_id. Not every row has an instance (kind:"run" records agentId: agent.id from routes/run.ts:82, a creator-side call against a template). The column is load-bearing; the bug is one writer using it for the wrong id.
Regression risk
The route's COALESCE currently prefers u.agent_id. Adding a second resolution path must keep template-scoped rows (creator run/chat against an agent, no instance) attributing to the template — a naive "if it looks like a UUID, treat it as an instance" would break 2dff5c62-…, which is a genuine UUID agent id and today labels correctly as "tmux Operator". Resolve by lookup, never by shape. usage.test.ts should carry one row of each: agent-id-that-is-a-UUID, instance-id-in-agent_id, instance-id-in-instance_id.
Reported as observed via MCP usage_summary; the mechanism above is read from the code, not instrumented at runtime.
The question that cannot be answered
"What did Chess coder 2 cost me this week?" The owner runs seven Repo Coder instances against seven different repositories. The Usage page cannot tell him about any one of them, and its by-agent breakdown lists sixteen rows labelled with a raw UUID.
Measured
usage_summary(range: "7d"),2026-08-12.byAgentcontains two different kinds of key:Sixteen rows are keyed by an instance id and carry no name. Every one of them corresponds to an instance in
my_instances. So the same agent appears twice — once as its template with a proper label, once (or several times, one per instance) as an unlabelled UUID.Mechanism — fully traced
Why the label is the UUID.
workers/api/src/routes/usage.ts:41-49resolves the agent per row:and
lib/usage.ts:511falls back to the key when the join found no name:The
COALESCEhandles the case it was written for — a row with onlyinstance_id. It cannot help a row whereagent_idis set to something that is not an agent.Who writes an instance id into
agent_id. The Durable Object's storage meter:routes/instances.ts:213-215initialises an instance's DO withagentId: instanceId— correct, that is the DO's own identity.agent-do.ts:153-166builds the meter from it:const meter = platformAi && userId ? { db: this.env.DB, userId, agentId } : null;— whereagentIdisstate.agentId, i.e. the instance UUID for an instance DO.EngineMeter(agent-storage/base.ts:16-21) declares bothagentIdandinstanceId. The only construction site above setsagentIdonly;instanceIdis never populated.agent-storage/vectors.ts:315andagent-storage/summaries.ts:143then record:ai_usage.agent_id = <instance uuid>,ai_usage.instance_id = NULL.With
instance_idnull theagent_instancesjoin has nothing to join on, and withagent_idset theCOALESCEprefers it. Both joins miss. The row is orphaned by construction.Why chat rows are fine, and why that hid this.
agent-think.ts:783records{ kind: "chat", instanceId: state.agentId }— the same value, passed in the right field. Those rows haveagent_id NULL, theCOALESCEresolves them throughagent_instances, and they land on the properly-labelled template row. Same for the Pilot (coding-session.ts→decideCodingAction(…, { kind: "coding", instanceId })). So the big-ticket spend attributes correctly and only the platform-paid embedding/summary crumbs are orphaned — which is why this reads as cosmetic until you try to answer a question with the page.byKindfor the same window:embedding393 calls,summary73 — consistent with the 16 orphaned rows' call counts.The second half: there is no per-instance figure at all
Fixing the labels makes the page tidy. It still cannot answer the owner's question, because
byAgentgroups by template:agent_coder_repo's 1,330 calls are seven instances working on seven repositories, summed. The ledger has the data —ai_usage.instance_idis populated on every chat and coding row — and nothing surfaces it.What to do, cheapest first
instanceIdon the meter.agent-do.ts:160-161→{ db: this.env.DB, userId, agentId, instanceId: agentId }for an instance DO (the DO knows which it is; the samestate.agentIdis the instance id). Every future embedding/summary row then resolves through the existingCOALESCE, with no query change. One line.agent_idforever. Either a migration (UPDATE ai_usage SET instance_id = agent_id, agent_id = NULL WHERE agent_id IN (SELECT id FROM agent_instances)) or a secondLEFT JOIN agent_instances i2 ON i2.id = u.agent_idin the route, resolving through it when theagentsjoin misses. I would do the query fix, not the migration: it is reversible, it fixes the history the owner is looking at right now, andai_usageis observability rather than a ledger of record.byInstance.aggregateUsagealready builds five maps by the sameinto()helper (lib/usage.ts:470-482); a sixth onr.instance_id, labelled fromagent_instances.config.name, is a few lines and is the thing that actually answers "what did Chess coder 2 cost?". The MCPusage_summarytool passes the payload straight through, so it arrives there for free.agent_idmatches noagents.idis a bug, not a display state. Assert it inusage.test.tsagainst a fixture containing an instance id inagent_id, and — better — narrow the type so a call site cannot pass an instance id asagentIdby accident. TheEngineMeterinterface having both fields, with only one ever set, is what made this invisible.Alternatives rejected
agent_idfromai_usageand always resolve throughinstance_id. Not every row has an instance (kind:"run"recordsagentId: agent.idfromroutes/run.ts:82, a creator-side call against a template). The column is load-bearing; the bug is one writer using it for the wrong id.Regression risk
The route's
COALESCEcurrently prefersu.agent_id. Adding a second resolution path must keep template-scoped rows (creatorrun/chatagainst an agent, no instance) attributing to the template — a naive "if it looks like a UUID, treat it as an instance" would break2dff5c62-…, which is a genuine UUID agent id and today labels correctly as "tmux Operator". Resolve by lookup, never by shape.usage.test.tsshould carry one row of each: agent-id-that-is-a-UUID, instance-id-in-agent_id, instance-id-in-instance_id.Reported as observed via MCP
usage_summary; the mechanism above is read from the code, not instrumented at runtime.