Files
cleveragents-core/tools/token_usage_audit.py
T
drew 2658deee94 feat(auto-agents): PR State Warmer substrate + supporting infra
Adds a long-lived sidecar (pr_state_warmer.py) that polls Forgejo's
/pulls endpoint every 30s and writes the full PR snapshot to a
shared SQLite store, eliminating the dispatcher's per-cycle
cold-cache stalls (24-30s rebuilds on flaky cycles) and the silent
50-PR pagination cap on the legacy single-page fetch.

Substrate
- tools/_pr_state_cache.py  — SQLite store with (owner, repo) PK,
  WAL mode, additive v2→v3 migration (comments_refreshed_updated_at),
  bounded fcntl.flock migration lock, threading.Lock for per-process
  init, @_with_reheal decorator (catches OperationalError no-such-
  table + DatabaseError corruption with file quarantine), atomic
  TEMP-table chunking for >32k seen-set, _normalize_updated_at to
  canonicalize Forgejo tz-marker drift
- tools/pr_state_warmer.py — poll/upsert/vanish/comments-refresh
  loop with fcntl.flock singleton (rejects second warmer), bounded
  comments-refresh cap, persistent deferral via SQL pending query,
  PermissionError-tolerant lock setup, cold-start log suppression
- tools/_pr_classification_cache.py — three-layer fall-through
  (warmer cache → list cache → live fetch) with staleness gate
  (PR_STATE_WARMER_STALE_AFTER_S floored at 30s in prod)

Comments cache hardening
- Bot-filter at write time drops bot status/claim/release/sentinel
  while preserving **Implementation Attempt** markers (94.6%
  reduction on bot-heavy PRs like #30's 19k-comment thread)
- _normalize_since_cursor strips microsecond precision before
  building ?since= query (fixes the live-observed Forgejo HTTP 422
  bug on PRs #25 + #28); handles uppercase Z, lowercase z, ±HH:MM
  offsets (including non-zero like +05:30), naive ISO
- Lazy migration of legacy null-key by_author entries on _read_cache
- _newest_cursor walks tail-back skipping malformed entries

Supporting infrastructure (cumulative dmpipeline-v2 work)
- Telemetry server: SSE live tail, run-sessions enumeration,
  cost/token tracking, app.js UI rewrite with collapsible sections
- MCP servers (mcp_ci_server, mcp_forgejo_server, mcp_git_server,
  mcp_handoff_server, mcp_graphify_server) for opencode worker
  context access
- Live log writer (tools/live_log_writer.py) — SSE-streaming
  dispatcher event log
- Tier-dispatcher escalation flow with prompts trimmed for budget
- Shared bot-logins resolver (tools/_bot_logins.py) replacing two
  drift-prone copies
- token_usage_audit.py for opencode cost analysis

Tests
- 2259 passing across 65 changed/new files
- New suites: test_pr_state_cache, test_pr_state_warmer,
  test_pr_state_warmer_integration, test_pr_classification_cache,
  test_pr_list_cache_backoff, test_mcp_* (5 servers),
  test_live_log_writer_sse, test_telemetry_run_sessions,
  test_review_post_ready_label
- Test_pr_comments_cache expanded with bot-filter coverage,
  cursor-normalization regression pins, format-drift, atomicity,
  failed-comments-not-stamped (silent-data-loss class)
- Parametrized @_with_reheal coverage across 7 wrapped APIs
- Real fault-inject atomicity test for chunked mark_vanished path
  via Connection wrapper class
- Subprocess-based singleton flock test (cross-process contract)
- Event-driven SIGTERM-mid-poll test (no fixed-sleep flake)

Architecture notes
- Schema v3 migration is additive (ALTER ADD COLUMN); v0/v1 still
  need destructive rebuild because pre-v2 column shape lacks
  owner/repo. Cross-process drop-table-ping-pong prevented by the
  fcntl migration lock + per-process _initialized flag.
- Comments-refresh deferral is persistent via
  comments_refreshed_updated_at column — survives warmer restart,
  picks up next cycle even if PR didn't change again. Replaces
  in-memory changed_numbers list.
- Rollback path: PR_STATE_WARMER_PREFER=0 bypasses the warmer
  cache and reverts to live-fetch behavior. PR_STATE_CACHE_DISABLE=1
  short-circuits the warmer process at startup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 10:02:53 -04:00

328 lines
12 KiB
Python

"""Aggregate historical token usage across the two surfaces that
consume LLM tokens in this project:
1. **Claude Code interactive sessions** — JSONL transcripts at
``~/.claude/projects/-home-drew-repos-cleveragents-core/*.jsonl``.
Each line carries an event; assistant-message events embed a
``message.usage`` object with input / cache-read /
cache-creation / output token counts.
2. **OpenCode worker sessions** (the deterministic dispatcher
pipeline — reviewer, implementer, merge, conflict) — JSON
archives at ``<repo>/.dispatcher-logs/sessions/*.json``. The
``per_turn`` array holds per-turn token counts (input, output,
reasoning).
Reads everything, sums by day and by agent/source, and emits a
structured JSON summary. Designed to be run BEFORE installing a new
LLM-affecting tool (e.g. graphify) to establish a baseline, then
again later for a before/after comparison.
Usage:
python3 tools/token_usage_audit.py [--out PATH]
Without ``--out`` the summary prints to stdout. With ``--out PATH``
it writes the summary to that path and prints a one-line digest.
The summary is intentionally serialisable: dates as ISO strings,
totals as integers, agents/sessions as sortable keys. A later
re-run produces a comparable shape so a diff is straightforward.
"""
from __future__ import annotations
import argparse
import json
import sys
from collections import defaultdict
from collections.abc import Iterable
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
REPO_ROOT = Path(__file__).resolve().parent.parent
CLAUDE_PROJECT_DIR = (
Path.home() / ".claude" / "projects" / "-home-drew-repos-cleveragents-core"
)
OPENCODE_ARCHIVE_DIR = REPO_ROOT / ".dispatcher-logs" / "sessions"
def _iter_jsonl(path: Path) -> Iterable[dict[str, Any]]:
"""Yield every JSON object from a JSONL file, swallowing
parse errors (malformed lines are rare and shouldn't kill the
whole audit). The harness writes one event per line."""
try:
with path.open("r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
yield json.loads(line)
except json.JSONDecodeError:
continue
except OSError:
return
def _date_of(timestamp: str | None) -> str | None:
"""Extract YYYY-MM-DD from an ISO-8601 timestamp; return None
on any parse failure so the caller can decide."""
if not timestamp or not isinstance(timestamp, str):
return None
try:
# Trim sub-millisecond precision Python's stdlib parser
# rejects on some platforms.
dt = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
return dt.astimezone(timezone.utc).date().isoformat()
except (ValueError, TypeError):
return None
def audit_claude_code() -> dict[str, Any]:
"""Walk every Claude Code JSONL transcript for this project and
sum per-assistant-message usage by date.
Returns a dict with:
- ``sessions``: count of distinct ``sessionId`` values seen
- ``messages_with_usage``: count of assistant messages whose
usage block we summed
- ``by_date``: mapping ``"YYYY-MM-DD"`` → totals dict
- ``totals``: grand totals across every transcript
"""
by_date: dict[str, dict[str, int]] = defaultdict(
lambda: {"input": 0, "cache_read": 0, "cache_create": 0, "output": 0, "messages": 0}
)
sessions: set[str] = set()
messages_with_usage = 0
if not CLAUDE_PROJECT_DIR.exists():
return {
"exists": False,
"path": str(CLAUDE_PROJECT_DIR),
"sessions": 0,
"messages_with_usage": 0,
"by_date": {},
"totals": {"input": 0, "cache_read": 0, "cache_create": 0, "output": 0, "messages": 0},
}
for jsonl_path in sorted(CLAUDE_PROJECT_DIR.glob("*.jsonl")):
for event in _iter_jsonl(jsonl_path):
session_id = event.get("sessionId")
if isinstance(session_id, str):
sessions.add(session_id)
msg = event.get("message")
if not isinstance(msg, dict):
continue
if msg.get("role") != "assistant":
continue
usage = msg.get("usage")
if not isinstance(usage, dict):
continue
date = _date_of(event.get("timestamp")) or "unknown"
bucket = by_date[date]
bucket["input"] += int(usage.get("input_tokens") or 0)
bucket["cache_read"] += int(usage.get("cache_read_input_tokens") or 0)
bucket["cache_create"] += int(usage.get("cache_creation_input_tokens") or 0)
bucket["output"] += int(usage.get("output_tokens") or 0)
bucket["messages"] += 1
messages_with_usage += 1
totals = {"input": 0, "cache_read": 0, "cache_create": 0, "output": 0, "messages": 0}
for d in by_date.values():
for k, v in d.items():
totals[k] += v
return {
"exists": True,
"path": str(CLAUDE_PROJECT_DIR),
"sessions": len(sessions),
"messages_with_usage": messages_with_usage,
"by_date": dict(sorted(by_date.items())),
"totals": totals,
}
def audit_opencode() -> dict[str, Any]:
"""Walk every OpenCode session archive and sum per_turn tokens.
Returns a dict with:
- ``archives``: count of archive files inspected
- ``by_date``: mapping ``"YYYY-MM-DD"`` → totals dict
- ``by_agent``: mapping ``agent_name`` → totals dict
- ``totals``: grand totals
"""
by_date: dict[str, dict[str, int]] = defaultdict(
lambda: {"input": 0, "output": 0, "reasoning": 0, "turns": 0, "sessions": 0}
)
by_agent: dict[str, dict[str, int]] = defaultdict(
lambda: {"input": 0, "output": 0, "reasoning": 0, "turns": 0, "sessions": 0}
)
archives_seen = 0
if not OPENCODE_ARCHIVE_DIR.exists():
return {
"exists": False,
"path": str(OPENCODE_ARCHIVE_DIR),
"archives": 0,
"by_date": {},
"by_agent": {},
"totals": {"input": 0, "output": 0, "reasoning": 0, "turns": 0, "sessions": 0},
}
for archive_path in sorted(OPENCODE_ARCHIVE_DIR.glob("*.json")):
try:
with archive_path.open("r", encoding="utf-8") as f:
archive = json.load(f)
except (OSError, json.JSONDecodeError):
continue
archives_seen += 1
agent = str(archive.get("agent") or "unknown")
date = _date_of(archive.get("started_at")) or "unknown"
per_turn = archive.get("per_turn") or []
if not isinstance(per_turn, list):
continue
sess_in = sess_out = sess_reason = sess_turns = 0
for turn in per_turn:
if not isinstance(turn, dict):
continue
sess_in += int(turn.get("input_tokens") or 0)
sess_out += int(turn.get("output_tokens") or 0)
sess_reason += int(turn.get("reasoning_tokens") or 0)
sess_turns += 1
by_date[date]["input"] += sess_in
by_date[date]["output"] += sess_out
by_date[date]["reasoning"] += sess_reason
by_date[date]["turns"] += sess_turns
by_date[date]["sessions"] += 1
by_agent[agent]["input"] += sess_in
by_agent[agent]["output"] += sess_out
by_agent[agent]["reasoning"] += sess_reason
by_agent[agent]["turns"] += sess_turns
by_agent[agent]["sessions"] += 1
totals = {"input": 0, "output": 0, "reasoning": 0, "turns": 0, "sessions": 0}
for d in by_date.values():
for k, v in d.items():
totals[k] += v
return {
"exists": True,
"path": str(OPENCODE_ARCHIVE_DIR),
"archives": archives_seen,
"by_date": dict(sorted(by_date.items())),
"by_agent": dict(sorted(by_agent.items())),
"totals": totals,
}
def _format_int(n: int) -> str:
return f"{n:>14,}"
def render_digest(summary: dict[str, Any]) -> str:
"""Produce a human-readable one-shot digest. Numbers are
right-aligned for easy column comparison between baseline and
follow-up runs."""
cc = summary["claude_code"]
oc = summary["opencode"]
lines = [
"Token usage audit",
f"Generated at: {summary['generated_at']}",
"",
"=== Claude Code (interactive Claude Code sessions) ===",
f" transcripts dir: {cc['path']}",
f" sessions: {cc['sessions']}",
f" assistant messages: {cc['messages_with_usage']}",
f" total input tokens: {_format_int(cc['totals']['input'])}",
f" total cache_read: {_format_int(cc['totals']['cache_read'])}",
f" total cache_create: {_format_int(cc['totals']['cache_create'])}",
f" total output tokens: {_format_int(cc['totals']['output'])}",
"",
" by date (assistant messages, input + cache_read + cache_create + output):",
]
for date, d in cc["by_date"].items():
total = d["input"] + d["cache_read"] + d["cache_create"] + d["output"]
lines.append(
f" {date} msgs={d['messages']:>4} total_tokens={total:>14,} "
f"(in={d['input']:>9,} c_r={d['cache_read']:>11,} "
f"c_w={d['cache_create']:>11,} out={d['output']:>8,})"
)
lines += [
"",
"=== OpenCode (dispatcher worker sessions) ===",
f" archives dir: {oc['path']}",
f" sessions: {oc['totals']['sessions']}",
f" assistant turns: {oc['totals']['turns']}",
f" total input tokens: {_format_int(oc['totals']['input'])}",
f" total output tokens: {_format_int(oc['totals']['output'])}",
f" total reasoning tokens: {_format_int(oc['totals']['reasoning'])}",
"",
" by date:",
]
for date, d in oc["by_date"].items():
total = d["input"] + d["output"] + d["reasoning"]
lines.append(
f" {date} sess={d['sessions']:>3} turns={d['turns']:>4} "
f"total_tokens={total:>12,} "
f"(in={d['input']:>11,} out={d['output']:>9,} reason={d['reasoning']:>9,})"
)
lines += ["", " by agent (top consumers by total tokens):"]
agent_rows = sorted(
oc["by_agent"].items(),
key=lambda kv: -(kv[1]["input"] + kv[1]["output"] + kv[1]["reasoning"]),
)
for agent, d in agent_rows:
total = d["input"] + d["output"] + d["reasoning"]
lines.append(
f" {agent:<32} sess={d['sessions']:>3} turns={d['turns']:>4} "
f"total_tokens={total:>12,} "
f"(in={d['input']:>11,} out={d['output']:>9,} reason={d['reasoning']:>9,})"
)
return "\n".join(lines)
def build_summary() -> dict[str, Any]:
return {
"generated_at": datetime.now(timezone.utc).isoformat(),
"claude_code": audit_claude_code(),
"opencode": audit_opencode(),
}
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--out",
type=Path,
help="Write the JSON summary to this path (otherwise prints the digest to stdout).",
)
parser.add_argument(
"--json-only",
action="store_true",
help="Print the JSON summary to stdout instead of the human digest.",
)
args = parser.parse_args(argv)
summary = build_summary()
if args.out:
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(json.dumps(summary, indent=2), encoding="utf-8")
digest = render_digest(summary)
print(f"wrote {args.out}\n")
print(digest)
elif args.json_only:
print(json.dumps(summary, indent=2))
else:
print(render_digest(summary))
return 0
if __name__ == "__main__":
raise SystemExit(main())