From c20c9f6ea543b8a114f31d767ff283b26e47fcb9 Mon Sep 17 00:00:00 2001 From: Renn F Date: Thu, 11 Jun 2026 20:09:23 +0200 Subject: [PATCH] fix(usage): capture tokens by reading the agent transcript at finalize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spawn sessions recorded 0 tokens in production despite the full capture chain being deployed (hook in the image, settings registering it, /usage/sync, the finalize fetch). The chain is inherently racy: token counts live in the SDK server's in-memory state inside a short-lived agent container, and the orchestrator races to fetch /usage/status before the container is torn down — so for cold-respawned agents the fetch returns nothing (verified: parser and transcripts are correct, every spawn-session row was still 0). Read the durable source of truth instead. The host ~/.claude is already mounted into the orchestrator, so at finalize, when the SDK fetch yields nothing, sum the agent's newest Claude Code transcript directly (_usage_from_transcript + the existing _sum_transcript_usage). Removes the race entirely — usage is captured regardless of container timing. --- roboco/runtime/orchestrator.py | 41 ++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/roboco/runtime/orchestrator.py b/roboco/runtime/orchestrator.py index 0570a500..c96aee3d 100644 --- a/roboco/runtime/orchestrator.py +++ b/roboco/runtime/orchestrator.py @@ -3207,6 +3207,34 @@ class AgentOrchestrator: ) return None + @staticmethod + def _usage_from_transcript(agent_id: str) -> tuple[int, int, int, int]: + """Sum token usage from the agent's newest Claude Code transcript. + + The host ``~/.claude`` is mounted into the orchestrator, so each agent's + transcripts are readable here under ``projects/*-{slug}/`` (Claude Code + encodes the agent's workspace cwd into the dir name, which ends in the + agent slug). The durable fallback for the live SDK ``/usage/status`` + fetch, which misses whenever the agent container is short-lived or + already torn down. Returns zeros when no transcript is found. + """ + from roboco.agent_sdk.server import _sum_transcript_usage + + projects = Path.home() / ".claude" / "projects" + try: + jsonl = [ + f + for d in projects.glob(f"*-{agent_id}") + if d.is_dir() + for f in d.glob("*.jsonl") + ] + except OSError: + return (0, 0, 0, 0) + if not jsonl: + return (0, 0, 0, 0) + newest = max(jsonl, key=lambda f: f.stat().st_mtime) + return _sum_transcript_usage(newest) + async def _finalize_spawn_session( self, agent_id: str, @@ -3248,6 +3276,19 @@ class AgentOrchestrator: error=str(sdk_exc), ) + # The live SDK fetch above races the container teardown and misses + # for short-lived agents (counts live in the SDK server's memory, + # which dies with the container). Fall back to the durable source of + # truth: the agent's Claude Code transcript, mounted into this + # container — so usage is captured regardless of container timing. + if not tokens_input and not tokens_output: + tin, tout, cr, cw = self._usage_from_transcript(agent_id) + if tin or tout: + tokens_input = tin + tokens_output = tout + tokens_cache_read = cr + tokens_cache_write = cw + # Look up the model and usage_session_id from the running instance config. instance = self._instances.get(agent_id) if instance and instance.config: