Skip to content

Commit d73f9bb

Browse files
lwgrayclaude
andauthored
feat(cost): worker tool_intent + task_id tracker (#527 Phase 1.5 + 2) (#534)
Closes the task_id attribution gap that PR #528's audit banner caught ("578 worker events missing task_id"), and the tool_intent gap that made the per-operation chart useless for workers ("99.85% of tokens in one bucket called turn"). Both in one PR per Kaia's review (decision 18aea45a) because they share the same content-parsing primitive. What's new ---------- src/cost_tracking/worker_intent.py — pure parsers: - classify_tool_intent(content): walks an assistant message's content blocks and picks one tool_intent bucket from the tool_use block names. Priority: worker_marcus_call > worker_mcp_call > worker_edit > worker_bash > worker_search > worker_read > worker_text > unknown. The marcus_call bucket gets top priority so the coordination tax surfaces as its own slice. - extract_task_id_from_tool_result(content): pulls task_id out of Marcus MCP results. Handles both shapes: result.task.id (request_next_task) result.data.task_id (log_artifact, report_blocker, report_task_progress) - extract_task_id_from_user_message(content): walks a user-message content list, returns the first task_id from any tool_result. src/cost_tracking/worker_ingester.py: - _session_task tracker (Dict[session_id, task_id]) updated on every user record carrying a Marcus tool_result. Consumed when the next assistant record on that session emits a token_events row. binding.task_id wins when the resolver supplies one; the tracker is a fallback for resolvers that only know agent_id + project_id from cwd (which is all Cato's resolver has). - _ingest_record also walks user records now (previously skipped). User records still produce zero rows; they only update tracker state. - backfill_file / backfill_directory: re-walk a JSONL and call store.update_attribution(request_id, task_id, tool_intent) for each assistant row. Used to populate the new columns on historical rows. Idempotent — COALESCE on the SQL side keeps already-set values intact. src/cost_tracking/cost_store.py: - New tool_intent column on token_events. - _tool_intent_migration: idempotent ALTER TABLE ADD COLUMN for existing DBs. Metadata-only operation in SQLite — cheap even on multi-million-row tables. - New index idx_te_intent for by_tool aggregation queries. - record_event INSERT extended to include tool_intent. - New update_attribution(request_id, task_id, tool_intent): COALESCE-guarded UPDATE used by the backfill path. Only fills NULL columns so re-running on already-backfilled rows is a no-op. src/cost_tracking/cost_aggregator.py: - by_tool slice added to both run_summary and project_summary. Excludes NULL tool_intent rows (planner / pre-#527 legacy) so the dashboard chart only shows real worker buckets. Tests ----- - 25 new tests in test_worker_intent.py covering both parsers, legacy/forward compat, multiple result shapes, edge cases. - 11 new tests in test_worker_ingester.py covering the session-task tracker, tool_intent on the row, backfill (fills NULL columns, respects already-populated values). - 3 new tests in test_cost_aggregator.py for the by_tool slice in both summaries. Validation against real costs.db -------------------------------- Backfill on ~/.marcus/costs.db (68,974 worker rows): - task_id coverage: 0% -> 62.5% (43,100 rows attributed) - tool_intent coverage: 0% -> 71.0% (48,947 rows tagged) The remaining gap is rows whose source JSONL is no longer on disk (cleaned-up old runs) — irrecoverable, but the audit banner now surfaces this explicitly instead of silently NULL-ing. For projects whose JSONL still exists, audit drops from 100% worker_events_without_task_id to under 5%. Baseline: 1454 passed, 1 skipped under `pytest -m unit`; mypy clean across src/cost_tracking/. Refs: #527, #528 (Phase 1), Simon decision 18aea45a Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8806fe4 commit d73f9bb

7 files changed

Lines changed: 1124 additions & 5 deletions

File tree

src/cost_tracking/cost_aggregator.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,25 @@ def run_summary(self, run_id: str) -> Optional[Dict[str, Any]]:
222222
(run_id,),
223223
)
224224

225+
# by_tool: spend grouped by Claude Code tool the agent invoked on
226+
# each turn (Marcus #527 Phase 2). Worker-only — planner rows
227+
# have tool_intent NULL. Coordination overhead (worker_marcus_call)
228+
# surfaces here as its own slice, which is the metric users most
229+
# want to see and the chart can't display today.
230+
by_tool = self._rows(
231+
"""
232+
SELECT tool_intent,
233+
COUNT(*) AS events,
234+
COALESCE(SUM(total_tokens), 0) AS tokens,
235+
COALESCE(SUM(cost_usd), 0) AS cost_usd
236+
FROM v_event_cost_inclusive
237+
WHERE run_id = ? AND tool_intent IS NOT NULL
238+
GROUP BY tool_intent
239+
ORDER BY cost_usd DESC
240+
""",
241+
(run_id,),
242+
)
243+
225244
return {
226245
**meta,
227246
"summary": totals,
@@ -230,6 +249,7 @@ def run_summary(self, run_id: str) -> Optional[Dict[str, Any]]:
230249
"by_task": by_task,
231250
"by_operation": by_operation,
232251
"by_model": by_model,
252+
"by_tool": by_tool,
233253
"audit": self.run_audit(run_id),
234254
}
235255

@@ -658,6 +678,21 @@ def project_summary(self, project_id: str) -> Optional[Dict[str, Any]]:
658678
(project_id,),
659679
)
660680

681+
# by_tool: same shape as run_summary's by_tool. See #527 Phase 2.
682+
by_tool = self._rows(
683+
"""
684+
SELECT tool_intent,
685+
COUNT(*) AS events,
686+
COALESCE(SUM(total_tokens), 0) AS tokens,
687+
COALESCE(SUM(cost_usd), 0) AS cost_usd
688+
FROM v_event_cost_inclusive
689+
WHERE project_id = ? AND tool_intent IS NOT NULL
690+
GROUP BY tool_intent
691+
ORDER BY cost_usd DESC
692+
""",
693+
(project_id,),
694+
)
695+
661696
return {
662697
"project_id": project_id,
663698
"summary": totals,
@@ -666,5 +701,6 @@ def project_summary(self, project_id: str) -> Optional[Dict[str, Any]]:
666701
"by_task": by_task,
667702
"by_operation": by_operation,
668703
"by_model": by_model,
704+
"by_tool": by_tool,
669705
"audit": self.project_audit(project_id),
670706
}

src/cost_tracking/cost_store.py

Lines changed: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,13 @@
9090
task_id TEXT,
9191
subtask_id TEXT,
9292
operation TEXT NOT NULL,
93+
-- ``tool_intent`` (Marcus #527 Phase 2): which Claude Code tool the
94+
-- agent invoked on this turn. Populated by ``worker_intent.py`` from
95+
-- the JSONL ``message.content`` tool_use blocks. NULL on planner
96+
-- rows and on legacy worker rows from before the parser landed.
97+
-- Values: worker_marcus_call / worker_mcp_call / worker_edit /
98+
-- worker_bash / worker_search / worker_read / worker_text / unknown.
99+
tool_intent TEXT,
93100
provider TEXT NOT NULL,
94101
model TEXT NOT NULL,
95102
input_tokens INTEGER NOT NULL DEFAULT 0,
@@ -119,6 +126,7 @@
119126
CREATE INDEX IF NOT EXISTS idx_te_run_agent ON token_events(run_id, agent_id);
120127
CREATE INDEX IF NOT EXISTS idx_te_task ON token_events(task_id);
121128
CREATE INDEX IF NOT EXISTS idx_te_op_model ON token_events(operation, model);
129+
CREATE INDEX IF NOT EXISTS idx_te_intent ON token_events(tool_intent);
122130
CREATE INDEX IF NOT EXISTS idx_te_timestamp ON token_events(timestamp);
123131
-- Partial unique index for dedup. Worker JSONL ingestion may sweep the
124132
-- same session files repeatedly (e.g. Cato's 30s dashboard poll re-runs
@@ -276,6 +284,7 @@ class TokenEvent:
276284
parent_agent_id: Optional[str] = None
277285
task_id: Optional[str] = None
278286
subtask_id: Optional[str] = None
287+
tool_intent: Optional[str] = None
279288
latency_ms: Optional[int] = None
280289
session_id: Optional[str] = None
281290
turn_index: Optional[int] = None
@@ -570,6 +579,7 @@ def _init_schema(self) -> None:
570579
"""
571580
self._runs_rename_migration()
572581
self._dedup_pre_index_migration()
582+
self._tool_intent_migration()
573583
self.conn.executescript(SCHEMA_SQL)
574584
self.conn.commit()
575585

@@ -643,6 +653,37 @@ def _runs_rename_migration(self) -> None:
643653
)
644654
self.conn.commit()
645655

656+
def _tool_intent_migration(self) -> None:
657+
"""Add ``token_events.tool_intent`` to existing DBs (Marcus #527 Phase 2).
658+
659+
Fresh installs get the column via SCHEMA_SQL. For an existing
660+
cost DB the column doesn't exist; this migration adds it as
661+
nullable so old rows survive with NULL until a backfill runs.
662+
Idempotent: detects the column via ``PRAGMA table_info`` and
663+
skips when already present.
664+
665+
Designed to be cheap and lock-friendly: a single ``ALTER TABLE
666+
ADD COLUMN`` is a metadata-only operation in SQLite, so it
667+
doesn't rewrite the table even on a multi-million-row
668+
``token_events``.
669+
"""
670+
cols = {
671+
row[1]
672+
for row in self.conn.execute("PRAGMA table_info(token_events)").fetchall()
673+
}
674+
if "token_events" not in {
675+
r[0]
676+
for r in self.conn.execute(
677+
"SELECT name FROM sqlite_master WHERE type='table'"
678+
).fetchall()
679+
}:
680+
# Fresh install — SCHEMA_SQL will create the column.
681+
return
682+
if "tool_intent" in cols:
683+
return
684+
self.conn.execute("ALTER TABLE token_events ADD COLUMN tool_intent TEXT")
685+
self.conn.commit()
686+
646687
def _dedup_pre_index_migration(self) -> None:
647688
"""Compact duplicate ``request_id`` rows before the index lands.
648689
@@ -698,11 +739,11 @@ def record_event(self, event: TokenEvent) -> int:
698739
"""
699740
INSERT OR IGNORE INTO token_events (
700741
run_id, project_id, agent_id, agent_role,
701-
parent_agent_id, task_id, subtask_id, operation,
742+
parent_agent_id, task_id, subtask_id, operation, tool_intent,
702743
provider, model, input_tokens, cache_creation_tokens,
703744
cache_read_tokens, output_tokens, latency_ms, session_id,
704745
turn_index, request_id, status, error_type, timestamp
705-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
746+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
706747
COALESCE(?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')))
707748
""",
708749
(
@@ -714,6 +755,7 @@ def record_event(self, event: TokenEvent) -> int:
714755
event.task_id,
715756
event.subtask_id,
716757
event.operation,
758+
event.tool_intent,
717759
event.provider,
718760
event.model,
719761
event.input_tokens,
@@ -745,6 +787,56 @@ def record_event(self, event: TokenEvent) -> int:
745787
raise RuntimeError("sqlite3 did not return lastrowid")
746788
return event_id
747789

790+
def update_attribution(
791+
self,
792+
request_id: str,
793+
*,
794+
task_id: Optional[str] = None,
795+
tool_intent: Optional[str] = None,
796+
) -> int:
797+
"""Backfill ``task_id`` and/or ``tool_intent`` on an existing row.
798+
799+
Used by the worker JSONL backfill path (Marcus #527 Phase 1.5
800+
+ 2): historical rows were written before the ingester knew
801+
how to populate these fields. A re-walk of the JSONL files
802+
runs the parser and calls this helper to fill in the gaps.
803+
804+
Idempotent and side-effect-light: only updates columns whose
805+
current value is NULL, so re-running on already-backfilled
806+
rows is a no-op. Returns the number of rows updated (0 or 1)
807+
so callers can report progress.
808+
809+
Parameters
810+
----------
811+
request_id : str
812+
The Claude Code per-call ID used as the upsert key.
813+
task_id : str, optional
814+
New ``task_id``. Only written if the row's current
815+
``task_id`` is NULL.
816+
tool_intent : str, optional
817+
New ``tool_intent``. Only written if the row's current
818+
``tool_intent`` is NULL.
819+
820+
Returns
821+
-------
822+
int
823+
``cur.rowcount`` from the UPDATE — 0 if no row matched
824+
the ``request_id``, 1 if a row was updated. Note: SQLite
825+
still reports 1 when the WHERE clause matched but the
826+
COALESCE guards left every column unchanged.
827+
"""
828+
cur = self.conn.execute(
829+
"""
830+
UPDATE token_events
831+
SET task_id = COALESCE(task_id, ?),
832+
tool_intent = COALESCE(tool_intent, ?)
833+
WHERE request_id = ?
834+
""",
835+
(task_id, tool_intent, request_id),
836+
)
837+
self.conn.commit()
838+
return cur.rowcount
839+
748840
def record_run(self, run: Run) -> None:
749841
"""Upsert one ``runs`` row keyed by ``run_id``.
750842

src/cost_tracking/worker_ingester.py

Lines changed: 127 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@
3939

4040
from src.cost_tracking.cost_recorder import canonical_project_id
4141
from src.cost_tracking.cost_store import CostStore, TokenEvent
42+
from src.cost_tracking.worker_intent import (
43+
classify_tool_intent,
44+
extract_task_id_from_user_message,
45+
)
4246

4347
logger = logging.getLogger(__name__)
4448

@@ -117,6 +121,13 @@ def __init__(
117121
self.resolve_binding = resolve_binding
118122
self._turn_counter: Dict[str, int] = defaultdict(int)
119123
self._seen_uuids: Set[str] = set()
124+
# Per-session running task tracker (Marcus #527 Phase 1.5).
125+
# Updated when a user-record carries a Marcus MCP tool_result
126+
# echoing a task_id; consumed when the next assistant record on
127+
# the same session emits a token_events row. Lives per-instance
128+
# so a single ingest_file call walks the file in order with the
129+
# tracker following the agent's task lifecycle.
130+
self._session_task: Dict[str, str] = {}
120131

121132
# -- file-level API ---------------------------------------------------
122133

@@ -155,18 +166,119 @@ def ingest_directory(self, dir_path: Path) -> int:
155166
total += self.ingest_file(jsonl)
156167
return total
157168

169+
def backfill_file(self, path: Path) -> int:
170+
"""Walk a JSONL file and backfill ``task_id`` / ``tool_intent``.
171+
172+
Used to populate the new columns on rows that were ingested
173+
before the parser landed (Marcus #527 Phase 1.5 + 2). Walks
174+
the file in order, maintains the same per-session task
175+
tracker as :meth:`ingest_file`, and calls
176+
:meth:`CostStore.update_attribution` for each assistant
177+
record carrying a ``request_id``. Skips rows that don't exist
178+
in the DB (the UPDATE rowcount comes back zero) and rows
179+
whose columns are already populated (the store's
180+
``COALESCE`` guards no-op them).
181+
182+
Idempotent: safe to re-run on the same file. The store's
183+
``update_attribution`` only fills NULL columns, so a second
184+
pass after manual edits will not overwrite curated values.
185+
186+
Returns
187+
-------
188+
int
189+
Number of rows whose ``task_id`` and/or ``tool_intent``
190+
were filled in by this call.
191+
"""
192+
# Use a fresh per-call tracker so backfill state doesn't bleed
193+
# into a subsequent ingest_file call (or vice versa).
194+
tracker: Dict[str, str] = {}
195+
updated = 0
196+
with path.open("r", encoding="utf-8") as fh:
197+
for raw in fh:
198+
line = raw.strip()
199+
if not line:
200+
continue
201+
try:
202+
record = json.loads(line)
203+
except json.JSONDecodeError:
204+
continue
205+
206+
session_id = record.get("sessionId") or "unknown"
207+
record_type = record.get("type")
208+
209+
if record_type == "user":
210+
message = record.get("message") or {}
211+
content = (
212+
message.get("content") if isinstance(message, dict) else None
213+
)
214+
tid = extract_task_id_from_user_message(content)
215+
if tid is not None:
216+
tracker[session_id] = tid
217+
continue
218+
219+
if record_type != "assistant":
220+
continue
221+
222+
request_id = record.get("requestId")
223+
if not request_id:
224+
continue
225+
226+
message = record.get("message") or {}
227+
content = message.get("content") if isinstance(message, dict) else None
228+
tool_intent = classify_tool_intent(content)
229+
task_id = tracker.get(session_id)
230+
231+
# classify_tool_intent always returns a non-None string, so
232+
# there's always at least one column to potentially fill.
233+
# update_attribution's COALESCE guards turn this into a
234+
# no-op when the row already has those values populated.
235+
rowcount = self.store.update_attribution(
236+
request_id=request_id,
237+
task_id=task_id,
238+
tool_intent=tool_intent,
239+
)
240+
if rowcount > 0:
241+
updated += 1
242+
return updated
243+
244+
def backfill_directory(self, dir_path: Path) -> int:
245+
"""Recursively backfill every ``*.jsonl`` file under a directory."""
246+
total = 0
247+
for jsonl in sorted(dir_path.rglob("*.jsonl")):
248+
total += self.backfill_file(jsonl)
249+
return total
250+
158251
# -- record-level helpers --------------------------------------------
159252

160253
def _ingest_record(self, record: Dict[str, Any]) -> bool:
161254
"""Insert one token_events row if the record is an assistant turn.
162255
256+
Also walks ``user`` records (tool_result carriers) to update
257+
the per-session task tracker (Marcus #527 Phase 1.5) — those
258+
records produce no row themselves but update the state used by
259+
the next assistant record on the same session.
260+
163261
Returns
164262
-------
165263
bool
166264
True if a row was inserted, False if the record was skipped
167265
(wrong type, missing usage, deduped, or binding rejected).
168266
"""
169-
if record.get("type") != "assistant":
267+
session_id = record.get("sessionId") or "unknown"
268+
record_type = record.get("type")
269+
270+
# User records carry tool_results — scan them for Marcus MCP
271+
# task_id echoes and update the tracker. No row written. The
272+
# tracker is per-session so concurrent agents don't bleed.
273+
if record_type == "user":
274+
message = record.get("message") or {}
275+
content = message.get("content") if isinstance(message, dict) else None
276+
tid = extract_task_id_from_user_message(content)
277+
if tid is not None:
278+
self._session_task[session_id] = tid
279+
return False
280+
281+
if record_type != "assistant":
170282
return False
171283
message = record.get("message") or {}
172284
usage = message.get("usage") if isinstance(message, dict) else None
@@ -181,10 +293,21 @@ def _ingest_record(self, record: Dict[str, Any]) -> bool:
181293
if binding is None:
182294
return False
183295

184-
session_id = record.get("sessionId") or "unknown"
185296
self._turn_counter[session_id] += 1
186297
turn_index = self._turn_counter[session_id]
187298

299+
# task_id: prefer caller-supplied binding, fall back to the
300+
# tracker. The fallback covers the common case where the
301+
# resolver only knows agent_id/project_id from the cwd and
302+
# never sets task_id; the tracker fills it in from MCP
303+
# tool-result history.
304+
resolved_task_id = binding.task_id or self._session_task.get(session_id)
305+
306+
# tool_intent: classify the assistant message's content (#527
307+
# Phase 2). Pure function in worker_intent.py.
308+
content = message.get("content") if isinstance(message, dict) else None
309+
tool_intent = classify_tool_intent(content)
310+
188311
# Normalize project_id to the cost-data canonical form (dashless
189312
# hex). Bindings come from spawn_agents.py via project_info.json,
190313
# which may carry either dashed or dashless UUIDs depending on
@@ -199,7 +322,8 @@ def _ingest_record(self, record: Dict[str, Any]) -> bool:
199322
agent_id=binding.agent_id,
200323
agent_role="worker",
201324
parent_agent_id=binding.parent_agent_id,
202-
task_id=binding.task_id,
325+
task_id=resolved_task_id,
326+
tool_intent=tool_intent,
203327
operation="turn",
204328
provider="anthropic",
205329
model=str(message.get("model", "unknown")),

0 commit comments

Comments
 (0)