cctally 1.82.0 → 1.83.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +52 -74
  3. package/bin/_cctally_alerts.py +8 -1
  4. package/bin/_cctally_cache.py +963 -149
  5. package/bin/_cctally_config.py +43 -4
  6. package/bin/_cctally_core.py +933 -759
  7. package/bin/_cctally_dashboard.py +157 -47
  8. package/bin/_cctally_dashboard_cache_report.py +13 -6
  9. package/bin/_cctally_dashboard_conversation.py +1 -0
  10. package/bin/_cctally_dashboard_envelope.py +186 -8
  11. package/bin/_cctally_dashboard_share.py +60 -20
  12. package/bin/_cctally_dashboard_sources.py +427 -128
  13. package/bin/_cctally_db.py +605 -128
  14. package/bin/_cctally_doctor.py +413 -28
  15. package/bin/_cctally_five_hour.py +12 -5
  16. package/bin/_cctally_journal.py +2050 -156
  17. package/bin/_cctally_journal_repair.py +519 -0
  18. package/bin/_cctally_milestone_history.py +142 -56
  19. package/bin/_cctally_milestones.py +179 -111
  20. package/bin/_cctally_parser.py +42 -0
  21. package/bin/_cctally_project.py +24 -18
  22. package/bin/_cctally_quota.py +139 -25
  23. package/bin/_cctally_record.py +279 -108
  24. package/bin/_cctally_rederive.py +1052 -0
  25. package/bin/_cctally_reporting.py +58 -53
  26. package/bin/_cctally_setup.py +1 -0
  27. package/bin/_cctally_source_analytics.py +4 -1
  28. package/bin/_cctally_statusline.py +11 -11
  29. package/bin/_cctally_store.py +1039 -31
  30. package/bin/_cctally_sync_week.py +17 -8
  31. package/bin/_cctally_tui.py +421 -54
  32. package/bin/_cctally_update.py +133 -8
  33. package/bin/_cctally_weekrefs.py +14 -0
  34. package/bin/_lib_aggregators.py +10 -6
  35. package/bin/_lib_cache_report.py +101 -9
  36. package/bin/_lib_codex_pools.py +82 -0
  37. package/bin/_lib_conversation_query.py +126 -33
  38. package/bin/_lib_dashboard_sources.py +126 -1
  39. package/bin/_lib_diff_kernel.py +28 -15
  40. package/bin/_lib_doctor.py +342 -4
  41. package/bin/_lib_journal.py +924 -2
  42. package/bin/_lib_jsonl.py +43 -14
  43. package/bin/_lib_pricing.py +140 -21
  44. package/bin/_lib_readme_refresh.py +401 -0
  45. package/bin/_lib_rederive.py +395 -0
  46. package/bin/_lib_share.py +58 -2
  47. package/bin/cctally +56 -8
  48. package/dashboard/static/assets/{index-DJP4gEB7.js → index-3bgCMVHb.js} +52 -52
  49. package/dashboard/static/assets/index-D27EIHEI.css +1 -0
  50. package/dashboard/static/dashboard.html +2 -2
  51. package/package.json +6 -1
  52. package/dashboard/static/assets/index-Dk1nplOz.css +0 -1
@@ -103,11 +103,12 @@ import fcntl
103
103
  import json
104
104
  import os
105
105
  import pathlib
106
+ import select
106
107
  import signal
107
108
  import sqlite3
108
109
  import sys
109
110
  import time
110
- from dataclasses import dataclass, field
111
+ from dataclasses import asdict, dataclass, field
111
112
  from typing import Any, Callable, Iterator, NamedTuple
112
113
 
113
114
 
@@ -197,6 +198,10 @@ _perf = _load_lib("_lib_perf")
197
198
  # _arm_rollup_backfill_on_pricing_change so a test may monkeypatch it.
198
199
  PRICING_SNAPSHOT_DATE = _load_lib("_lib_pricing").PRICING_SNAPSHOT_DATE
199
200
 
201
+ # #195: the single construction point for every cost-feeding usage dict. Bound
202
+ # from the same circular-safe stdlib leaf as PRICING_SNAPSHOT_DATE above.
203
+ claude_usage_dict = _load_lib("_lib_pricing").claude_usage_dict
204
+
200
205
  # Shared by the fused per-file walk AND backfill_conversation_messages so the
201
206
  # column list, placeholders, and tuple order live in ONE place — a column
202
207
  # add/reorder can't silently desync the two ingest paths (which would land
@@ -223,6 +228,129 @@ _AI_TITLE_UPSERT_SQL = (
223
228
  "ai_title=excluded.ai_title, source_path=excluded.source_path, byte_offset=excluded.byte_offset"
224
229
  )
225
230
 
231
+ # ---------------------------------------------------------------------------
232
+ # session_entries upsert (#195: extracted from the inline string in sync_cache
233
+ # so the steady-state and re-walk variants share ONE body).
234
+ #
235
+ # ccusage-parity ON CONFLICT DO UPDATE: higher-token total wins on conflict;
236
+ # speed-set breaks ties. The partial UNIQUE index `idx_entries_dedup` restricts
237
+ # the conflict target to (msg_id IS NOT NULL AND req_id IS NOT NULL), so the
238
+ # WHERE clause on the conflict target MUST repeat that predicate verbatim —
239
+ # bare `ON CONFLICT(msg_id, req_id)` raises OperationalError. NULL-keyed rows
240
+ # fall through to a plain INSERT, unchanged.
241
+ #
242
+ # `source_path` is INTENTIONALLY OMITTED from the DO UPDATE SET clause: it
243
+ # stays pinned to whichever JSONL FIRST INSERTed the (msg_id, req_id) row. The
244
+ # downstream `LEFT JOIN session_files ON sf.path = se.source_path` uses
245
+ # source_path to attribute tokens to a `project_path`. If a later UPSERT from a
246
+ # different file flipped source_path, the row's project attribution would move
247
+ # with the winner — `cctally project` would mis-aggregate. Sticky source_path
248
+ # matches pre-dedup INSERT OR IGNORE behavior and the operator's mental model.
249
+ # (`line_offset` is similarly sticky for the same reason — the offset only
250
+ # makes sense within the file that originally wrote the row.)
251
+ #
252
+ # `account_key` is DELIBERATELY OMITTED from DO UPDATE SET too (#341,
253
+ # first-stamp-wins): a resumed session replaying identical bytes under a
254
+ # different account is the SAME message and keeps the first observed stamp.
255
+ _SESSION_ENTRY_SET = """
256
+ timestamp_utc = excluded.timestamp_utc,
257
+ model = excluded.model,
258
+ input_tokens = excluded.input_tokens,
259
+ output_tokens = excluded.output_tokens,
260
+ cache_create_tokens = excluded.cache_create_tokens,
261
+ cache_read_tokens = excluded.cache_read_tokens,
262
+ cache_create_1h_tokens = excluded.cache_create_1h_tokens,
263
+ cache_create_5m_tokens = excluded.cache_create_5m_tokens,
264
+ usage_extra_json = excluded.usage_extra_json,
265
+ speed = excluded.speed,
266
+ cost_usd_raw = excluded.cost_usd_raw,
267
+ -- #270: stamp the change. mutation_seq advances
268
+ -- exactly when this guarded UPSERT's WHERE passes
269
+ -- (incl. the equal-tokens speed-tiebreak branch,
270
+ -- Codex-2d). mutation_min_ts accumulates the
271
+ -- EARLIEST event time the row has held —
272
+ -- session_entries.mutation_min_ts is the OLD
273
+ -- (pre-update) value, excluded.timestamp_utc the
274
+ -- finalization's new time — so a finalization
275
+ -- that moves the row across a bucket boundary
276
+ -- still lets the closed-bucket watermark reach
277
+ -- the OLD bucket (spec §6/§7b). The SET reads
278
+ -- pre-update column values, unaffected by the
279
+ -- sibling timestamp_utc = excluded.timestamp_utc.
280
+ -- COALESCE(mutation_min_ts, timestamp_utc) guards
281
+ -- a LEGACY row (written before these columns
282
+ -- existed: mutation_min_ts NULL): SQLite scalar
283
+ -- MIN(NULL, x) is NULL, which would strand the
284
+ -- watermark; the pre-update timestamp_utc is that
285
+ -- legacy row's old event time, so both its old
286
+ -- and new buckets stay reachable. No-op for
287
+ -- non-legacy rows (mutation_min_ts already set).
288
+ mutation_seq = excluded.mutation_seq,
289
+ mutation_min_ts = MIN(COALESCE(session_entries.mutation_min_ts,
290
+ session_entries.timestamp_utc),
291
+ excluded.timestamp_utc)"""
292
+
293
+ # The third guard branch (#195) mirrors the existing `speed` tiebreak: a replay
294
+ # of IDENTICAL bytes has an EQUAL token sum, so without it the enrichment can
295
+ # never land on an existing row.
296
+ _SESSION_ENTRY_GUARD = """
297
+ WHERE
298
+ (excluded.input_tokens + excluded.output_tokens
299
+ + excluded.cache_create_tokens + excluded.cache_read_tokens)
300
+ >
301
+ (session_entries.input_tokens + session_entries.output_tokens
302
+ + session_entries.cache_create_tokens + session_entries.cache_read_tokens)
303
+ OR (
304
+ (excluded.input_tokens + excluded.output_tokens
305
+ + excluded.cache_create_tokens + excluded.cache_read_tokens)
306
+ =
307
+ (session_entries.input_tokens + session_entries.output_tokens
308
+ + session_entries.cache_create_tokens + session_entries.cache_read_tokens)
309
+ AND excluded.speed IS NOT NULL
310
+ AND session_entries.speed IS NULL
311
+ )
312
+ OR (
313
+ (excluded.input_tokens + excluded.output_tokens
314
+ + excluded.cache_create_tokens + excluded.cache_read_tokens)
315
+ =
316
+ (session_entries.input_tokens + session_entries.output_tokens
317
+ + session_entries.cache_create_tokens + session_entries.cache_read_tokens)
318
+ AND excluded.cache_create_1h_tokens IS NOT NULL
319
+ AND session_entries.cache_create_1h_tokens IS NULL
320
+ )"""
321
+
322
+ # Column order is the bind order of the tuples built in `sync_cache` (13 walk
323
+ # columns, then the two #195 split columns, then the three #270/#341 stamps
324
+ # appended by `stamped_rows`). Keep the two in lockstep.
325
+ _SESSION_ENTRY_HEAD = """INSERT INTO session_entries
326
+ (source_path, line_offset, timestamp_utc, model,
327
+ msg_id, req_id, input_tokens, output_tokens,
328
+ cache_create_tokens, cache_read_tokens,
329
+ usage_extra_json, speed, cost_usd_raw,
330
+ cache_create_1h_tokens, cache_create_5m_tokens,
331
+ mutation_seq, mutation_min_ts, account_key)
332
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
333
+ ON CONFLICT(msg_id, req_id)
334
+ WHERE msg_id IS NOT NULL AND req_id IS NOT NULL
335
+ DO UPDATE SET"""
336
+
337
+ # Steady state: ONE conflict target. A duplicate physical key stays a LOUD
338
+ # IntegrityError — migration 020 calls those "strictly ingest-bug artifacts"
339
+ # and that backstop must not be silently converted into an update.
340
+ SESSION_ENTRY_UPSERT_SQL = _SESSION_ENTRY_HEAD + _SESSION_ENTRY_SET + _SESSION_ENTRY_GUARD
341
+
342
+ # Re-walk only (#195 migration 030): rows are NOT wiped first, so a row the
343
+ # partial dedup index does not cover (NULL msg_id and/or req_id) collides on
344
+ # idx_entries_physical instead. SQLite does not route that through the first
345
+ # target's handler, so it needs its own clause or the whole per-file
346
+ # transaction rolls back and that file is silently skipped forever.
347
+ SESSION_ENTRY_UPSERT_SQL_REWALK = (
348
+ SESSION_ENTRY_UPSERT_SQL
349
+ + """
350
+ ON CONFLICT(source_path, line_offset)
351
+ DO UPDATE SET"""
352
+ + _SESSION_ENTRY_SET + _SESSION_ENTRY_GUARD)
353
+
226
354
 
227
355
  def _conv_row_tuple(m, path_str):
228
356
  """Flatten a ``MessageRow`` into the ``_CONV_INSERT_SQL`` column order.
@@ -1714,6 +1842,15 @@ def _ensure_session_files_row(conn: sqlite3.Connection, source_path: str) -> Non
1714
1842
  # Read at call time in cmd_cache_sync so tests can monkeypatch it low.
1715
1843
  _REBUILD_LOCK_TIMEOUT_SECONDS = 30.0
1716
1844
 
1845
+ # #395: an explicit transcript rebuild runs each provider in a disposable child
1846
+ # process. A provider phase may legitimately be large, so the production bound
1847
+ # measures time without a phase/file progress event, not total wall time. The
1848
+ # important contract is that a truly stuck phase is finite and process-level
1849
+ # (SQLite/Python work is never unsafely cancelled in the parent). Tests patch
1850
+ # this module constant to exercise the real timeout path quickly.
1851
+ _TRANSCRIPT_REBUILD_PHASE_TIMEOUT_SECONDS = 30.0 * 60.0
1852
+ _TRANSCRIPT_REBUILD_KILL_GRACE_SECONDS = 1.0
1853
+
1717
1854
 
1718
1855
  # Orphan-warning throttle: warn only when the detected orphan set CHANGES,
1719
1856
  # so a long-lived dashboard doesn't re-spam the "[cache] N tracked file(s) no
@@ -2604,6 +2741,19 @@ def sync_cache(
2604
2741
  # "walk" phase (never per-row — Section 2 rule: volume is a count, not
2605
2742
  # N timed phases). Opened via the context-manager protocol so the hot
2606
2743
  # loop body below is not reindented; counts recorded after the loop.
2744
+ # #195: is the cache-write-split re-walk armed? Computed ONCE per
2745
+ # sync_cache call, before the file loop, so every file in this walk uses
2746
+ # one statement. Cache migration 030 sets this flag and zeroes the
2747
+ # per-file cursors; the end-of-walk block below clears it after a clean,
2748
+ # non-targeted full walk. While armed, the chained-conflict variant is
2749
+ # used so a replayed NULL-key row updates in place instead of raising
2750
+ # IntegrityError and rolling back its whole file. Scoped to THIS flag
2751
+ # (not marker-absence) so migration 020's loud duplicate-physical-key
2752
+ # backstop stays intact on every other ingest path.
2753
+ rewalk_armed = conn.execute(
2754
+ "SELECT 1 FROM cache_meta WHERE key=?",
2755
+ (_cctally_db_sib.CACHE_CREATION_SPLIT_REWALK_KEY,),
2756
+ ).fetchone() is not None
2607
2757
  _p_walk = _perf.phase("walk")
2608
2758
  _p_walk.__enter__()
2609
2759
  for jp in paths:
@@ -2679,6 +2829,13 @@ def sync_cache(
2679
2829
  # serializing the deeply-nested blob the read paths
2680
2830
  # used to json.loads per row.
2681
2831
  speed = usage.get("speed")
2832
+ # #195: the cache-write TTL split, normalized out of
2833
+ # the nested `usage.cache_creation` by
2834
+ # `_classify_cost_entry`. Absent keys stay None,
2835
+ # which stores NULL — the "split unknown" sentinel
2836
+ # the pricing kernel branches on.
2837
+ h = usage.get("cache_creation_1h_input_tokens")
2838
+ m = usage.get("cache_creation_5m_input_tokens")
2682
2839
  rows.append((
2683
2840
  path_str,
2684
2841
  offset,
@@ -2690,6 +2847,7 @@ def sync_cache(
2690
2847
  None, # usage_extra_json — bloat no longer written (#181)
2691
2848
  speed, # materialized speed column
2692
2849
  entry.cost_usd,
2850
+ h, m, # #195 cache-write TTL split
2693
2851
  ))
2694
2852
  if mrow is not None:
2695
2853
  conv_rows.append(_conv_row_tuple(mrow, path_str))
@@ -2770,68 +2928,14 @@ def sync_cache(
2770
2928
  # (`line_offset` is similarly sticky for the same
2771
2929
  # reason — the offset only makes sense within the
2772
2930
  # file that originally wrote the row.)
2773
- conn.executemany(
2774
- """INSERT INTO session_entries
2775
- (source_path, line_offset, timestamp_utc, model,
2776
- msg_id, req_id, input_tokens, output_tokens,
2777
- cache_create_tokens, cache_read_tokens,
2778
- usage_extra_json, speed, cost_usd_raw,
2779
- mutation_seq, mutation_min_ts, account_key)
2780
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
2781
- ON CONFLICT(msg_id, req_id)
2782
- WHERE msg_id IS NOT NULL AND req_id IS NOT NULL
2783
- DO UPDATE SET
2784
- timestamp_utc = excluded.timestamp_utc,
2785
- model = excluded.model,
2786
- input_tokens = excluded.input_tokens,
2787
- output_tokens = excluded.output_tokens,
2788
- cache_create_tokens = excluded.cache_create_tokens,
2789
- cache_read_tokens = excluded.cache_read_tokens,
2790
- usage_extra_json = excluded.usage_extra_json,
2791
- speed = excluded.speed,
2792
- cost_usd_raw = excluded.cost_usd_raw,
2793
- -- #270: stamp the change. mutation_seq advances
2794
- -- exactly when this guarded UPSERT's WHERE passes
2795
- -- (incl. the equal-tokens speed-tiebreak branch,
2796
- -- Codex-2d). mutation_min_ts accumulates the
2797
- -- EARLIEST event time the row has held —
2798
- -- session_entries.mutation_min_ts is the OLD
2799
- -- (pre-update) value, excluded.timestamp_utc the
2800
- -- finalization's new time — so a finalization
2801
- -- that moves the row across a bucket boundary
2802
- -- still lets the closed-bucket watermark reach
2803
- -- the OLD bucket (spec §6/§7b). The SET reads
2804
- -- pre-update column values, unaffected by the
2805
- -- sibling timestamp_utc = excluded.timestamp_utc.
2806
- -- COALESCE(mutation_min_ts, timestamp_utc) guards
2807
- -- a LEGACY row (written before these columns
2808
- -- existed: mutation_min_ts NULL): SQLite scalar
2809
- -- MIN(NULL, x) is NULL, which would strand the
2810
- -- watermark; the pre-update timestamp_utc is that
2811
- -- legacy row's old event time, so both its old
2812
- -- and new buckets stay reachable. No-op for
2813
- -- non-legacy rows (mutation_min_ts already set).
2814
- mutation_seq = excluded.mutation_seq,
2815
- mutation_min_ts = MIN(COALESCE(session_entries.mutation_min_ts,
2816
- session_entries.timestamp_utc),
2817
- excluded.timestamp_utc)
2818
- WHERE
2819
- (excluded.input_tokens + excluded.output_tokens
2820
- + excluded.cache_create_tokens + excluded.cache_read_tokens)
2821
- >
2822
- (session_entries.input_tokens + session_entries.output_tokens
2823
- + session_entries.cache_create_tokens + session_entries.cache_read_tokens)
2824
- OR (
2825
- (excluded.input_tokens + excluded.output_tokens
2826
- + excluded.cache_create_tokens + excluded.cache_read_tokens)
2827
- =
2828
- (session_entries.input_tokens + session_entries.output_tokens
2829
- + session_entries.cache_create_tokens + session_entries.cache_read_tokens)
2830
- AND excluded.speed IS NOT NULL
2831
- AND session_entries.speed IS NULL
2832
- )""",
2833
- stamped_rows,
2834
- )
2931
+ # #195: while the re-walk is armed, rows are NOT wiped
2932
+ # first, so a row the partial dedup index does not cover
2933
+ # collides on idx_entries_physical and would roll back the
2934
+ # whole per-file transaction. Steady state keeps the
2935
+ # single-target SQL and its LOUD physical-key backstop.
2936
+ _sql = (SESSION_ENTRY_UPSERT_SQL_REWALK if rewalk_armed
2937
+ else SESSION_ENTRY_UPSERT_SQL)
2938
+ conn.executemany(_sql, stamped_rows)
2835
2939
  stats.rows_changed += conn.total_changes - before
2836
2940
  # Conversation message ingest (Plan 1). Lands in the SAME
2837
2941
  # per-file write transaction as session_entries so the cost
@@ -2964,6 +3068,14 @@ def sync_cache(
2964
3068
  "ON CONFLICT(key) DO UPDATE SET value = excluded.value",
2965
3069
  (dt.datetime.now(dt.timezone.utc).isoformat(),),
2966
3070
  )
3071
+ # #195: the same clean-full-walk condition retires the split
3072
+ # re-walk arming flag, so steady state goes back to the
3073
+ # single-target UPSERT and its loud physical-key backstop. An
3074
+ # unclean or targeted walk leaves it armed and retries next time.
3075
+ conn.execute(
3076
+ "DELETE FROM cache_meta WHERE key=?",
3077
+ (_cctally_db_sib.CACHE_CREATION_SPLIT_REWALK_KEY,),
3078
+ )
2967
3079
  conn.commit()
2968
3080
  # #279 S2 F1: rolling parse-health record. Anomaly-delta-gated so
2969
3081
  # steady-state (incl. targeted live-tail) syncs stay zero-write;
@@ -3664,7 +3776,7 @@ def iter_entries(
3664
3776
  sql = (
3665
3777
  "SELECT timestamp_utc, model, input_tokens, output_tokens, "
3666
3778
  "cache_create_tokens, cache_read_tokens, speed, "
3667
- "cost_usd_raw, source_path "
3779
+ "cost_usd_raw, source_path, cache_create_1h_tokens "
3668
3780
  "FROM session_entries "
3669
3781
  "WHERE timestamp_utc >= ? AND timestamp_utc <= ?"
3670
3782
  )
@@ -3698,18 +3810,16 @@ def iter_entries(
3698
3810
 
3699
3811
  entries: list[UsageEntry] = []
3700
3812
  for row in conn.execute(sql, params):
3701
- usage: dict[str, Any] = {
3702
- "input_tokens": row[2],
3703
- "output_tokens": row[3],
3704
- "cache_creation_input_tokens": row[4],
3705
- "cache_read_input_tokens": row[5],
3706
- }
3707
- # speed is the only non-token usage key any consumer reads (#181);
3708
- # materialized into its own column so this hot path never parses JSON.
3709
- # `is not None` (not truthiness) so an empty-string speed still surfaces,
3710
- # mirroring the SQL `json_extract(...) IS NOT NULL` parity.
3711
- if row[6] is not None:
3712
- usage["speed"] = row[6]
3813
+ # #195: one construction point for every cost-feeding usage dict.
3814
+ # `cache_1h_tokens` is a REQUIRED keyword — see claude_usage_dict.
3815
+ usage: dict[str, Any] = claude_usage_dict(
3816
+ input_tokens=row[2],
3817
+ output_tokens=row[3],
3818
+ cache_creation_tokens=row[4],
3819
+ cache_read_tokens=row[5],
3820
+ cache_1h_tokens=row[9],
3821
+ speed=row[6],
3822
+ )
3713
3823
  entries.append(UsageEntry(
3714
3824
  timestamp=dt.datetime.fromisoformat(row[0]),
3715
3825
  model=row[1],
@@ -3753,7 +3863,8 @@ def iter_entries_with_id(
3753
3863
  end_iso = range_end.astimezone(dt.timezone.utc).isoformat()
3754
3864
  sql = (
3755
3865
  "SELECT id, timestamp_utc, model, input_tokens, output_tokens, "
3756
- "cache_create_tokens, cache_read_tokens, speed, cost_usd_raw, source_path "
3866
+ "cache_create_tokens, cache_read_tokens, speed, cost_usd_raw, source_path, "
3867
+ "cache_create_1h_tokens "
3757
3868
  "FROM session_entries "
3758
3869
  "WHERE timestamp_utc >= ? AND timestamp_utc <= ?"
3759
3870
  )
@@ -3770,14 +3881,14 @@ def iter_entries_with_id(
3770
3881
 
3771
3882
  out: list[tuple[int, UsageEntry]] = []
3772
3883
  for row in conn.execute(sql, params):
3773
- usage: dict[str, Any] = {
3774
- "input_tokens": row[3],
3775
- "output_tokens": row[4],
3776
- "cache_creation_input_tokens": row[5],
3777
- "cache_read_input_tokens": row[6],
3778
- }
3779
- if row[7] is not None: # speed (materialized column, #181)
3780
- usage["speed"] = row[7]
3884
+ usage: dict[str, Any] = claude_usage_dict( # #195 chokepoint
3885
+ input_tokens=row[3],
3886
+ output_tokens=row[4],
3887
+ cache_creation_tokens=row[5],
3888
+ cache_read_tokens=row[6],
3889
+ cache_1h_tokens=row[10],
3890
+ speed=row[7],
3891
+ )
3781
3892
  out.append((row[0], UsageEntry(
3782
3893
  timestamp=dt.datetime.fromisoformat(row[1]),
3783
3894
  model=row[2],
@@ -3870,6 +3981,18 @@ class _JoinedClaudeEntry:
3870
3981
  # them (else `daily -i`/`-p` lose fast-tier model labels). None when
3871
3982
  # the row has no extras.
3872
3983
  usage_extra: dict | None = None
3984
+ # #195: the 1-hour portion of `cache_creation_tokens`, or None when the
3985
+ # split is unknown (a pre-#195 cache row, or a JSONL entry with no nested
3986
+ # `cache_creation` breakdown). None is the sentinel the pricing kernel
3987
+ # branches on to reproduce pre-#195 behavior byte-identically.
3988
+ cache_1h_tokens: int | None = None
3989
+
3990
+ @property
3991
+ def speed(self):
3992
+ """Authoritative effective tier retained from ``message.usage.speed``."""
3993
+ if self.usage_extra is None:
3994
+ return None
3995
+ return self.usage_extra.get("speed")
3873
3996
 
3874
3997
 
3875
3998
  def get_claude_session_entries(
@@ -3911,7 +4034,9 @@ def get_claude_session_entries(
3911
4034
 
3912
4035
  if not skip_sync:
3913
4036
  stats, conn = _run_cache_operation_with_recovery(
3914
- conn, lambda active_conn: sync_cache(active_conn)
4037
+ conn,
4038
+ lambda active_conn: sync_cache(active_conn),
4039
+ origin="claude.session_entries.sync",
3915
4040
  )
3916
4041
  if stats.lock_contended:
3917
4042
  # Partial cache window: a concurrent ingest may have committed some
@@ -3919,6 +4044,7 @@ def get_claude_session_entries(
3919
4044
  # JSONL parse — same rationale as `get_entries`.
3920
4045
  # #341: fail closed on an account-scoped read — the direct-JSONL
3921
4046
  # fallback carries no account identity (exit 3, not a mislabel).
4047
+ conn.close()
3922
4048
  _guard_account_attribution(account_key, "concurrent ingest")
3923
4049
  eprint(
3924
4050
  "[cache] concurrent ingest in progress; "
@@ -3938,7 +4064,7 @@ def get_claude_session_entries(
3938
4064
  " se.cache_create_tokens, se.cache_read_tokens, "
3939
4065
  " se.source_path, "
3940
4066
  " sf.session_id, sf.project_path, "
3941
- " se.cost_usd_raw, se.speed "
4067
+ " se.cost_usd_raw, se.speed, se.cache_create_1h_tokens "
3942
4068
  "FROM session_entries se "
3943
4069
  "LEFT JOIN session_files sf ON sf.path = se.source_path "
3944
4070
  "WHERE se.timestamp_utc >= ? AND se.timestamp_utc <= ?"
@@ -3972,7 +4098,10 @@ def get_claude_session_entries(
3972
4098
  # which plan SQLite picks for either window.
3973
4099
  sql += " ORDER BY se.timestamp_utc ASC, se.id ASC"
3974
4100
 
3975
- rows = conn.execute(sql, params).fetchall()
4101
+ try:
4102
+ rows = conn.execute(sql, params).fetchall()
4103
+ finally:
4104
+ conn.close()
3976
4105
 
3977
4106
  return [
3978
4107
  _JoinedClaudeEntry(
@@ -3990,6 +4119,9 @@ def get_claude_session_entries(
3990
4119
  # {"speed": …} shape _usage_entry_from_joined already merges, with
3991
4120
  # zero JSON parsing. `is not None` so an empty-string speed surfaces.
3992
4121
  usage_extra=({"speed": row[10]} if row[10] is not None else None),
4122
+ # #195: NULL == split unknown; carried through so the pricing
4123
+ # kernel can price the 1h portion at 2x base input.
4124
+ cache_1h_tokens=row[11],
3993
4125
  )
3994
4126
  for row in rows
3995
4127
  ]
@@ -4104,6 +4236,9 @@ def _direct_parse_claude_session_entries(
4104
4236
  _token_keys = {
4105
4237
  "input_tokens", "output_tokens",
4106
4238
  "cache_creation_input_tokens", "cache_read_input_tokens",
4239
+ # #195: the normalized TTL split rides its own dataclass field and its
4240
+ # own columns, so it must NOT double-ride into usage_extra.
4241
+ "cache_creation_1h_input_tokens", "cache_creation_5m_input_tokens",
4107
4242
  }
4108
4243
  for entry, source_path in flat:
4109
4244
  usage = entry.usage
@@ -4127,6 +4262,7 @@ def _direct_parse_claude_session_entries(
4127
4262
  project_path=cwd,
4128
4263
  cost_usd=entry.cost_usd,
4129
4264
  usage_extra=(extras or None),
4265
+ cache_1h_tokens=usage.get("cache_creation_1h_input_tokens"),
4130
4266
  ))
4131
4267
 
4132
4268
  return results
@@ -4966,7 +5102,9 @@ def get_codex_entries(
4966
5102
  # classified corruption closes the handle, quarantines once, and
4967
5103
  # restarts on a fresh family (reassigning `conn`, closed in finally).
4968
5104
  stats, conn = _run_cache_operation_with_recovery(
4969
- conn, lambda active_conn: sync_codex_cache(active_conn)
5105
+ conn,
5106
+ lambda active_conn: sync_codex_cache(active_conn),
5107
+ origin="codex.entries.sync",
4970
5108
  )
4971
5109
  if stats.lock_contended:
4972
5110
  # Sync commits file-by-file, so contention on the ingest lock
@@ -5086,7 +5224,9 @@ def get_entries(
5086
5224
  try:
5087
5225
  if not skip_sync:
5088
5226
  stats, conn = _run_cache_operation_with_recovery(
5089
- conn, lambda active_conn: sync_cache(active_conn)
5227
+ conn,
5228
+ lambda active_conn: sync_cache(active_conn),
5229
+ origin="claude.entries.sync",
5090
5230
  )
5091
5231
  if stats.lock_contended:
5092
5232
  # Sync commits file-by-file, so contention on the ingest lock
@@ -5108,10 +5248,7 @@ def get_entries(
5108
5248
  return iter_entries(
5109
5249
  conn, range_start, range_end, project=project, account_key=account_key)
5110
5250
  finally:
5111
- try:
5112
- conn.close()
5113
- except Exception:
5114
- pass
5251
+ conn.close()
5115
5252
 
5116
5253
 
5117
5254
  def _harden_cache_sidecars() -> None:
@@ -5229,12 +5366,22 @@ def _cache_open_guarded() -> sqlite3.Connection:
5229
5366
  f"cache.db maintenance is in progress ({marker})"
5230
5367
  )
5231
5368
  return conn
5232
- except Exception:
5369
+ except Exception as exc:
5233
5370
  if conn is not None:
5234
- try:
5235
- conn.close()
5236
- except Exception:
5237
- pass
5371
+ if (
5372
+ isinstance(exc, sqlite3.DatabaseError)
5373
+ and _cctally_db_sib._is_sqlite_corruption_error(exc)
5374
+ ):
5375
+ # Keep the triggering handle alive until recovery owns
5376
+ # marker + maintenance-EX. Closing it here would run
5377
+ # SQLite's last-close checkpoint before that boundary.
5378
+ setattr(exc, "_cctally_cache_connection", conn)
5379
+ conn = None
5380
+ else:
5381
+ try:
5382
+ conn.close()
5383
+ except Exception:
5384
+ pass
5238
5385
  raise
5239
5386
  finally:
5240
5387
  fcntl.flock(lock_fh, fcntl.LOCK_UN)
@@ -5245,34 +5392,203 @@ def _cache_open_guarded() -> sqlite3.Connection:
5245
5392
  lock_fh.close()
5246
5393
 
5247
5394
 
5248
- def _recover_corrupt_cache(exc: sqlite3.DatabaseError) -> bool:
5395
+ def _set_cache_no_checkpoint_on_close(
5396
+ conn: sqlite3.Connection, disabled: bool,
5397
+ ) -> None:
5398
+ """Set SQLite's per-connection checkpoint-on-close policy.
5399
+
5400
+ Python 3.12 added ``Connection.setconfig``. cctally still supports 3.11,
5401
+ so CPython 3.11 reaches the same SQLite API through its supported-version
5402
+ ``pysqlite_Connection`` layout and the stdlib extension's linked SQLite
5403
+ symbol. This helper is reached only after a classified cache failure;
5404
+ ordinary opens never depend on the implementation-specific adapter.
5405
+ """
5406
+ option = getattr(sqlite3, "SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE", None)
5407
+ setconfig = getattr(conn, "setconfig", None)
5408
+ if option is not None and setconfig is not None:
5409
+ setconfig(option, bool(disabled))
5410
+ return
5411
+
5412
+ _set_cache_no_checkpoint_on_close_cpython(conn, disabled)
5413
+
5414
+
5415
+ def _set_cache_no_checkpoint_on_close_cpython(
5416
+ conn: sqlite3.Connection, disabled: bool,
5417
+ ) -> None:
5418
+ """Python 3.11 compatibility adapter for sqlite3_db_config()."""
5419
+ if sys.implementation.name != "cpython":
5420
+ raise sqlite3.NotSupportedError(
5421
+ "cache recovery requires SQLite no-checkpoint-on-close support"
5422
+ )
5423
+
5424
+ # CPython 3.11's public sqlite3 module does not expose db_config(), but its
5425
+ # connection layout begins with PyObject_HEAD followed by ``sqlite3 *db``.
5426
+ # The layout and audit-visible handle are defined by Modules/_sqlite in
5427
+ # every supported CPython release. Load sqlite3_db_config from the same
5428
+ # extension dependency so we never bind a different SQLite instance.
5429
+ import _sqlite3
5430
+ import ctypes
5431
+
5432
+ sqlite_lib = ctypes.CDLL(_sqlite3.__file__)
5433
+ db_config = sqlite_lib.sqlite3_db_config
5434
+ db_config.argtypes = (ctypes.c_void_p, ctypes.c_int)
5435
+ db_config.restype = ctypes.c_int
5436
+ pointer_size = ctypes.sizeof(ctypes.c_void_p)
5437
+ db_pointer = ctypes.c_void_p.from_address(
5438
+ id(conn) + (2 * pointer_size)
5439
+ ).value
5440
+ if not db_pointer:
5441
+ raise sqlite3.NotSupportedError(
5442
+ "cache recovery could not resolve the SQLite connection handle"
5443
+ )
5444
+ current = ctypes.c_int()
5445
+ rc = db_config(
5446
+ ctypes.c_void_p(db_pointer),
5447
+ 1006, # SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE
5448
+ ctypes.c_int(1 if disabled else 0),
5449
+ ctypes.byref(current),
5450
+ )
5451
+ if rc != sqlite3.SQLITE_OK or current.value != int(bool(disabled)):
5452
+ raise sqlite3.NotSupportedError(
5453
+ "cache recovery could not configure SQLite close checkpointing"
5454
+ )
5455
+
5456
+
5457
+ @dataclass(frozen=True)
5458
+ class _CacheShmSnapshot:
5459
+ existed: bool
5460
+ data: bytes
5461
+
5462
+
5463
+ def _capture_cache_shm_snapshot(db_path: pathlib.Path) -> _CacheShmSnapshot:
5464
+ shm = pathlib.Path(f"{db_path}-shm")
5465
+ try:
5466
+ return _CacheShmSnapshot(existed=True, data=shm.read_bytes())
5467
+ except FileNotFoundError:
5468
+ return _CacheShmSnapshot(existed=False, data=b"")
5469
+
5470
+
5471
+ def _restore_cache_shm_snapshot(
5472
+ db_path: pathlib.Path, snapshot: _CacheShmSnapshot,
5473
+ ) -> None:
5474
+ """Undo read-mark changes made by the locked read-only probe.
5475
+
5476
+ The WAL index is transient, but Task A's preservation contract is stronger:
5477
+ a declined heal retains all three family members byte-for-byte. With every
5478
+ SQLite handle drained under maintenance-exclusive, restoring the exact
5479
+ pre-probe SHM bytes is safe and preserves its inode when it already existed.
5480
+ """
5481
+ shm = pathlib.Path(f"{db_path}-shm")
5482
+ if not snapshot.existed:
5483
+ try:
5484
+ shm.unlink()
5485
+ except FileNotFoundError:
5486
+ pass
5487
+ return
5488
+ with shm.open("r+b") as fh:
5489
+ fh.seek(0)
5490
+ fh.write(snapshot.data)
5491
+ fh.truncate()
5492
+ fh.flush()
5493
+ os.fsync(fh.fileno())
5494
+
5495
+
5496
+ def _close_cache_trigger_connection(
5497
+ conn: sqlite3.Connection, db_path: pathlib.Path,
5498
+ ) -> None:
5499
+ """Drain the triggering handle without a last-close checkpoint.
5500
+
5501
+ Modern Python and CPython 3.11 use SQLite's native db_config option. An
5502
+ alternate Python 3.11 implementation falls back to a short-lived read-only
5503
+ keeper: with another connection present, closing the trigger is not the
5504
+ last read/write close. The keeper may update transient SHM read marks, so
5505
+ their exact pre-keeper bytes are restored while maintenance-EX excludes
5506
+ every other cache opener.
5507
+ """
5508
+ try:
5509
+ _set_cache_no_checkpoint_on_close(conn, True)
5510
+ except sqlite3.NotSupportedError:
5511
+ snapshot = _capture_cache_shm_snapshot(db_path)
5512
+ keeper = None
5513
+ try:
5514
+ keeper = sqlite3.connect(
5515
+ db_path.resolve().as_uri() + "?mode=ro", uri=True,
5516
+ )
5517
+ keeper.execute("PRAGMA schema_version").fetchone()
5518
+ conn.close()
5519
+ finally:
5520
+ if keeper is not None:
5521
+ keeper.close()
5522
+ _restore_cache_shm_snapshot(db_path, snapshot)
5523
+ else:
5524
+ conn.close()
5525
+
5526
+
5527
+ def _close_cache_trigger_connection_best_effort(
5528
+ conn: sqlite3.Connection,
5529
+ ) -> None:
5530
+ """Close an unclaimed trigger handle without enabling destructive recovery."""
5531
+ try:
5532
+ _set_cache_no_checkpoint_on_close(conn, True)
5533
+ except Exception:
5534
+ pass
5535
+ try:
5536
+ conn.close()
5537
+ except Exception:
5538
+ pass
5539
+
5540
+
5541
+ def _recover_corrupt_cache(
5542
+ exc: sqlite3.DatabaseError,
5543
+ *,
5544
+ origin: str,
5545
+ active_conn: sqlite3.Connection | None = None,
5546
+ ) -> bool:
5249
5547
  """Quarantine a corrupt cache family only after every reader has drained.
5250
5548
 
5251
- Returns True after a safe quarantine, so the caller may create a fresh
5252
- re-derivable cache. Raises a guided DatabaseError when recovery cannot prove
5253
- exclusivity; callers then use their established direct-JSONL fallback.
5549
+ Returns True only after a locked forensics probe confirms corruption and
5550
+ whole-family quarantine completes, so the caller may create a fresh
5551
+ re-derivable cache. An unconfirmed trigger returns False after preserving
5552
+ the family and emitting its incident path; the caller then propagates the
5553
+ original exception through its established direct-JSONL/error fallback.
5554
+ Raises a guided DatabaseError when recovery cannot prove exclusivity.
5254
5555
  """
5255
5556
  if not _cctally_db_sib._is_sqlite_corruption_error(exc):
5256
5557
  return False
5558
+ if not origin.strip():
5559
+ raise ValueError("cache recovery origin must be non-empty")
5257
5560
 
5258
5561
  path = pathlib.Path(_cctally_core.CACHE_DB_PATH)
5259
5562
  try:
5260
5563
  claim, reason = _cctally_db_sib._claim_repair_marker(path)
5261
5564
  except OSError as marker_exc:
5565
+ if active_conn is not None:
5566
+ _close_cache_trigger_connection_best_effort(active_conn)
5262
5567
  raise sqlite3.DatabaseError(
5263
5568
  f"cache.db recovery could not claim maintenance: {marker_exc}"
5264
5569
  ) from exc
5265
5570
  if claim is None:
5571
+ if active_conn is not None:
5572
+ _close_cache_trigger_connection_best_effort(active_conn)
5266
5573
  raise sqlite3.DatabaseError(
5267
5574
  f"cache.db maintenance is in progress: {reason}"
5268
5575
  ) from exc
5269
5576
  _cache_storm_test_pause("cache_repair_claimed")
5270
5577
 
5271
5578
  lock_path = pathlib.Path(_cctally_core.CACHE_LOCK_MAINTENANCE_PATH)
5272
- lock_path.parent.mkdir(parents=True, exist_ok=True)
5273
- lock_fh = open(lock_path, "a+")
5579
+ try:
5580
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
5581
+ lock_fh = open(lock_path, "a+")
5582
+ except OSError:
5583
+ if active_conn is not None:
5584
+ _close_cache_trigger_connection_best_effort(active_conn)
5585
+ _cctally_db_sib._release_repair_marker(path, claim)
5586
+ raise
5274
5587
  try:
5275
5588
  fcntl.flock(lock_fh, fcntl.LOCK_EX)
5589
+ if active_conn is not None:
5590
+ _close_cache_trigger_connection(active_conn, path)
5591
+ active_conn = None
5276
5592
  open_pids = _cctally_db_sib._db_family_open_pids(path)
5277
5593
  if open_pids is None:
5278
5594
  raise sqlite3.DatabaseError(
@@ -5286,8 +5602,54 @@ def _recover_corrupt_cache(exc: sqlite3.DatabaseError) -> bool:
5286
5602
  + "; leaving the live family untouched"
5287
5603
  ) from exc
5288
5604
 
5289
- _cctally_db_sib.write_corruption_forensics(path, db_label="cache")
5605
+ # Capture only after marker + maintenance-EX + handle drain. A writer
5606
+ # that completed before the marker is legitimate current state; taking
5607
+ # this snapshot earlier and restoring it after the probe would overwrite
5608
+ # that writer's newer WAL index.
5609
+ shm_snapshot = _capture_cache_shm_snapshot(path)
5610
+ try:
5611
+ forensics = _cctally_db_sib.write_corruption_forensics(
5612
+ path,
5613
+ db_label="cache",
5614
+ trigger_origin=origin,
5615
+ trigger_exception=exc,
5616
+ return_result=True,
5617
+ )
5618
+ except Exception as forensics_exc:
5619
+ if shm_snapshot is not None:
5620
+ _restore_cache_shm_snapshot(path, shm_snapshot)
5621
+ eprint(
5622
+ "[cache] destructive recovery declined for classified trigger "
5623
+ f"at {origin}: forensics was unavailable "
5624
+ f"({forensics_exc}; forensics: unavailable); leaving the "
5625
+ "cache.db file family untouched"
5626
+ )
5627
+ return False
5628
+ assert isinstance(
5629
+ forensics, _cctally_db_sib.CorruptionForensicsResult,
5630
+ )
5631
+ if shm_snapshot is not None:
5632
+ try:
5633
+ _restore_cache_shm_snapshot(path, shm_snapshot)
5634
+ except OSError as restore_exc:
5635
+ raise sqlite3.DatabaseError(
5636
+ "cache.db recovery could not restore the exact pre-probe "
5637
+ f"WAL-index bytes: {restore_exc}"
5638
+ ) from exc
5290
5639
  _cache_storm_test_pause("cache_repair_forensics")
5640
+ if (
5641
+ forensics.disposition
5642
+ is not _cctally_db_sib.CorruptionProbeDisposition.CONFIRMED
5643
+ or forensics.path is None
5644
+ ):
5645
+ bundle = str(forensics.path) if forensics.path is not None else "unavailable"
5646
+ eprint(
5647
+ "[cache] destructive recovery declined for classified trigger "
5648
+ f"at {origin}: corruption was not confirmed "
5649
+ f"({forensics.reason}; forensics: {bundle}); leaving the "
5650
+ "cache.db file family untouched"
5651
+ )
5652
+ return False
5291
5653
  try:
5292
5654
  incident = _cctally_db_sib.quarantine_db_family(path, strict=True)
5293
5655
  except OSError as quarantine_exc:
@@ -5302,6 +5664,8 @@ def _recover_corrupt_cache(exc: sqlite3.DatabaseError) -> bool:
5302
5664
  )
5303
5665
  return True
5304
5666
  finally:
5667
+ if active_conn is not None:
5668
+ _close_cache_trigger_connection_best_effort(active_conn)
5305
5669
  try:
5306
5670
  fcntl.flock(lock_fh, fcntl.LOCK_UN)
5307
5671
  finally:
@@ -5312,32 +5676,45 @@ def _recover_corrupt_cache(exc: sqlite3.DatabaseError) -> bool:
5312
5676
  def _run_cache_operation_with_recovery(
5313
5677
  conn: sqlite3.Connection,
5314
5678
  operation: Callable[[sqlite3.Connection], Any],
5679
+ *,
5680
+ origin: str,
5315
5681
  ) -> "tuple[Any, sqlite3.Connection]":
5316
- results, replacement = _run_cache_plan_with_recovery(conn, (operation,))
5682
+ results, replacement = _run_cache_plan_with_recovery(
5683
+ conn, (operation,), origins=(origin,),
5684
+ )
5317
5685
  return results[0], replacement
5318
5686
 
5319
5687
 
5320
5688
  def _run_cache_plan_with_recovery(
5321
5689
  conn: sqlite3.Connection,
5322
5690
  operations: "tuple[Callable[[sqlite3.Connection], Any], ...]",
5691
+ *,
5692
+ origins: "tuple[str, ...]",
5323
5693
  ) -> "tuple[tuple[Any, ...], sqlite3.Connection]":
5324
5694
  """Run a provider plan, recovering once and restarting from its first leg.
5325
5695
 
5326
- The connection that observed corruption is closed before the destructive
5327
- maintenance handshake. Because cache.db is one shared physical family, a
5696
+ The connection that observed corruption is drained only after the repair
5697
+ marker and maintenance-exclusive lock exclude new openers. Because cache.db
5698
+ is one shared physical family, a
5328
5699
  recovery in a later provider leg invalidates every earlier result; the
5329
5700
  complete requested plan therefore restarts against the replacement family.
5330
5701
  A second classified failure closes the replacement and propagates without a
5331
5702
  second quarantine attempt.
5332
5703
  """
5704
+ if len(operations) != len(origins):
5705
+ raise ValueError("cache recovery origins must match operation count")
5706
+ if any(not origin.strip() for origin in origins):
5707
+ raise ValueError("cache recovery origins must be non-empty")
5333
5708
  if not operations:
5334
5709
  return (), conn
5335
5710
  active = conn
5336
5711
  recovered = False
5337
5712
  while True:
5338
5713
  try:
5339
- results = tuple(operation(active) for operation in operations)
5340
- return results, active
5714
+ results: list[Any] = []
5715
+ for operation, origin in zip(operations, origins):
5716
+ results.append(operation(active))
5717
+ return tuple(results), active
5341
5718
  except sqlite3.DatabaseError as exc:
5342
5719
  if (
5343
5720
  recovered
@@ -5345,8 +5722,9 @@ def _run_cache_plan_with_recovery(
5345
5722
  ):
5346
5723
  active.close()
5347
5724
  raise
5348
- active.close()
5349
- if not _recover_corrupt_cache(exc):
5725
+ if not _recover_corrupt_cache(
5726
+ exc, origin=origin, active_conn=active,
5727
+ ):
5350
5728
  raise
5351
5729
  active = open_cache_db()
5352
5730
  _cache_storm_test_pause("cache_repair_recreated")
@@ -5378,7 +5756,13 @@ def open_cache_db() -> sqlite3.Connection:
5378
5756
  try:
5379
5757
  conn = _cache_open_guarded()
5380
5758
  except sqlite3.DatabaseError as exc:
5381
- if not _recover_corrupt_cache(exc):
5759
+ if not _recover_corrupt_cache(
5760
+ exc,
5761
+ origin="cache.open",
5762
+ active_conn=getattr(
5763
+ exc, "_cctally_cache_connection", None,
5764
+ ),
5765
+ ):
5382
5766
  raise
5383
5767
  # One retry only. A second failure surfaces to the existing direct-JSONL
5384
5768
  # fallback instead of looping through destructive recovery.
@@ -5405,6 +5789,9 @@ def open_cache_db() -> sqlite3.Connection:
5405
5789
  # only. Persistent/schema PRAGMAs and every DDL/DML migration path are
5406
5790
  # reserved for the globally serialized branch below.
5407
5791
  _cctally_store.apply_connection_policy(conn, "cache")
5792
+ _cctally_db_sib._reconcile_durable_applied_migration_errors(
5793
+ conn, _CACHE_MIGRATIONS, "cache.db",
5794
+ )
5408
5795
  return conn
5409
5796
 
5410
5797
  from _lib_cache_writer_lock import (
@@ -5569,6 +5956,57 @@ def open_conversations_db(*, attach_cache: bool = True) -> sqlite3.Connection:
5569
5956
  return conn
5570
5957
 
5571
5958
 
5959
+ def read_session_titles_bounded(
5960
+ session_ids,
5961
+ *,
5962
+ timeout_s: float = 0.05,
5963
+ ) -> dict:
5964
+ """{session_id: title} for the dashboard Sessions panel — bounded, fail-soft.
5965
+
5966
+ The Sessions panel is an ACCOUNTING surface that shows one piece of
5967
+ transcript-derived decoration (the session title). #320 made the transcript
5968
+ corpus an independent store precisely so accounting can never wait on it, so
5969
+ this read is deliberately not ``open_conversations_db``: that opener applies
5970
+ the schema, runs the migration dispatcher, attaches ``cache.db``, and carries
5971
+ the 15s store-policy ``busy_timeout`` — a locked or rebuilding store would
5972
+ stall the whole sync tick before a fail-soft caller could give up.
5973
+
5974
+ Instead: one RAW ``mode=ro`` connection with a ``timeout_s`` busy timeout
5975
+ (the same idiom the Codex sessions rows use for Codex's own
5976
+ ``state_5.sqlite``), reading only the two INDEXED title sources via
5977
+ ``session_titles_indexed_map`` — never the windowed ``conversation_messages``
5978
+ scan. Every failure path — no store on disk, locked store, absent tables,
5979
+ corruption — degrades to ``{}`` and the panel renders its em-dash fallback,
5980
+ which self-heals on a later tick. Never creates the store.
5981
+ """
5982
+ ids = [sid for sid in dict.fromkeys(session_ids or ()) if sid]
5983
+ if not ids:
5984
+ return {}
5985
+ path = _cctally_core.CONVERSATIONS_DB_PATH
5986
+ try:
5987
+ if not path.is_file():
5988
+ return {}
5989
+ uri = f"{path.resolve().as_uri()}?mode=ro"
5990
+ except OSError:
5991
+ return {}
5992
+ conn: sqlite3.Connection | None = None
5993
+ try:
5994
+ conn = sqlite3.connect(uri, uri=True, timeout=max(timeout_s, 0.0))
5995
+ return dict(
5996
+ _load_lib("_lib_conversation_query").session_titles_indexed_map(
5997
+ conn, ids,
5998
+ )
5999
+ )
6000
+ except (sqlite3.Error, OSError):
6001
+ return {}
6002
+ finally:
6003
+ if conn is not None:
6004
+ try:
6005
+ conn.close()
6006
+ except sqlite3.Error:
6007
+ pass
6008
+
6009
+
5572
6010
  def _import_legacy_conversation_rows(conn: sqlite3.Connection) -> None:
5573
6011
  """Bridge pre-028/compatibility rows into an empty conversation store.
5574
6012
 
@@ -5772,12 +6210,23 @@ def _prepare_claude_conversation_maintenance(
5772
6210
  _consume_file_touches(conn)
5773
6211
 
5774
6212
 
6213
+ def _report_conversation_progress(
6214
+ progress: "Callable[[str, Any], None] | None",
6215
+ phase: str,
6216
+ stats: "IngestStats | CodexIngestStats",
6217
+ ) -> None:
6218
+ """Emit one optional #395 transcript-rebuild phase observation."""
6219
+ if progress is not None:
6220
+ progress(phase, stats)
6221
+
6222
+
5775
6223
  def sync_claude_conversations(
5776
6224
  conn: sqlite3.Connection,
5777
6225
  *,
5778
6226
  rebuild: bool = False,
5779
6227
  lock_timeout: "float | None" = None,
5780
6228
  only_paths: "set[str] | None" = None,
6229
+ progress: "Callable[[str, IngestStats], None] | None" = None,
5781
6230
  ) -> IngestStats:
5782
6231
  """Delta-sync Claude transcript/search rows into conversations.db (#320).
5783
6232
 
@@ -5791,6 +6240,7 @@ def sync_claude_conversations(
5791
6240
  _cctally_core.CONVERSATIONS_LOCK_PATH.touch()
5792
6241
  lock_fh = open(_cctally_core.CONVERSATIONS_LOCK_PATH, "w")
5793
6242
  try:
6243
+ _report_conversation_progress(progress, "lock", stats)
5794
6244
  if not _acquire_cache_flock(lock_fh, timeout=lock_timeout):
5795
6245
  stats.lock_contended = True
5796
6246
  return stats
@@ -5812,7 +6262,17 @@ def sync_claude_conversations(
5812
6262
  stats.deferred_reason = "pending_global_flags"
5813
6263
  return stats
5814
6264
  rebuild = rebuild or pending_rebuild
6265
+ if rebuild:
6266
+ # Commit the retry marker before the destructive clear. A killed
6267
+ # #395 worker therefore leaves a partial transcript store visibly
6268
+ # pending instead of advancing it to a false-complete state.
6269
+ conn.execute(
6270
+ "INSERT OR REPLACE INTO cache_meta(key,value) VALUES(?,?)",
6271
+ ("conversation_rebuild_claude_pending", "1"),
6272
+ )
6273
+ conn.commit()
5815
6274
 
6275
+ _report_conversation_progress(progress, "prepare", stats)
5816
6276
  _prepare_claude_conversation_maintenance(
5817
6277
  conn, rebuild=rebuild, targeted=targeted
5818
6278
  )
@@ -5835,6 +6295,7 @@ def sync_claude_conversations(
5835
6295
  else list(_iter_claude_jsonl_files())
5836
6296
  )
5837
6297
  stats.files_total = len(paths)
6298
+ _report_conversation_progress(progress, "ingest", stats)
5838
6299
  existing = {
5839
6300
  row[0]: (row[1], row[2], row[3])
5840
6301
  for row in conn.execute(
@@ -5868,11 +6329,13 @@ def sync_claude_conversations(
5868
6329
  st = jp.stat()
5869
6330
  except OSError:
5870
6331
  stats.files_failed += 1
6332
+ _report_conversation_progress(progress, "ingest", stats)
5871
6333
  continue
5872
6334
  size, mtime_ns = st.st_size, st.st_mtime_ns
5873
6335
  prev = existing.get(path_str)
5874
6336
  if prev is not None and size == prev[0]:
5875
6337
  stats.files_skipped_unchanged += 1
6338
+ _report_conversation_progress(progress, "ingest", stats)
5876
6339
  continue
5877
6340
  truncated = prev is not None and size < prev[0]
5878
6341
  if targeted and truncated:
@@ -5900,6 +6363,7 @@ def sync_claude_conversations(
5900
6363
  except OSError as exc:
5901
6364
  eprint(f"[conversations] could not read {jp}: {exc}")
5902
6365
  stats.files_failed += 1
6366
+ _report_conversation_progress(progress, "ingest", stats)
5903
6367
  continue
5904
6368
 
5905
6369
  try:
@@ -5953,11 +6417,14 @@ def sync_claude_conversations(
5953
6417
  touched_sessions.update(
5954
6418
  row[0] for row in conv_rows if row[0] is not None
5955
6419
  )
6420
+ _report_conversation_progress(progress, "ingest", stats)
5956
6421
  except sqlite3.DatabaseError as exc:
5957
6422
  conn.rollback()
5958
6423
  eprint(f"[conversations] db error on {jp}: {exc}")
5959
6424
  stats.files_failed += 1
6425
+ _report_conversation_progress(progress, "ingest", stats)
5960
6426
 
6427
+ _report_conversation_progress(progress, "rollup", stats)
5961
6428
  _arm_rollup_backfill_on_pricing_change(conn)
5962
6429
  if _conversation_sessions_backfill_pending(conn):
5963
6430
  _recompute_conversation_sessions(conn)
@@ -5975,6 +6442,7 @@ def sync_claude_conversations(
5975
6442
  "WHERE key='conversation_rebuild_claude_pending'"
5976
6443
  )
5977
6444
  conn.commit()
6445
+ _report_conversation_progress(progress, "checkpoint", stats)
5978
6446
  _harden_conversation_sidecars()
5979
6447
  _maybe_truncate_wal(conn, _cctally_core.CONVERSATIONS_DB_PATH)
5980
6448
  did_from_zero_replay = rebuild or stats.files_reset_truncated > 0
@@ -5985,7 +6453,9 @@ def sync_claude_conversations(
5985
6453
  pass
5986
6454
  lock_fh.close()
5987
6455
  if did_from_zero_replay:
6456
+ _report_conversation_progress(progress, "retention", stats)
5988
6457
  _force_retention_prune_after_replay()
6458
+ _report_conversation_progress(progress, "complete", stats)
5989
6459
  return stats
5990
6460
 
5991
6461
 
@@ -6002,6 +6472,7 @@ def sync_codex_conversations(
6002
6472
  rebuild: bool = False,
6003
6473
  lock_timeout: "float | None" = None,
6004
6474
  only_paths: "set[str] | None" = None,
6475
+ progress: "Callable[[str, CodexIngestStats], None] | None" = None,
6005
6476
  ) -> CodexIngestStats:
6006
6477
  """Delta-sync Codex events/search rows into conversations.db (#320)."""
6007
6478
  stats = CodexIngestStats()
@@ -6010,6 +6481,7 @@ def sync_codex_conversations(
6010
6481
  _cctally_core.CONVERSATIONS_LOCK_CODEX_PATH.touch()
6011
6482
  lock_fh = open(_cctally_core.CONVERSATIONS_LOCK_CODEX_PATH, "w")
6012
6483
  try:
6484
+ _report_conversation_progress(progress, "lock", stats)
6013
6485
  if not _acquire_cache_flock(lock_fh, timeout=lock_timeout):
6014
6486
  stats.lock_contended = True
6015
6487
  return stats
@@ -6038,6 +6510,12 @@ def sync_codex_conversations(
6038
6510
  return stats
6039
6511
  rebuild = rebuild or pending_rebuild or contract_rebuild
6040
6512
  if rebuild:
6513
+ conn.execute(
6514
+ "INSERT OR REPLACE INTO cache_meta(key,value) VALUES(?,?)",
6515
+ ("conversation_rebuild_codex_pending", "1"),
6516
+ )
6517
+ conn.commit()
6518
+ _report_conversation_progress(progress, "prepare", stats)
6041
6519
  _clear_codex_conversation_store(conn)
6042
6520
  conn.commit()
6043
6521
 
@@ -6051,6 +6529,7 @@ def sync_codex_conversations(
6051
6529
  else _discover_codex_files_with_roots()
6052
6530
  )
6053
6531
  stats.files_total = len(files)
6532
+ _report_conversation_progress(progress, "ingest", stats)
6054
6533
  existing = {
6055
6534
  row[0]: tuple(row[1:])
6056
6535
  for row in conn.execute(
@@ -6112,11 +6591,13 @@ def sync_codex_conversations(
6112
6591
  st = jp.stat()
6113
6592
  except OSError:
6114
6593
  stats.files_failed += 1
6594
+ _report_conversation_progress(progress, "ingest", stats)
6115
6595
  continue
6116
6596
  size, mtime_ns = st.st_size, st.st_mtime_ns
6117
6597
  prev = existing.get(path_str)
6118
6598
  if prev is not None and size == prev[0] and prev[3] == discovered.source_root_key:
6119
6599
  stats.files_skipped_unchanged += 1
6600
+ _report_conversation_progress(progress, "ingest", stats)
6120
6601
  continue
6121
6602
  reset_file = (
6122
6603
  prev is not None
@@ -6199,6 +6680,7 @@ def sync_codex_conversations(
6199
6680
  except OSError as exc:
6200
6681
  eprint(f"[codex-conversations] could not read {jp}: {exc}")
6201
6682
  stats.files_failed += 1
6683
+ _report_conversation_progress(progress, "ingest", stats)
6202
6684
  continue
6203
6685
 
6204
6686
  try:
@@ -6216,6 +6698,7 @@ def sync_codex_conversations(
6216
6698
  f"[codex-conversations] normalization failed for {jp}: {exc}"
6217
6699
  )
6218
6700
  stats.files_failed += 1
6701
+ _report_conversation_progress(progress, "ingest", stats)
6219
6702
  continue
6220
6703
  affected_keys = {
6221
6704
  row[0]
@@ -6305,11 +6788,14 @@ def sync_codex_conversations(
6305
6788
  )
6306
6789
  conn.commit()
6307
6790
  stats.files_processed += 1
6791
+ _report_conversation_progress(progress, "ingest", stats)
6308
6792
  except sqlite3.DatabaseError as exc:
6309
6793
  conn.rollback()
6310
6794
  eprint(f"[codex-conversations] db error on {jp}: {exc}")
6311
6795
  stats.files_failed += 1
6796
+ _report_conversation_progress(progress, "ingest", stats)
6312
6797
 
6798
+ _report_conversation_progress(progress, "finalize", stats)
6313
6799
  if only_paths is None and stats.files_failed == 0:
6314
6800
  conn.execute(
6315
6801
  "INSERT OR REPLACE INTO cache_meta(key,value) VALUES(?,?)",
@@ -6323,6 +6809,7 @@ def sync_codex_conversations(
6323
6809
  "WHERE key='conversation_rebuild_codex_pending'"
6324
6810
  )
6325
6811
  conn.commit()
6812
+ _report_conversation_progress(progress, "checkpoint", stats)
6326
6813
  _harden_conversation_sidecars()
6327
6814
  _maybe_truncate_wal(conn, _cctally_core.CONVERSATIONS_DB_PATH)
6328
6815
  did_from_zero_replay = rebuild or stats.files_reset_truncated > 0
@@ -6333,10 +6820,269 @@ def sync_codex_conversations(
6333
6820
  pass
6334
6821
  lock_fh.close()
6335
6822
  if did_from_zero_replay:
6823
+ _report_conversation_progress(progress, "retention", stats)
6336
6824
  _force_retention_prune_after_replay()
6825
+ _report_conversation_progress(progress, "complete", stats)
6337
6826
  return stats
6338
6827
 
6339
6828
 
6829
+ class _TranscriptRebuildOutcome(NamedTuple):
6830
+ stats: "IngestStats | CodexIngestStats | None"
6831
+ timed_out: bool
6832
+ phase: str
6833
+ error: "str | None"
6834
+ elapsed_seconds: float
6835
+
6836
+
6837
+ def _write_transcript_worker_event(fd: int, payload: dict[str, Any]) -> None:
6838
+ """Write one PIPE_BUF-sized JSON event from the isolated #395 worker."""
6839
+ encoded = (
6840
+ json.dumps(payload, separators=(",", ":"), sort_keys=True) + "\n"
6841
+ ).encode("utf-8", errors="replace")
6842
+ try:
6843
+ os.write(fd, encoded)
6844
+ except OSError:
6845
+ pass
6846
+
6847
+
6848
+ def _test_transcript_stall_requested(provider: str, phase: str) -> bool:
6849
+ """Pytest-only real-subprocess fault seam for #395 containment evidence."""
6850
+ if not os.environ.get("PYTEST_CURRENT_TEST"):
6851
+ return False
6852
+ return os.environ.get("CCTALLY_TEST_CACHE_SYNC_STALL_PHASE") == (
6853
+ f"{provider}:{phase}"
6854
+ )
6855
+
6856
+
6857
+ def _transcript_rebuild_timeout_seconds() -> float:
6858
+ timeout = _TRANSCRIPT_REBUILD_PHASE_TIMEOUT_SECONDS
6859
+ if os.environ.get("PYTEST_CURRENT_TEST"):
6860
+ raw = os.environ.get("CCTALLY_TEST_CACHE_SYNC_PHASE_TIMEOUT_SECONDS")
6861
+ if raw is not None:
6862
+ try:
6863
+ timeout = float(raw)
6864
+ except ValueError:
6865
+ pass
6866
+ return max(0.01, float(timeout))
6867
+
6868
+
6869
+ def _terminate_transcript_worker(pid: int) -> int:
6870
+ """Bounded SIGTERM -> SIGKILL reap for one explicit rebuild worker."""
6871
+ try:
6872
+ os.kill(pid, signal.SIGTERM)
6873
+ except ProcessLookupError:
6874
+ pass
6875
+ deadline = time.monotonic() + _TRANSCRIPT_REBUILD_KILL_GRACE_SECONDS
6876
+ while time.monotonic() < deadline:
6877
+ done, status = os.waitpid(pid, os.WNOHANG)
6878
+ if done == pid:
6879
+ return status
6880
+ time.sleep(0.02)
6881
+ try:
6882
+ os.kill(pid, signal.SIGKILL)
6883
+ except ProcessLookupError:
6884
+ pass
6885
+ _done, status = os.waitpid(pid, 0)
6886
+ return status
6887
+
6888
+
6889
+ def _run_transcript_rebuild_worker(
6890
+ provider: str,
6891
+ *,
6892
+ lock_timeout: "float | None",
6893
+ ) -> _TranscriptRebuildOutcome:
6894
+ """Run one destructive transcript provider leg in a kill-safe child.
6895
+
6896
+ Core cache connections are already closed before this boundary. The child
6897
+ owns its conversations.db connection and provider flock; SIGKILL therefore
6898
+ lets SQLite roll back only the active transaction while preserving prior
6899
+ per-file commits and the durable pending marker.
6900
+ """
6901
+ read_fd, write_fd = os.pipe()
6902
+ started = time.monotonic()
6903
+ pid = os.fork()
6904
+ if pid == 0:
6905
+ os.close(read_fd)
6906
+
6907
+ def emit(payload: dict[str, Any]) -> None:
6908
+ _write_transcript_worker_event(write_fd, payload)
6909
+
6910
+ def progress(phase: str, stats: Any) -> None:
6911
+ emit({
6912
+ "event": "progress",
6913
+ "phase": phase,
6914
+ "filesDone": (
6915
+ stats.files_processed
6916
+ + stats.files_skipped_unchanged
6917
+ + stats.files_failed
6918
+ ),
6919
+ "filesTotal": stats.files_total,
6920
+ })
6921
+ if _test_transcript_stall_requested(provider, phase):
6922
+ while True:
6923
+ time.sleep(0.05)
6924
+
6925
+ conn = None
6926
+ try:
6927
+ emit({"event": "progress", "phase": "open", "filesDone": 0,
6928
+ "filesTotal": 0})
6929
+ conn = open_conversations_db()
6930
+ emit({"event": "progress", "phase": "sync-start", "filesDone": 0,
6931
+ "filesTotal": 0})
6932
+ sync = (
6933
+ sync_claude_conversations
6934
+ if provider == "claude"
6935
+ else sync_codex_conversations
6936
+ )
6937
+ stats = sync(
6938
+ conn,
6939
+ rebuild=True,
6940
+ lock_timeout=lock_timeout,
6941
+ progress=progress,
6942
+ )
6943
+ emit({"event": "progress", "phase": "close", "filesDone": 0,
6944
+ "filesTotal": 0})
6945
+ conn.close()
6946
+ conn = None
6947
+ emit({
6948
+ "event": "result",
6949
+ "stats": asdict(stats),
6950
+ "statsType": type(stats).__name__,
6951
+ })
6952
+ except BaseException as exc: # child reports; parent owns CLI wording
6953
+ emit({
6954
+ "event": "error",
6955
+ "errorType": type(exc).__name__,
6956
+ "message": str(exc),
6957
+ })
6958
+ finally:
6959
+ if conn is not None:
6960
+ try:
6961
+ conn.close()
6962
+ except Exception:
6963
+ pass
6964
+ os.close(write_fd)
6965
+ os._exit(0)
6966
+
6967
+ os.close(write_fd)
6968
+ os.set_blocking(read_fd, False)
6969
+ buffer = b""
6970
+ last_phase = "spawn"
6971
+ result_payload: "dict[str, Any] | None" = None
6972
+ error_payload: "dict[str, Any] | None" = None
6973
+ last_reported_done = -1
6974
+ last_progress_at = started
6975
+
6976
+ def consume(chunk: bytes) -> None:
6977
+ nonlocal buffer, last_phase, result_payload, error_payload
6978
+ nonlocal last_reported_done, last_progress_at
6979
+ buffer += chunk
6980
+ while b"\n" in buffer:
6981
+ line, buffer = buffer.split(b"\n", 1)
6982
+ if not line:
6983
+ continue
6984
+ try:
6985
+ event = json.loads(line)
6986
+ except (UnicodeDecodeError, json.JSONDecodeError):
6987
+ continue
6988
+ kind = event.get("event")
6989
+ if kind == "progress":
6990
+ last_progress_at = time.monotonic()
6991
+ phase = str(event.get("phase") or "unknown")
6992
+ elapsed = time.monotonic() - started
6993
+ if phase != last_phase:
6994
+ last_phase = phase
6995
+ eprint(
6996
+ f"[cache-sync] {provider} transcripts phase={phase} "
6997
+ f"(+{elapsed:.1f}s)"
6998
+ )
6999
+ done = int(event.get("filesDone") or 0)
7000
+ total = int(event.get("filesTotal") or 0)
7001
+ if (
7002
+ phase == "ingest"
7003
+ and done != last_reported_done
7004
+ and (done > 0 and (done % 200 == 0 or done == total))
7005
+ ):
7006
+ last_reported_done = done
7007
+ eprint(
7008
+ f"[cache-sync] {provider} transcripts: "
7009
+ f"{done}/{total} files (+{elapsed:.1f}s)"
7010
+ )
7011
+ elif kind == "result":
7012
+ result_payload = event
7013
+ elif kind == "error":
7014
+ error_payload = event
7015
+
7016
+ timeout = _transcript_rebuild_timeout_seconds()
7017
+ status = None
7018
+ timed_out = False
7019
+ try:
7020
+ while True:
7021
+ ready, _writable, _exceptional = select.select(
7022
+ [read_fd], [], [], 0.05
7023
+ )
7024
+ if ready:
7025
+ try:
7026
+ chunk = os.read(read_fd, 65_536)
7027
+ except BlockingIOError:
7028
+ chunk = b""
7029
+ if chunk:
7030
+ consume(chunk)
7031
+ done, child_status = os.waitpid(pid, os.WNOHANG)
7032
+ if done == pid:
7033
+ status = child_status
7034
+ break
7035
+ if time.monotonic() - last_progress_at >= timeout:
7036
+ timed_out = True
7037
+ status = _terminate_transcript_worker(pid)
7038
+ break
7039
+ except BaseException:
7040
+ _terminate_transcript_worker(pid)
7041
+ raise
7042
+ finally:
7043
+ while True:
7044
+ try:
7045
+ chunk = os.read(read_fd, 65_536)
7046
+ except BlockingIOError:
7047
+ break
7048
+ if not chunk:
7049
+ break
7050
+ consume(chunk)
7051
+ os.close(read_fd)
7052
+
7053
+ elapsed = time.monotonic() - started
7054
+ if timed_out:
7055
+ return _TranscriptRebuildOutcome(
7056
+ None, True, last_phase, None, elapsed
7057
+ )
7058
+ if error_payload is not None:
7059
+ message = str(error_payload.get("message") or "unknown error")
7060
+ error_type = str(error_payload.get("errorType") or "Error")
7061
+ return _TranscriptRebuildOutcome(
7062
+ None, False, last_phase, f"{error_type}: {message}", elapsed
7063
+ )
7064
+ if status != 0 or result_payload is None:
7065
+ return _TranscriptRebuildOutcome(
7066
+ None,
7067
+ False,
7068
+ last_phase,
7069
+ f"worker exited without a result (status={status})",
7070
+ elapsed,
7071
+ )
7072
+ stats_type = result_payload.get("statsType")
7073
+ stats_data = result_payload.get("stats")
7074
+ if not isinstance(stats_data, dict):
7075
+ return _TranscriptRebuildOutcome(
7076
+ None, False, last_phase, "worker returned invalid stats", elapsed
7077
+ )
7078
+ stats = (
7079
+ IngestStats(**stats_data)
7080
+ if stats_type == "IngestStats"
7081
+ else CodexIngestStats(**stats_data)
7082
+ )
7083
+ return _TranscriptRebuildOutcome(stats, False, last_phase, None, elapsed)
7084
+
7085
+
6340
7086
  # === Region 7: cmd_cache_sync (was bin/cctally:11563-11616) ===
6341
7087
 
6342
7088
 
@@ -6456,6 +7202,7 @@ def cmd_cache_sync(args: argparse.Namespace) -> int:
6456
7202
  _p_root.__enter__()
6457
7203
 
6458
7204
  plan: list[Callable[[sqlite3.Connection], Any]] = []
7205
+ plan_origins: list[str] = []
6459
7206
 
6460
7207
  if source in ("claude", "all"):
6461
7208
  def _sync_claude_leg(active_conn: sqlite3.Connection) -> IngestStats:
@@ -6468,6 +7215,7 @@ def cmd_cache_sync(args: argparse.Namespace) -> int:
6468
7215
  )
6469
7216
 
6470
7217
  plan.append(_sync_claude_leg)
7218
+ plan_origins.append("cache_sync.cli.claude")
6471
7219
 
6472
7220
  if source in ("codex", "all"):
6473
7221
  def _sync_codex_leg(
@@ -6482,9 +7230,12 @@ def cmd_cache_sync(args: argparse.Namespace) -> int:
6482
7230
  )
6483
7231
 
6484
7232
  plan.append(_sync_codex_leg)
7233
+ plan_origins.append("cache_sync.cli.codex")
6485
7234
 
6486
7235
  try:
6487
- plan_results, conn = _run_cache_plan_with_recovery(conn, tuple(plan))
7236
+ plan_results, conn = _run_cache_plan_with_recovery(
7237
+ conn, tuple(plan), origins=tuple(plan_origins),
7238
+ )
6488
7239
  except (OSError, sqlite3.DatabaseError) as exc:
6489
7240
  eprint(f"[cache-sync] failed: {exc}")
6490
7241
  _p_root.__exit__(type(exc), exc, exc.__traceback__)
@@ -6549,53 +7300,116 @@ def cmd_cache_sync(args: argparse.Namespace) -> int:
6549
7300
  # #320: transcript/search ingestion is a second physical database with its
6550
7301
  # own cursors and flocks. Run it only after the core providers have
6551
7302
  # committed so a slow/failed transcript pass can never roll back accounting
6552
- # or quota state.
6553
- try:
6554
- conversation_conn = open_conversations_db()
6555
- except (OSError, sqlite3.DatabaseError) as exc:
6556
- eprint(
6557
- f"[cache-sync] transcript store unavailable ({exc}); "
6558
- "core accounting/quota sync is complete"
6559
- )
6560
- _p_root.__exit__(None, None, None)
6561
- if _perf.enabled():
6562
- _perf.flush_stderr(_perf.current_root())
6563
- return 1 if args.rebuild else (1 if contended else 0)
6564
- try:
6565
- if source in ("claude", "all"):
6566
- conv_stats = sync_claude_conversations(
6567
- conversation_conn, rebuild=args.rebuild, lock_timeout=lt
7303
+ # or quota state. #395 contains each explicit provider rebuild in its own
7304
+ # process so a stuck SQLite/parser/normalization phase has a real finite
7305
+ # boundary without unsafe thread cancellation.
7306
+ if args.rebuild:
7307
+ providers = [
7308
+ provider
7309
+ for provider in ("claude", "codex")
7310
+ if source in (provider, "all")
7311
+ ]
7312
+ for provider in providers:
7313
+ outcome = _run_transcript_rebuild_worker(
7314
+ provider, lock_timeout=lt
6568
7315
  )
6569
- if conv_stats.lock_contended:
7316
+ retry = (
7317
+ "Re-run `cctally cache-sync "
7318
+ f"--source {provider} --rebuild`."
7319
+ )
7320
+ if outcome.timed_out:
6570
7321
  eprint(
6571
- "[cache-sync] transcript sync skipped (claude): "
6572
- "another process holds the conversations lock"
7322
+ "[cache-sync] transcript rebuild timed out: "
7323
+ f"provider={provider} store=conversations.db "
7324
+ f"phase={outcome.phase} after "
7325
+ f"{_transcript_rebuild_timeout_seconds():.1f}s without "
7326
+ f"progress (+{outcome.elapsed_seconds:.1f}s total); "
7327
+ "core accounting/quota sync is complete; any partial "
7328
+ f"transcript state remains retry-safe and incomplete. {retry}"
6573
7329
  )
6574
- contended = contended or bool(args.rebuild)
6575
- else:
7330
+ _p_root.__exit__(None, None, None)
7331
+ if _perf.enabled():
7332
+ _perf.flush_stderr(_perf.current_root())
7333
+ return 1
7334
+ if outcome.error is not None or outcome.stats is None:
6576
7335
  eprint(
6577
- f"[cache-sync] claude transcripts done: "
6578
- f"{conv_stats.files_processed} processed, "
6579
- f"{conv_stats.files_skipped_unchanged} skipped"
7336
+ "[cache-sync] transcript rebuild failed: "
7337
+ f"provider={provider} store=conversations.db "
7338
+ f"phase={outcome.phase} ({outcome.error}); "
7339
+ f"core accounting/quota sync is complete. {retry}"
6580
7340
  )
6581
- if source in ("codex", "all"):
6582
- conv_stats = sync_codex_conversations(
6583
- conversation_conn, rebuild=args.rebuild, lock_timeout=lt
6584
- )
7341
+ _p_root.__exit__(None, None, None)
7342
+ if _perf.enabled():
7343
+ _perf.flush_stderr(_perf.current_root())
7344
+ return 1
7345
+ conv_stats = outcome.stats
6585
7346
  if conv_stats.lock_contended:
6586
7347
  eprint(
6587
- "[cache-sync] transcript sync skipped (codex): "
6588
- "another process holds the conversations lock"
7348
+ "[cache-sync] transcript rebuild incomplete: "
7349
+ f"provider={provider} store=conversations.db phase=lock "
7350
+ "(another process holds the conversations lock); "
7351
+ f"core accounting/quota sync is complete. {retry}"
6589
7352
  )
6590
- contended = contended or bool(args.rebuild)
7353
+ contended = True
7354
+ elif conv_stats.files_failed:
7355
+ eprint(
7356
+ "[cache-sync] transcript rebuild incomplete: "
7357
+ f"provider={provider} store=conversations.db phase=ingest "
7358
+ f"({conv_stats.files_failed} file(s) failed); "
7359
+ f"core accounting/quota sync is complete. {retry}"
7360
+ )
7361
+ contended = True
6591
7362
  else:
6592
7363
  eprint(
6593
- f"[cache-sync] codex transcripts done: "
7364
+ f"[cache-sync] {provider} transcripts done: "
6594
7365
  f"{conv_stats.files_processed} processed, "
6595
7366
  f"{conv_stats.files_skipped_unchanged} skipped"
6596
7367
  )
6597
- finally:
6598
- conversation_conn.close()
7368
+ else:
7369
+ try:
7370
+ conversation_conn = open_conversations_db()
7371
+ except (OSError, sqlite3.DatabaseError) as exc:
7372
+ eprint(
7373
+ f"[cache-sync] transcript store unavailable ({exc}); "
7374
+ "core accounting/quota sync is complete"
7375
+ )
7376
+ _p_root.__exit__(None, None, None)
7377
+ if _perf.enabled():
7378
+ _perf.flush_stderr(_perf.current_root())
7379
+ return 1 if contended else 0
7380
+ try:
7381
+ if source in ("claude", "all"):
7382
+ conv_stats = sync_claude_conversations(
7383
+ conversation_conn, rebuild=False, lock_timeout=lt
7384
+ )
7385
+ if conv_stats.lock_contended:
7386
+ eprint(
7387
+ "[cache-sync] transcript sync skipped (claude): "
7388
+ "another process holds the conversations lock"
7389
+ )
7390
+ else:
7391
+ eprint(
7392
+ f"[cache-sync] claude transcripts done: "
7393
+ f"{conv_stats.files_processed} processed, "
7394
+ f"{conv_stats.files_skipped_unchanged} skipped"
7395
+ )
7396
+ if source in ("codex", "all"):
7397
+ conv_stats = sync_codex_conversations(
7398
+ conversation_conn, rebuild=False, lock_timeout=lt
7399
+ )
7400
+ if conv_stats.lock_contended:
7401
+ eprint(
7402
+ "[cache-sync] transcript sync skipped (codex): "
7403
+ "another process holds the conversations lock"
7404
+ )
7405
+ else:
7406
+ eprint(
7407
+ f"[cache-sync] codex transcripts done: "
7408
+ f"{conv_stats.files_processed} processed, "
7409
+ f"{conv_stats.files_skipped_unchanged} skipped"
7410
+ )
7411
+ finally:
7412
+ conversation_conn.close()
6599
7413
 
6600
7414
  _p_root.__exit__(None, None, None)
6601
7415
  # #276 perf: when tracing is enabled, flush the completed "cache-sync"