mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
fix(usage): attribute agent transcripts by an orchestrator-assigned session id
Review/coordinate roles (qa, cell_pm, main_pm, auditor) run at the image
WORKDIR /app — intentionally, since that's how they read/grep the codebase — so
their Claude Code transcript lands in the shared ~/.claude/projects/-app dir,
not a per-agent *-{slug} dir. _usage_from_transcript globbed *-{slug}, so it
never found theirs and their token usage was never captured (silently invisible
on the dashboard).
Pin each agent's Claude session id at spawn (--session-id <uuid>, stored on
AgentConfig) and locate the transcript by that id at finalize and in the live
sweep — across ANY project dir. The load-bearing /app cwd is untouched (agents
read the codebase exactly as before); only attribution changes, and it now
works for every role. Falls back to the old slug glob when no session id is set
(in-flight pre-upgrade agents).
This commit is contained in:
@@ -41,6 +41,10 @@ class OrchestratorAgentConfig:
|
|||||||
model: str = "sonnet" # sonnet, opus, haiku, or any ollama-cloud tag
|
model: str = "sonnet" # sonnet, opus, haiku, or any ollama-cloud tag
|
||||||
mcp_config_path: Path | None = None
|
mcp_config_path: Path | None = None
|
||||||
working_directory: Path | None = None
|
working_directory: Path | None = None
|
||||||
|
# Orchestrator-assigned Claude Code session id (passed to the agent CLI as
|
||||||
|
# --session-id) so the agent's transcript can be located by id at finalize,
|
||||||
|
# regardless of which project/cwd dir Claude Code writes it to.
|
||||||
|
claude_session_id: str | None = None
|
||||||
# Git context for tasks requiring git workflow
|
# Git context for tasks requiring git workflow
|
||||||
git_context: SpawnGitContext | None = None
|
git_context: SpawnGitContext | None = None
|
||||||
# Pre-rendered SessionStart briefing mounted as /app/briefing.md
|
# Pre-rendered SessionStart briefing mounted as /app/briefing.md
|
||||||
|
|||||||
@@ -1396,11 +1396,14 @@ class AgentOrchestrator:
|
|||||||
await self._ensure_agent_image(agent_id)
|
await self._ensure_agent_image(agent_id)
|
||||||
mcp_config_path = await self._generate_mcp_config(agent_id, git_context)
|
mcp_config_path = await self._generate_mcp_config(agent_id, git_context)
|
||||||
|
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
config = AgentConfig(
|
config = AgentConfig(
|
||||||
agent_id=agent_id,
|
agent_id=agent_id,
|
||||||
blueprint_path=blueprint_path,
|
blueprint_path=blueprint_path,
|
||||||
model=model,
|
model=model,
|
||||||
mcp_config_path=mcp_config_path,
|
mcp_config_path=mcp_config_path,
|
||||||
|
claude_session_id=str(uuid4()),
|
||||||
git_context=git_context,
|
git_context=git_context,
|
||||||
briefing_path=briefing_path,
|
briefing_path=briefing_path,
|
||||||
provider_type=route.provider_type.value,
|
provider_type=route.provider_type.value,
|
||||||
@@ -1804,25 +1807,28 @@ class AgentOrchestrator:
|
|||||||
`_get_role_permissions`), so this is purely about loading vs
|
`_get_role_permissions`), so this is purely about loading vs
|
||||||
denying.
|
denying.
|
||||||
"""
|
"""
|
||||||
cmd.extend(
|
claude_args = [
|
||||||
[
|
get_agent_image(config.agent_id),
|
||||||
get_agent_image(config.agent_id),
|
"--model",
|
||||||
"--model",
|
cls._resolve_cli_model(config),
|
||||||
cls._resolve_cli_model(config),
|
"--system-prompt-file",
|
||||||
"--system-prompt-file",
|
"/app/system-prompt.md",
|
||||||
"/app/system-prompt.md",
|
"--mcp-config",
|
||||||
"--mcp-config",
|
"/app/mcp-config.json",
|
||||||
"/app/mcp-config.json",
|
"--strict-mcp-config",
|
||||||
"--strict-mcp-config",
|
"--tools",
|
||||||
"--tools",
|
"Read,Write,Edit,Bash,Grep,Glob,Task,TodoWrite",
|
||||||
"Read,Write,Edit,Bash,Grep,Glob,Task,TodoWrite",
|
"--output-format",
|
||||||
"--output-format",
|
"stream-json",
|
||||||
"stream-json",
|
"--verbose",
|
||||||
"--verbose",
|
]
|
||||||
"-p",
|
# Pin the Claude session id so the agent's transcript is locatable by id
|
||||||
initial_prompt or cls._default_spawn_prompt(),
|
# at finalize, regardless of which project/cwd dir Claude Code writes it
|
||||||
]
|
# to (review/coordinate roles run at /app, not a per-agent workspace).
|
||||||
)
|
if config.claude_session_id:
|
||||||
|
claude_args += ["--session-id", config.claude_session_id]
|
||||||
|
claude_args += ["-p", initial_prompt or cls._default_spawn_prompt()]
|
||||||
|
cmd.extend(claude_args)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _resolve_cli_model(config: AgentConfig) -> str:
|
def _resolve_cli_model(config: AgentConfig) -> str:
|
||||||
@@ -3207,21 +3213,37 @@ class AgentOrchestrator:
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
def _claude_session_id_for(self, agent_id: str) -> str | None:
|
||||||
def _usage_from_transcript(agent_id: str) -> tuple[int, int, int, int]:
|
"""The orchestrator-assigned Claude session id for a running agent."""
|
||||||
"""Sum token usage from the agent's newest Claude Code transcript.
|
instance = self._instances.get(agent_id)
|
||||||
|
return (
|
||||||
|
instance.config.claude_session_id if instance and instance.config else None
|
||||||
|
)
|
||||||
|
|
||||||
The host ``~/.claude`` is mounted into the orchestrator, so each agent's
|
@staticmethod
|
||||||
transcripts are readable here under ``projects/*-{slug}/`` (Claude Code
|
def _usage_from_transcript(
|
||||||
encodes the agent's workspace cwd into the dir name, which ends in the
|
agent_id: str, claude_session_id: str | None = None
|
||||||
agent slug). The durable fallback for the live SDK ``/usage/status``
|
) -> tuple[int, int, int, int]:
|
||||||
fetch, which misses whenever the agent container is short-lived or
|
"""Sum token usage from the agent's Claude Code transcript.
|
||||||
already torn down. Returns zeros when no transcript is found.
|
|
||||||
|
The host ``~/.claude`` is mounted into the orchestrator, so transcripts
|
||||||
|
are readable here under ``projects/<cwd-dir>/<session-id>.jsonl``. When
|
||||||
|
the orchestrator-assigned ``claude_session_id`` is known we locate the
|
||||||
|
exact transcript by id across ANY project dir — review/coordinate roles
|
||||||
|
run at cwd ``/app`` so theirs lands in ``projects/-app``, not in a
|
||||||
|
per-agent ``projects/*-{slug}`` dir. Without an id we fall back to the
|
||||||
|
newest transcript in the agent's own workspace dir. Durable fallback for
|
||||||
|
the live SDK ``/usage/status`` fetch, which misses for short-lived or
|
||||||
|
torn-down agents. Returns zeros when no transcript is found.
|
||||||
"""
|
"""
|
||||||
from roboco.agent_sdk.transcript_usage import sum_transcript_usage
|
from roboco.agent_sdk.transcript_usage import sum_transcript_usage
|
||||||
|
|
||||||
projects = Path.home() / ".claude" / "projects"
|
projects = Path.home() / ".claude" / "projects"
|
||||||
try:
|
try:
|
||||||
|
if claude_session_id:
|
||||||
|
by_id = list(projects.glob(f"*/{claude_session_id}.jsonl"))
|
||||||
|
if by_id:
|
||||||
|
return sum_transcript_usage(by_id[0])
|
||||||
jsonl = [
|
jsonl = [
|
||||||
f
|
f
|
||||||
for d in projects.glob(f"*-{agent_id}")
|
for d in projects.glob(f"*-{agent_id}")
|
||||||
@@ -3267,7 +3289,9 @@ class AgentOrchestrator:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not tokens[0] and not tokens[1]:
|
if not tokens[0] and not tokens[1]:
|
||||||
tin, tout, cr, cw = self._usage_from_transcript(agent_id)
|
tin, tout, cr, cw = self._usage_from_transcript(
|
||||||
|
agent_id, self._claude_session_id_for(agent_id)
|
||||||
|
)
|
||||||
if tin or tout:
|
if tin or tout:
|
||||||
tokens = (tin, tout, cr, cw)
|
tokens = (tin, tout, cr, cw)
|
||||||
return tokens
|
return tokens
|
||||||
@@ -3403,7 +3427,9 @@ class AgentOrchestrator:
|
|||||||
tokens = await self._fetch_agent_tokens(client, agent_id)
|
tokens = await self._fetch_agent_tokens(client, agent_id)
|
||||||
if tokens is not None:
|
if tokens is not None:
|
||||||
return tokens
|
return tokens
|
||||||
transcript = self._usage_from_transcript(agent_id)
|
transcript = self._usage_from_transcript(
|
||||||
|
agent_id, self._claude_session_id_for(agent_id)
|
||||||
|
)
|
||||||
return transcript if any(transcript) else None
|
return transcript if any(transcript) else None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ Coverage:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -648,3 +649,65 @@ async def test_resolve_active_tokens_prefers_sdk() -> None:
|
|||||||
|
|
||||||
assert tokens == (10, 20, 0, 0)
|
assert tokens == (10, 20, 0, 0)
|
||||||
mock_tx.assert_not_called()
|
mock_tx.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _usage_from_transcript — locate by session id across any project dir
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_usage_from_transcript_finds_by_session_id_in_shared_app_dir(
|
||||||
|
tmp_path: Path, monkeypatch: Any
|
||||||
|
) -> None:
|
||||||
|
"""A session id locates the transcript even in the shared -app dir.
|
||||||
|
|
||||||
|
Review/coordinate roles run at cwd /app, so their transcript lands in
|
||||||
|
projects/-app, not a per-agent projects/*-{slug} dir. The session id finds
|
||||||
|
it regardless; the slug glob (no *-main-pm dir here) would return zeros.
|
||||||
|
"""
|
||||||
|
app_dir = tmp_path / ".claude" / "projects" / "-app"
|
||||||
|
app_dir.mkdir(parents=True)
|
||||||
|
sid = "11111111-1111-1111-1111-111111111111"
|
||||||
|
exp_in, exp_out, exp_cr, exp_cw = 12, 34, 5, 6
|
||||||
|
line = json.dumps(
|
||||||
|
{
|
||||||
|
"type": "assistant",
|
||||||
|
"message": {
|
||||||
|
"id": "m1",
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": exp_in,
|
||||||
|
"output_tokens": exp_out,
|
||||||
|
"cache_read_input_tokens": exp_cr,
|
||||||
|
"cache_creation_input_tokens": exp_cw,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
(app_dir / f"{sid}.jsonl").write_text(line + "\n", encoding="utf-8")
|
||||||
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||||
|
|
||||||
|
result = AgentOrchestrator._usage_from_transcript("main-pm", sid)
|
||||||
|
assert result == (exp_in, exp_out, exp_cr, exp_cw)
|
||||||
|
|
||||||
|
|
||||||
|
def test_usage_from_transcript_without_session_id_uses_slug_glob(
|
||||||
|
tmp_path: Path, monkeypatch: Any
|
||||||
|
) -> None:
|
||||||
|
"""Without a session id it still finds the agent's own workspace transcript."""
|
||||||
|
slug_dir = tmp_path / ".claude" / "projects" / "-data-ws-roboco-backend-be-dev-1"
|
||||||
|
slug_dir.mkdir(parents=True)
|
||||||
|
exp_in, exp_out = 7, 8
|
||||||
|
line = json.dumps(
|
||||||
|
{
|
||||||
|
"type": "assistant",
|
||||||
|
"message": {
|
||||||
|
"id": "m1",
|
||||||
|
"usage": {"input_tokens": exp_in, "output_tokens": exp_out},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
(slug_dir / "sess.jsonl").write_text(line + "\n", encoding="utf-8")
|
||||||
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||||
|
|
||||||
|
result = AgentOrchestrator._usage_from_transcript("be-dev-1")
|
||||||
|
assert result == (exp_in, exp_out, 0, 0)
|
||||||
|
|||||||
Reference in New Issue
Block a user