Skip to content

The Usage page lists 16 agents named after their own UUID, and cannot say what one instance cost — the DO's storage meter writes an instance id into ai_usage.agent_id #526

Description

@serge-ivo

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.tsdecideCodingAction(…, { 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    backendBackend / Worker / API workbugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions