cctally 1.64.0 → 1.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,23 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.65.0] - 2026-07-09
9
+
10
+ ### Added
11
+ - Opt-in backend performance instrumentation: set `CCTALLY_PERF_TRACE=1` to print a phase-timing trace to stderr on `cache-sync` (and the hidden `tui --render-once` build), and read live backend timings + cache state from the dashboard's loopback-only `/api/debug/backend` endpoint. The trace is off by default and never changes command output — it goes to stderr only, so stdout stays byte-identical. New [docs/backend-performance.md](docs/backend-performance.md) documents the read model, hot paths, and invariants.
12
+ - Reproducible backend benchmark suite (`bin/cctally-bench` + `bin/build-bench-fixtures.py`, a dev/maintainer tool — never shipped): in-process timings for the dashboard snapshot (cold/warm/idle), cache ingest (no-op/delta), the conversation rail/search/find/payload/outline, and the warm reconcile paths, measured against a deterministic synthetic fixture, with a committed advisory baseline and an opt-in `--compare`/`--gate` (never a CI gate) that guard the recent backend performance wins from silent regression. See [bench/README.md](bench/README.md).
13
+ - The opt-in `CCTALLY_PERF_TRACE` backend trace now breaks whole-session conversation assembly into named sub-phases (read, dedup, build, correlate, fold, classify, cost, finalize) under the reader's detail/paginate phases, and the loopback `/api/debug/backend` diagnostic can now surface a conversation-open trace (list/search/outline/find/export/prompts/detail) as well as the data-snapshot build — so a slow transcript open is attributable to a specific stage on a live dashboard; off by default, no command output changes.
14
+ - `cctally-bench --assembly-scan` (dev/maintainer tool): sweeps the cost of assembling a whole conversation across a size ladder against its own isolated fixture and baseline (`bench/baselines/assembly.json`), the measurement behind the recorded decision to defer materializing pre-rendered conversation turns — assembly stays well under a 100 ms visibility budget for all but pathologically large (multi-thousand-turn) sessions, and the reader already caps payload bytes. See [docs/backend-performance.md](docs/backend-performance.md) §5.
15
+ - The conversation browse rail and cross-session search can now be filtered by **model family**: a new "Model" section in the filter popover lists the Opus / Sonnet / Haiku / Fable families that actually appear (each with a session count), and selecting one or more scopes both browsing and search to sessions that used that model — anywhere in the session, including its subagents — with removable chips and "Clear all" just like the other filters (#278).
16
+
17
+ ### Changed
18
+ - Conversation reader no longer re-processes an open conversation on every background refresh — it now recomputes only when the conversation actually grows, cutting idle CPU/battery use for anyone leaving a transcript open (#278).
19
+ - The dashboard now opens instantly on a heavy-history instance: it binds and paints the current-week and forecast panels almost immediately instead of waiting for the full ~2s data aggregation, and the heavier panels (sessions, projects, weekly, monthly, blocks, daily, trend, cache report) hydrate over the live stream showing a brief loading skeleton (respecting reduced-motion) rather than flashing a misleading "no data" / "restart the dashboard" empty state that then fills in (#278).
20
+ - A first-run or long-gap dashboard now fills in progressively as session history is ingested, instead of sitting blank and then snapping to fully-loaded in one jump; a warm returning session is unaffected, and a slow or backgrounded browser tab always converges to the latest data instead of getting stuck on a stale frame (#278).
21
+
22
+ ### Fixed
23
+ - The conversation filter popover now retries once if its filter options (the Project and Model lists) fail to load on a transient hiccup, instead of coming up empty and staying that way until it is reopened (#278).
24
+
8
25
  ## [1.64.0] - 2026-07-07
9
26
 
10
27
  ### Added
@@ -176,6 +176,12 @@ _should_replace = _lib_jsonl._should_replace
176
176
  _lib_conversation = _load_lib("_lib_conversation")
177
177
  _iter_message_rows = _lib_conversation.iter_message_rows
178
178
 
179
+ # Opt-in backend phase-instrumentation collector (issue #276, Session A). Pure
180
+ # stdlib leaf; near-noop when CCTALLY_PERF_TRACE is unset (phase() returns a
181
+ # shared no-op singleton), so the sync_cache seam wraps below cost nothing on
182
+ # the default path.
183
+ _perf = _load_lib("_lib_perf")
184
+
179
185
  # Shared by the fused per-file walk AND backfill_conversation_messages so the
180
186
  # column list, placeholders, and tuple order live in ONE place — a column
181
187
  # add/reorder can't silently desync the two ingest paths (which would land
@@ -857,7 +863,10 @@ def sync_cache(
857
863
 
858
864
  lock_fh = open(_cctally_core.CACHE_LOCK_PATH, "w")
859
865
  try:
860
- if not _acquire_cache_flock(lock_fh, timeout=lock_timeout):
866
+ with _perf.phase("flock") as _p_flock:
867
+ _acquired = _acquire_cache_flock(lock_fh, timeout=lock_timeout)
868
+ _p_flock.set_meta(contended=not _acquired)
869
+ if not _acquired:
861
870
  eprint("[cache] sync already in progress; using existing cache")
862
871
  stats.lock_contended = True
863
872
  return stats
@@ -1044,6 +1053,12 @@ def sync_cache(
1044
1053
  # path, which already cleared the flag and repopulates via the normal
1045
1054
  # walk). A path-less/:memory: conn has no cache_meta only if the schema
1046
1055
  # was never applied; the try/except tolerates that.
1056
+ # #276 perf: bracket the (rare, upgrade-only) backfill/reingest region
1057
+ # as one coarse "backfills" phase. Opened via the context-manager
1058
+ # protocol rather than a ``with`` block so the ~150-line body below is
1059
+ # not reindented. Near-noop when tracing is off (_NULL_PHASE).
1060
+ _p_backfills = _perf.phase("backfills")
1061
+ _p_backfills.__enter__()
1047
1062
  if not rebuild and not targeted:
1048
1063
  try:
1049
1064
  _pending = conn.execute(
@@ -1191,12 +1206,15 @@ def sync_cache(
1191
1206
  # IGNORE). Touches ONLY conversation_file_touches, never
1192
1207
  # conversation_messages (P1-2).
1193
1208
  _consume_file_touches(conn)
1209
+ _p_backfills.__exit__(None, None, None)
1194
1210
 
1195
- if targeted:
1196
- paths = [pathlib.Path(p) for p in only_paths if pathlib.Path(p).is_file()]
1197
- else:
1198
- paths = list(_iter_claude_jsonl_files())
1199
- stats.files_total = len(paths)
1211
+ with _perf.phase("discover") as _p_disc:
1212
+ if targeted:
1213
+ paths = [pathlib.Path(p) for p in only_paths if pathlib.Path(p).is_file()]
1214
+ else:
1215
+ paths = list(_iter_claude_jsonl_files())
1216
+ stats.files_total = len(paths)
1217
+ _p_disc.set_count(len(paths))
1200
1218
 
1201
1219
  # This SELECT does NOT open an implicit transaction (Python's
1202
1220
  # sqlite3 module only BEGINs on DML). Do NOT add any INSERT/
@@ -1406,6 +1424,12 @@ def sync_cache(
1406
1424
  # zero-write-lock read/parse region, so it adds no DML there.
1407
1425
  touched_sessions: set = set()
1408
1426
 
1427
+ # #276 perf: bracket the fused per-file ingest loop as ONE coarse
1428
+ # "walk" phase (never per-row — Section 2 rule: volume is a count, not
1429
+ # N timed phases). Opened via the context-manager protocol so the hot
1430
+ # loop body below is not reindented; counts recorded after the loop.
1431
+ _p_walk = _perf.phase("walk")
1432
+ _p_walk.__enter__()
1409
1433
  for jp in paths:
1410
1434
  path_str = str(jp)
1411
1435
  # Backfill session_id/project_path for A2 `session` subcommand.
@@ -1683,6 +1707,13 @@ def sync_cache(
1683
1707
 
1684
1708
  if progress is not None:
1685
1709
  progress(stats)
1710
+ _p_walk.__exit__(None, None, None)
1711
+ _p_walk.set_count(stats.files_processed)
1712
+ _p_walk.set_meta(
1713
+ skipped=stats.files_skipped_unchanged,
1714
+ failed=stats.files_failed,
1715
+ rows=stats.rows_changed,
1716
+ )
1686
1717
 
1687
1718
  # Browse-rail rollup maintenance (single post-walk recompute, under the
1688
1719
  # still-held flock, after every per-file commit and before the
@@ -1698,16 +1729,17 @@ def sync_cache(
1698
1729
  # ~1 session/tick). Both recomputes derive COUNT/MIN/MAX from the same
1699
1730
  # rows the rail's old live aggregate read, so the rollup stays
1700
1731
  # byte-identical to that aggregate.
1701
- if _conversation_sessions_backfill_pending(conn):
1702
- _recompute_conversation_sessions(conn)
1703
- conn.execute(
1704
- "DELETE FROM cache_meta "
1705
- "WHERE key='conversation_sessions_backfill_pending'"
1706
- )
1707
- conn.commit()
1708
- elif touched_sessions:
1709
- _recompute_conversation_sessions(conn, touched_sessions)
1710
- conn.commit()
1732
+ with _perf.phase("recompute.conversation_sessions"):
1733
+ if _conversation_sessions_backfill_pending(conn):
1734
+ _recompute_conversation_sessions(conn)
1735
+ conn.execute(
1736
+ "DELETE FROM cache_meta "
1737
+ "WHERE key='conversation_sessions_backfill_pending'"
1738
+ )
1739
+ conn.commit()
1740
+ elif touched_sessions:
1741
+ _recompute_conversation_sessions(conn, touched_sessions)
1742
+ conn.commit()
1711
1743
 
1712
1744
  # Walk-complete sentinel write (cctally-dev#93, D5a). Still inside the
1713
1745
  # held fcntl lock, before the finally-unlock. Only when the entire walk
@@ -3370,6 +3402,10 @@ def cmd_cache_sync(args: argparse.Namespace) -> int:
3370
3402
  default is 'all'.
3371
3403
  """
3372
3404
  source = getattr(args, "source", "all")
3405
+ # #276 perf: clear any prior tree on this thread so a leaked root can't be
3406
+ # flushed, then (below) time the Claude sync_cache call as the "sync_cache"
3407
+ # root phase and flush the tree to stderr when CCTALLY_PERF_TRACE is set.
3408
+ _perf.reset_thread()
3373
3409
  conn = open_cache_db()
3374
3410
 
3375
3411
  # --prune-orphans: fast, targeted cleanup of cache rows whose source
@@ -3419,9 +3455,10 @@ def cmd_cache_sync(args: argparse.Namespace) -> int:
3419
3455
  contended = False
3420
3456
 
3421
3457
  if source in ("claude", "all"):
3422
- stats = sync_cache(
3423
- conn, progress=_progress_stderr, rebuild=args.rebuild, lock_timeout=lt
3424
- )
3458
+ with _perf.phase("sync_cache"):
3459
+ stats = sync_cache(
3460
+ conn, progress=_progress_stderr, rebuild=args.rebuild, lock_timeout=lt
3461
+ )
3425
3462
  _progress_stderr(stats, force=True)
3426
3463
  if stats.lock_contended and args.rebuild:
3427
3464
  eprint(
@@ -3456,4 +3493,8 @@ def cmd_cache_sync(args: argparse.Namespace) -> int:
3456
3493
  f"{stats.rows_changed} rows changed"
3457
3494
  )
3458
3495
 
3496
+ # #276 perf: when tracing is enabled, flush the completed "sync_cache"
3497
+ # phase tree to stderr (stdout stays byte-identical). No-op when off.
3498
+ if _perf.enabled():
3499
+ _perf.flush_stderr(_perf.current_root())
3459
3500
  return 1 if contended else 0