Skip to content

A Codex coding session is completely invisible — unmetered like a tmux drive, but unlike a tmux drive the absence is never recorded, and classifyEngineMetering has zero production callers #556

Description

@serge-ivo

The gap

AIPA coder (a1d3522f…) runs Codex. Measured on production 2026-08-13, GET /v1/instances/a1d3522f…/coding/engines:

defaultEngineId = codex   command = "codex exec --sandbox danger-full-access"

Its whole all-time footprint in usage_summary is 20 calls, $0.000475 — the cloud-side Pilot decisions. The Codex process itself has produced zero ai_usage rows and zero usage.unmetered trace events. On the Usage page that agent's engine reads as costless, in the same way a tmux-driven Claude Code session read as costless before #348.

#348's own header states the rule this violates:

A zero you did not measure is a lie; an absence you did measure is data.

It applied that rule to the terminal driver and left the headless raw-engine case unrecorded — even though the classifier it shipped covers both.

Verified

The classifier knows. workers/api/src/lib/engine-metering.ts:100-121, classifyEngineMetering("headless", "codex"):

	if (STRUCTURED_ENGINES.has(name)) {
		return { metered: true, reason: "Claude Code reports each turn's tokens and cost, and that figure is recorded as measured." };
	}
	return {
		metered: false,
		reason: name
			? `${name} ends a turn with plain stdout and reports no token counts, so its spend cannot be measured.`
			: "This engine reports no token counts, so its spend cannot be measured.",
	};

with STRUCTURED_ENGINES = new Set(["claude", "claude-code"]) (:70), and the module docstring saying so outright: "Codex and Grok are spawned raw and their turns are stdout text, which is why they are unmetered under BOTH drivers rather than only under the terminal one."

Nothing calls it.

$ grep -rn "classifyEngineMetering" --include="*.ts" workers packages store | grep -v node_modules
workers/api/src/lib/engine-metering.ts:107:export function classifyEngineMetering(…)
workers/api/src/lib/engine-metering.test.ts:  (10 assertions, including `("headless","codex").metered === false`)

One definition, ten test assertions, zero production call sites. The function that describes this gap is dead code.

And the absence recorder is wired only to the terminal driver.

$ grep -rn "noteUnmeteredDrive" --include="*.ts" workers/api/src | grep -v '\.test\.'
lib/connectors/tmux.ts:100,129,162       driver: "terminal"
lib/connectors/terminal.ts:114,144,178   driver: "terminal"

Six call sites, all driver: "terminal". EngineDriver is declared as "headless" | "terminal" (engine-metering.ts:40) and "headless" is never passed by any caller.

So the runner-side path confirms it. packages/browser-runner/src/coding/headless.ts sets this.mode = config.clientType === "claude" ? "stream-json" : "raw" (:255); only the stream-json path parses a result event carrying tokens. A raw engine's turn produces stdout and nothing else, so takeUsage() returns [] and recordEngineUsage no-ops on !records?.length (usage.ts:191).

Why it composed into a bug

Two correct decisions:

  1. [bug] Coding CLIs driven through the terminal connector are completely unmetered — a tmux run reads as $0, not as unknown #348 fixed the driver axis. Its rule — "metering is a property of the (driver, engine) PAIR, not of the engine" — is right, and it built the classifier over both axes.
  2. noteUnmeteredDrive was wired where the drives were happening, in the two terminal connectors, which is where the reported problem was.

Composed: the classifier covers a 2x2, the recorder was wired to one row of it, and the remaining cell — headless + raw engine — is now the default for any subscriber who picks Codex or Grok from the engines panel. The panel offers all three as equals (coding-engines.ts:253, DEFAULT_ENGINES) with nothing saying that one of them is measurable and two are not.

The full metering picture, for the record

Path Ledger row? Draws a budget pool?
BYOK chat / apply / coding / copilot / overseer / translate / resume / pipeline ai_generate yes — all funnel through user-ai.ts, recordUsage at :272 / :462 no
Platform Workers AI (embeddings, summaries, translation) yesrecordPlatformUsage (vectors.ts:313, summaries.ts:141, instances-translation.ts:289) no
Voice STT / TTS via the key-proxy yesrecordVoiceUsage (routes/keys.ts:561,564) no
Coding Engine, headless + Claude Code yesrecordEngineUsage, 4 sites no (the Pilot's own decisions do)
Coding Engine, headless + codex/grok/gemini NO — and no absence recorded no
Coding CLI via the tmux/kitty/iTerm2 connector no — but the absence is recorded (#348) no
Loop / delegated runs (agent-loop.ts:88) yes yes
Pilot (coding-session.ts:459) yes yes
JOB_APPLY yes (job-apply.ts:155, kind:"apply") no#516

reserve() has exactly two callers; openBudget has six. So four paths open a pool that nothing ever draws against.

One latent hole worth naming, not currently costing anything: the key-proxy meters only OpenAI /audio/speech and /audio/transcriptions (routes/keys.ts:555). api.anthropic.com is on its allowlist (keys.ts:58), so a proxied /v1/messages would be entirely unmetered. Grepped: the only in-repo consumers are packages/sdk/src/voice/stt.ts:635 and tts.ts:333, both audio. Nothing exploits it today.

What to do, cheapest first

  1. Call the classifier from the headless drain. In routes/coding.ts (capture, :237) and workflows/coding-session.ts (:402), when classifyEngineMetering("headless", session.clientType).metered === false, call noteUnmeteredDrive(env, ctx, { driver: "headless", target: session.engineLabel, activeCommand: session.clientType }). Everything needed is already in hand at both sites, the row id is already coarse enough not to flood the trace (unmeteredRowId keys on instance+target+day), and unmeteredUsageSummary — which the Usage page already renders — picks it up with no change.
  2. Say it in the engines panel. classifyEngineMetering(...).reason is a finished sentence written for exactly this: "codex ends a turn with plain stdout and reports no token counts, so its spend cannot be measured." Show it under any preset whose engine is not Claude Code. This is the cheapest half and it prevents the choice being made blind.
  3. Consider --json for Codex. Out of scope here; note only that if any raw engine grows a structured turn-end event, the fix is to add it to STRUCTURED_ENGINES and teach headless.ts to parse it — the classifier needs no change.

Alternatives considered and rejected

  • Estimate raw-engine spend from stdout length. Rejected — this is precisely the "a guess wearing a measurement's clothes" that engine-metering.ts:20-27 refuses for pane text, and the same refusal applies to stdout.
  • Warn on the Usage page only. Insufficient: the page already says the terminal connector is unmetered in prose, and the whole point of [bug] Coding CLIs driven through the terminal connector are completely unmetered — a tmux run reads as $0, not as unknown #348 was that prose does not distinguish "nothing ran" from "we could not see it". This needs a counted observation, which step 1 provides.
  • Block or hide non-Claude engines. Rejected outright. Codex is a legitimate choice and DEFAULT_ENGINES ships it deliberately; the fix is to record the consequence, not to remove the option.

Acceptance criteria

  • Driving a Codex session writes usage.unmetered trace events with driver: "headless", visible via GET /v1/instances/:id/trace and MCP agent_trace.
  • unmeteredUsageSummary counts them, so the Usage page's "what this leaves out" figure covers headless raw engines.
  • classifyEngineMetering has at least one production caller, asserted by the same style of source test that guards CHARGED_SQL.
  • The engines panel shows the metering verdict per preset.
  • A test drives the ("headless", "codex") cell end to end — today all four cells are unit-tested and only two are reachable.

Regression risk

noteUnmeteredDrive is best-effort and never throws (engine-metering.ts:230-247), so adding it to the capture drain cannot break a session. The one thing to watch is volume: the capture poll runs every 3s per open session, and the coarse unmeteredRowId (instance + target + day + aiCli) is what keeps that from becoming thousands of trace rows — do not make the id finer to include the session, or a day of Codex work becomes a day of identical trace rows. unmeteredUsageSummary's own comment already calls the resulting count "drives on distinct target-days", and that stays true.

Measured on production (engine presets, usage_summary, the instance's trace) and read from source.
Related: #348 (the terminal half of the same rule), #267 (engine metering), #449 (raw engines are one-shot), #516, #543, #551.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2: correctnessReal defect, no live harm today — inert fields, miscounts, missing guardsbackendBackend / Worker / API workbugSomething isn't workingcoderThe Coder wedge agent (#68) — Engine, Pilot, Co-pilot, Loop, OverseerobservabilityA displayed value the code cannot produce, or that means something other than its label

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions