Skip to content

Commit 24188c4

Browse files
lwgrayclaude
andauthored
feat(cost): task name snapshot pattern + backfill (#530) (#536)
* feat(cost): task name snapshot pattern + backfill (#530) Cato dashboard's Tokens by task panel currently shows opaque hex IDs (be31d8c7…) instead of real kanban task names. Mirrors the project_names snapshot pattern from PR #515. Schema (cost_store.py) - New task_names table: task_id PRIMARY KEY, name, first_seen, last_seen. Created via SCHEMA_SQL with IF NOT EXISTS — no migration script needed beyond a fresh CREATE on existing DBs. - New record_task_name(task_id, name) + get_task_name(task_id) on CostStore. UPSERT-on-conflict semantics, identical shape to upsert_project_name. Decomposer wiring (integrations/nlp_tools.py) - The decomposer's tasks-with-real-ids loop now snapshots each task's name into costs.db::task_names. Failure is logged and swallowed — cost tracking is optional and must never block project creation. Aggregator (cost_aggregator.py) - by_task in run_summary and project_summary now LEFT JOINs task_names and emits task_name on every slice. NULL when the task_id was never snapshotted. Backfill (scripts/backfill_task_names.py) - One-shot script reading marcus.db::task_metadata into costs.db::task_names. Two passes: 1. Parent tasks: direct copy of (task_id, name). 2. Subtasks: synthesize "<parent name> — subtask N" for IDs like <parent>_sub_N that appear in token_events but not in task_metadata. - Idempotent. UPSERT. Real-data result on a 68,974-row costs.db: Backfilled 292 parent task names from data/marcus.db Backfilled 157 subtask names derived from parents task_id match coverage in token_events: 220 / 893 (was 0 / 893) Tests: 4 in test_cost_store.py (TestTaskNamesSnapshot) + 2 in test_cost_aggregator.py (TestTaskNamesJoin). Refs: #527, #530, #532 (About + Planning task name follow-up) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cost): move task-name snapshot into shared create_tasks_on_board (Codex P2) Codex P2 review on #536: the original Phase 3 wiring put the record_task_name call inside NaturalLanguageProjectCreator.create_project_from_description only. The feature-adder flow (NaturalLanguageFeatureAdder.add_feature_from_description) also creates kanban tasks through the same NaturalLanguageTaskCreator.create_tasks_on_board shared method but never executed the snapshot block. Result: agents working on feature-added tasks would render as opaque hex IDs in the Cato dashboard, defeating Phase 3 for that flow. Move the snapshot into the shared create_tasks_on_board in src/integrations/nlp_base.py, right after the existing task_metadata persistence write. Both flows now covered automatically; the project-creator-specific snapshot block in nlp_tools.py is now redundant and removed. Verifying the recorder failure path is non-fatal — cost tracking is optional and must never block kanban task creation. Tests: 2 new in tests/unit/integrations/test_task_name_snapshot.py. - test_record_task_name_called_for_each_created_task: exercises the shared method with a mock kanban client, asserts every created task lands in task_names with the right (id, name) pair. - test_snapshot_failure_does_not_break_task_creation: broken recorder doesn't propagate into kanban creation. Baseline: 1449 pass under pytest -m unit. mypy clean. Refs: #536 Codex review, #530 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c4e05a5 commit 24188c4

8 files changed

Lines changed: 472 additions & 14 deletions

File tree

scripts/backfill_task_names.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
#!/usr/bin/env python3
2+
"""
3+
One-shot backfill of ``task_names`` from ``marcus.db::task_metadata``.
4+
5+
Marcus's `marcus.db` (collection ``task_metadata`` inside the
6+
key-value ``persistence`` table) is the source of truth for task
7+
names. The cost-tracking dashboard reads from `costs.db::task_names`.
8+
After Marcus #530 added the snapshot path, new tasks land in both
9+
DBs at creation time. Historical tasks created before #530 only
10+
exist in `marcus.db`; the dashboard renders their opaque hex IDs.
11+
12+
This script reads every row in the ``task_metadata`` collection,
13+
extracts ``(task_id, name)``, and calls
14+
:meth:`CostStore.record_task_name` for each. The store's UPSERT
15+
makes the operation idempotent — re-running is safe and a no-op
16+
when nothing has changed.
17+
18+
Usage
19+
-----
20+
.. code-block:: console
21+
22+
$ python scripts/backfill_task_names.py
23+
Backfilled 287 task names from /Users/.../data/marcus.db
24+
Skipped 5 rows (malformed JSON or missing name)
25+
26+
Pass ``--marcus-db PATH`` to point at a non-default Marcus DB.
27+
Pass ``--costs-db PATH`` to override the cost-store path (defaults
28+
to ``~/.marcus/costs.db``).
29+
"""
30+
31+
from __future__ import annotations
32+
33+
import argparse
34+
import json
35+
import sys
36+
from pathlib import Path
37+
from typing import Tuple
38+
39+
# Allow running directly from the repo root without installing.
40+
_REPO_ROOT = Path(__file__).resolve().parent.parent
41+
sys.path.insert(0, str(_REPO_ROOT))
42+
43+
from src.cost_tracking.cost_store import CostStore # noqa: E402
44+
45+
46+
def _parse_args() -> argparse.Namespace:
47+
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
48+
parser.add_argument(
49+
"--marcus-db",
50+
type=Path,
51+
default=_REPO_ROOT / "data" / "marcus.db",
52+
help="Path to Marcus's marcus.db (default: ./data/marcus.db).",
53+
)
54+
parser.add_argument(
55+
"--costs-db",
56+
type=Path,
57+
default=Path.home() / ".marcus" / "costs.db",
58+
help="Path to costs.db (default: ~/.marcus/costs.db).",
59+
)
60+
return parser.parse_args()
61+
62+
63+
def backfill(marcus_db: Path, costs_db: Path) -> Tuple[int, int, int]:
64+
"""Copy ``(task_id, name)`` from ``marcus.db`` into ``costs.db``.
65+
66+
Two passes:
67+
68+
1. **Parent tasks.** Read every row in ``marcus.db::task_metadata``
69+
and snapshot its ``(task_id, name)`` into ``task_names``.
70+
2. **Subtasks.** Marcus decomposes some tasks into subtasks with
71+
IDs like ``<parent_hex>_sub_1`` / ``_sub_2``. Those IDs land in
72+
``token_events.task_id`` but never appear in ``task_metadata``
73+
(only parents do). Walk every distinct ``_sub_N`` ID in
74+
``token_events``, look up the parent's snapshotted name, and
75+
write a derived label (``"<parent name> — subtask N"``) so the
76+
dashboard surfaces something readable instead of a hex tail.
77+
78+
Returns
79+
-------
80+
(parents, subtasks, skipped) : tuple of int
81+
Counts for parent-task names recorded, subtask-derived names
82+
recorded, and rows skipped (malformed JSON, missing name).
83+
"""
84+
import sqlite3
85+
86+
if not marcus_db.exists():
87+
raise FileNotFoundError(f"marcus.db not found at {marcus_db}")
88+
89+
store = CostStore(db_path=costs_db)
90+
91+
src = sqlite3.connect(str(marcus_db))
92+
rows = src.execute(
93+
"SELECT key, data FROM persistence WHERE collection = 'task_metadata'"
94+
).fetchall()
95+
src.close()
96+
97+
# Pass 1: parent-task names from marcus.db.
98+
parents = 0
99+
skipped = 0
100+
for key, raw in rows:
101+
try:
102+
parsed = json.loads(raw) if isinstance(raw, str) else raw
103+
except (json.JSONDecodeError, ValueError, TypeError):
104+
skipped += 1
105+
continue
106+
107+
if not isinstance(parsed, dict):
108+
skipped += 1
109+
continue
110+
111+
task_id = parsed.get("task_id") or key
112+
name = parsed.get("name")
113+
if not isinstance(task_id, str) or not isinstance(name, str) or not name:
114+
skipped += 1
115+
continue
116+
117+
store.record_task_name(task_id, name)
118+
parents += 1
119+
120+
# Pass 2: subtask derived names. Look for ``_sub_N`` task_ids that
121+
# appear in token_events but have no entry in task_names yet, and
122+
# synthesize a label from the parent's snapshotted name.
123+
subtasks = 0
124+
sub_rows = store.conn.execute("""
125+
SELECT DISTINCT te.task_id
126+
FROM token_events te
127+
LEFT JOIN task_names tn USING (task_id)
128+
WHERE te.task_id IS NOT NULL
129+
AND tn.task_id IS NULL
130+
AND instr(te.task_id, '_sub_') > 0
131+
""").fetchall()
132+
for (sub_id,) in sub_rows:
133+
suffix_at = sub_id.find("_sub_")
134+
parent_id = sub_id[:suffix_at]
135+
suffix = sub_id[suffix_at + len("_sub_") :]
136+
parent_name = store.get_task_name(parent_id)
137+
if not parent_name:
138+
continue
139+
store.record_task_name(sub_id, f"{parent_name} — subtask {suffix}")
140+
subtasks += 1
141+
142+
return parents, subtasks, skipped
143+
144+
145+
def main() -> int:
146+
"""Parse CLI args, run the backfill, and print a short summary."""
147+
args = _parse_args()
148+
parents, subtasks, skipped = backfill(args.marcus_db, args.costs_db)
149+
print(f"Backfilled {parents} parent task names from {args.marcus_db}")
150+
if subtasks > 0:
151+
print(f"Backfilled {subtasks} subtask names derived from parents")
152+
if skipped > 0:
153+
print(f"Skipped {skipped} rows (malformed JSON or missing name)")
154+
return 0
155+
156+
157+
if __name__ == "__main__":
158+
raise SystemExit(main())

src/cost_tracking/cost_aggregator.py

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -170,15 +170,21 @@ def run_summary(self, run_id: str) -> Optional[Dict[str, Any]]:
170170
(run_id,),
171171
)
172172

173+
# by_task: LEFT JOIN to task_names so the dashboard can render
174+
# human-readable task names instead of opaque hex IDs (Marcus
175+
# #530). Rows without a name entry come back with task_name=NULL
176+
# and the dashboard falls back to the truncated id.
173177
by_task = self._rows(
174178
"""
175-
SELECT task_id,
179+
SELECT t.task_id,
180+
n.name AS task_name,
176181
COUNT(*) AS events,
177-
COALESCE(SUM(total_tokens), 0) AS tokens,
178-
COALESCE(SUM(cost_usd), 0) AS cost_usd
179-
FROM v_event_cost_inclusive
180-
WHERE run_id = ? AND task_id IS NOT NULL
181-
GROUP BY task_id
182+
COALESCE(SUM(t.total_tokens), 0) AS tokens,
183+
COALESCE(SUM(t.cost_usd), 0) AS cost_usd
184+
FROM v_event_cost_inclusive t
185+
LEFT JOIN task_names n USING (task_id)
186+
WHERE t.run_id = ? AND t.task_id IS NOT NULL
187+
GROUP BY t.task_id, n.name
182188
ORDER BY cost_usd DESC
183189
""",
184190
(run_id,),
@@ -598,15 +604,18 @@ def project_summary(self, project_id: str) -> Optional[Dict[str, Any]]:
598604
(project_id,),
599605
)
600606

607+
# See run_summary for the LEFT JOIN rationale (Marcus #530).
601608
by_task = self._rows(
602609
"""
603-
SELECT task_id,
610+
SELECT t.task_id,
611+
n.name AS task_name,
604612
COUNT(*) AS events,
605-
COALESCE(SUM(total_tokens), 0) AS tokens,
606-
COALESCE(SUM(cost_usd), 0) AS cost_usd
607-
FROM v_event_cost_inclusive
608-
WHERE project_id = ? AND task_id IS NOT NULL
609-
GROUP BY task_id
613+
COALESCE(SUM(t.total_tokens), 0) AS tokens,
614+
COALESCE(SUM(t.cost_usd), 0) AS cost_usd
615+
FROM v_event_cost_inclusive t
616+
LEFT JOIN task_names n USING (task_id)
617+
WHERE t.project_id = ? AND t.task_id IS NOT NULL
618+
GROUP BY t.task_id, n.name
610619
ORDER BY cost_usd DESC
611620
""",
612621
(project_id,),

src/cost_tracking/cost_store.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,20 @@
154154
DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
155155
);
156156
157+
-- Per-task name snapshot (Marcus #530). Mirrors project_names. Marcus
158+
-- writes the kanban task name here when the decomposer first creates
159+
-- the task; Cato joins by task_id in by_task SQL so the dashboard
160+
-- shows real names instead of opaque hex IDs. Idempotent UPSERT keeps
161+
-- the most recent name when a task gets renamed mid-flight.
162+
CREATE TABLE IF NOT EXISTS task_names (
163+
task_id TEXT PRIMARY KEY,
164+
name TEXT NOT NULL,
165+
first_seen TIMESTAMP NOT NULL
166+
DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
167+
last_seen TIMESTAMP NOT NULL
168+
DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
169+
);
170+
157171
-- Project-level budget caps. Set by the dashboard so users can compare
158172
-- spend against a target without needing MLflow experiments. Stored in
159173
-- the cost DB (not ProjectRegistry) because the cap is a cost concept,
@@ -955,6 +969,42 @@ def get_project_name(self, project_id: str) -> Optional[str]:
955969
).fetchone()
956970
return row[0] if row else None
957971

972+
def record_task_name(self, task_id: str, name: str) -> None:
973+
"""Snapshot a kanban task's name into cost storage (Marcus #530).
974+
975+
Mirrors :meth:`upsert_project_name` for tasks. Called by the
976+
decomposer at the moment a task is first created, so the
977+
``by_task`` chart on Cato shows human-readable names instead
978+
of opaque hex IDs. Idempotent: same ``(task_id, name)`` is a
979+
no-op after the first call; a name change updates in place
980+
and bumps ``last_seen``.
981+
982+
Tasks whose name isn't snapshotted still appear in ``by_task``
983+
(the aggregator LEFT JOINs); the dashboard falls back to the
984+
truncated ID for those rows.
985+
"""
986+
if not task_id or not name:
987+
return
988+
self.conn.execute(
989+
"""
990+
INSERT INTO task_names (task_id, name)
991+
VALUES (?, ?)
992+
ON CONFLICT(task_id) DO UPDATE SET
993+
name = excluded.name,
994+
last_seen = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
995+
""",
996+
(task_id, name),
997+
)
998+
self.conn.commit()
999+
1000+
def get_task_name(self, task_id: str) -> Optional[str]:
1001+
"""Return the snapshotted name for ``task_id``, or None."""
1002+
row = self.conn.execute(
1003+
"SELECT name FROM task_names WHERE task_id = ?",
1004+
(task_id,),
1005+
).fetchone()
1006+
return row[0] if row else None
1007+
9581008
def rebind_project_id(self, *, from_id: str, to_id: str) -> int:
9591009
"""Re-attribute every token_events row from one project to another.
9601010

src/integrations/nlp_base.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,31 @@ async def create_tasks_on_board(
184184
f"Failed to log task metadata for '{task.name}': {log_error}"
185185
)
186186

187+
# Snapshot the kanban task's human name into costs.db so the
188+
# Cato dashboard can render real names in "Tokens by task"
189+
# instead of opaque hex IDs (Marcus #530). Lives in the
190+
# shared task-creation method so BOTH create_project and
191+
# add_feature flows are covered automatically — Codex P2 on
192+
# PR #536 caught that the project-only snapshot left the
193+
# feature-adder path silently dropping names.
194+
#
195+
# Failure is logged + swallowed: cost tracking is optional
196+
# and must never block kanban task creation.
197+
try:
198+
from src.cost_tracking.cost_recorder import (
199+
get_recorder as _cost_get_recorder,
200+
)
201+
202+
_cost_store = _cost_get_recorder().store
203+
if kanban_task.id and task.name:
204+
_cost_store.record_task_name(str(kanban_task.id), task.name)
205+
except Exception as _name_err: # pragma: no cover
206+
logger.debug(
207+
"Failed to snapshot task name for %s: %s",
208+
kanban_task.id,
209+
_name_err,
210+
)
211+
187212
except Exception as e:
188213
from src.core.error_framework import (
189214
ErrorContext,

src/integrations/nlp_tools.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1596,8 +1596,11 @@ async def create_project_from_description(
15961596
f"Failed to stage planning phase metadata: {_plan_err}"
15971597
)
15981598

1599-
# NOW create About task AFTER decomposition with real task IDs
1600-
# Map created tasks to original tasks to preserve details
1599+
# NOW create About task AFTER decomposition with real task IDs.
1600+
# Map created tasks to original tasks to preserve details.
1601+
# Task-name snapshotting for the cost dashboard happens in
1602+
# the shared ``create_tasks_on_board`` (nlp_base.py) so
1603+
# both this flow and the feature-adder flow get covered.
16011604
tasks_with_real_ids = []
16021605
for i, created in enumerate(created_tasks):
16031606
if i < len(safe_tasks):

tests/unit/cost_tracking/test_cost_aggregator.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -668,3 +668,31 @@ def test_project_summary_includes_by_tool(self, populated_store: CostStore) -> N
668668
assert result is not None
669669
intents = {r["tool_intent"] for r in result["by_tool"]}
670670
assert "worker_bash" in intents
671+
672+
673+
class TestTaskNamesJoin:
674+
"""``by_task`` LEFT JOINs to task_names so the dashboard sees names
675+
(Marcus #530). Rows without a name entry still appear with NULL.
676+
"""
677+
678+
def test_run_summary_by_task_includes_task_name(
679+
self, populated_store: CostStore
680+
) -> None:
681+
"""When a task_name row exists, by_task surfaces it."""
682+
populated_store.record_task_name("t_1", "Implement scoring logic")
683+
result = CostAggregator(store=populated_store).run_summary("exp_1")
684+
assert result is not None
685+
rows = {r["task_id"]: r for r in result["by_task"]}
686+
assert rows["t_1"]["task_name"] == "Implement scoring logic"
687+
# t_2 has no name entry — LEFT JOIN returns NULL, not a row drop.
688+
assert rows["t_2"]["task_name"] is None
689+
690+
def test_project_summary_by_task_includes_task_name(
691+
self, populated_store: CostStore
692+
) -> None:
693+
"""Same LEFT JOIN on the project-scoped summary."""
694+
populated_store.record_task_name("t_1", "Implement scoring logic")
695+
result = CostAggregator(store=populated_store).project_summary("proj_1")
696+
assert result is not None
697+
rows = {r["task_id"]: r for r in result["by_task"]}
698+
assert rows["t_1"]["task_name"] == "Implement scoring logic"

tests/unit/cost_tracking/test_cost_store.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313

1414
import pytest
1515

16+
pytestmark = pytest.mark.unit
17+
1618
from src.cost_tracking.cost_store import (
1719
CostStore,
1820
ModelPrice,
@@ -878,3 +880,32 @@ def test_load_seed_idempotent(self, store: CostStore) -> None:
878880
"SELECT COUNT(*) FROM model_prices"
879881
).fetchone()[0]
880882
assert first_count == second_count
883+
884+
885+
class TestTaskNamesSnapshot:
886+
"""``task_names`` table mirrors the project_names snapshot pattern (#530)."""
887+
888+
def test_record_and_retrieve_task_name(self, tmp_path: Path) -> None:
889+
"""Round-trip a single task name."""
890+
s = CostStore(db_path=tmp_path / "costs.db")
891+
s.record_task_name("abc123", "Implement scoring logic")
892+
assert s.get_task_name("abc123") == "Implement scoring logic"
893+
894+
def test_upsert_on_conflict(self, tmp_path: Path) -> None:
895+
"""A name change updates in place."""
896+
s = CostStore(db_path=tmp_path / "costs.db")
897+
s.record_task_name("abc123", "Old name")
898+
s.record_task_name("abc123", "New name")
899+
assert s.get_task_name("abc123") == "New name"
900+
901+
def test_empty_inputs_are_no_op(self, tmp_path: Path) -> None:
902+
"""Empty task_id or name silently does nothing — doesn't crash."""
903+
s = CostStore(db_path=tmp_path / "costs.db")
904+
s.record_task_name("", "name")
905+
s.record_task_name("abc", "")
906+
assert s.get_task_name("abc") is None
907+
908+
def test_missing_task_returns_none(self, tmp_path: Path) -> None:
909+
"""get_task_name on an unrecorded id returns None, not an error."""
910+
s = CostStore(db_path=tmp_path / "costs.db")
911+
assert s.get_task_name("never_recorded") is None

0 commit comments

Comments
 (0)