2658deee94
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>
328 lines
12 KiB
Python
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())
|