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
@@ -266,6 +266,7 @@ from _cctally_dashboard_sources import (
266
266
  _claude_accounts_wire,
267
267
  accounts_identity_digest,
268
268
  build_codex_source_state,
269
+ codex_decision_deadline_passed,
269
270
  refresh_codex_source_clock,
270
271
  resolve_dashboard_source_semantics,
271
272
  )
@@ -1029,6 +1030,100 @@ def _tui_build_five_hour_milestones(
1029
1030
  return out
1030
1031
 
1031
1032
 
1033
+ @dataclass(frozen=True)
1034
+ class SyncFailureAttribution:
1035
+ """Database ownership retained at the dashboard leg catch boundary.
1036
+
1037
+ The raw compatibility string remains on ``DataSnapshot.last_sync_error``;
1038
+ this compact sidecar carries only the facts needed for truthful,
1039
+ privacy-safe envelope classification.
1040
+ """
1041
+
1042
+ leg: str
1043
+ database: str
1044
+ corruption: bool
1045
+
1046
+
1047
+ class _StatsSnapshotCorruption(Exception):
1048
+ """Internal control signal for one post-query stats heal attempt."""
1049
+
1050
+ def __init__(self, cause: Exception) -> None:
1051
+ self.cause = cause
1052
+ super().__init__(str(cause))
1053
+
1054
+
1055
+ def _tui_attribute_corruption(
1056
+ conn: sqlite3.Connection,
1057
+ exc: Exception,
1058
+ *,
1059
+ database: str,
1060
+ ) -> tuple[str, bool]:
1061
+ """Classify corruption against the actual database at the catch site.
1062
+
1063
+ Mixed stats/cache builders can surface the same SQLite message from either
1064
+ family. Only after a corruption-shaped exception do we run the expensive
1065
+ stats ``quick_check``: a failed/non-ok result positively attributes stats;
1066
+ an intact stats family leaves the failure attributed to cache. No path or
1067
+ exception-text parsing is used for database identity.
1068
+ """
1069
+
1070
+ corruption = bool(_cctally()._is_sqlite_corruption_error(exc))
1071
+ attributed = database
1072
+ if corruption and database == "stats_or_cache":
1073
+ try:
1074
+ rows = conn.execute("PRAGMA quick_check").fetchall()
1075
+ stats_ok = len(rows) == 1 and tuple(rows[0]) == ("ok",)
1076
+ attributed = "cache" if stats_ok else "stats"
1077
+ except sqlite3.DatabaseError:
1078
+ attributed = "stats"
1079
+ return attributed, corruption
1080
+
1081
+
1082
+ def _tui_capture_sync_failure(
1083
+ conn: sqlite3.Connection,
1084
+ errors: list[str],
1085
+ failures: list[SyncFailureAttribution],
1086
+ *,
1087
+ leg: str,
1088
+ database: str,
1089
+ exc: Exception,
1090
+ stats_heal_attempted: bool,
1091
+ ) -> None:
1092
+ """Record one attributed leg failure or request the single stats retry."""
1093
+
1094
+ attributed_database, corruption = _tui_attribute_corruption(
1095
+ conn, exc, database=database
1096
+ )
1097
+ failure = SyncFailureAttribution(
1098
+ leg=leg,
1099
+ database=attributed_database,
1100
+ corruption=corruption,
1101
+ )
1102
+ if (
1103
+ failure.database == "stats"
1104
+ and failure.corruption
1105
+ and not stats_heal_attempted
1106
+ ):
1107
+ raise _StatsSnapshotCorruption(exc)
1108
+ failures.append(failure)
1109
+ errors.append(f"{leg}: {exc}")
1110
+
1111
+
1112
+ def _tui_heal_post_query_stats(exc: Exception) -> bool:
1113
+ """Invoke the existing stats replacement engine after all handles close."""
1114
+
1115
+ import _cctally_store
1116
+
1117
+ heal = getattr(_cctally_store, "HEAL_HOOK", None)
1118
+ if heal is None:
1119
+ return False
1120
+ try:
1121
+ return bool(heal("stats", exc, post_query=True))
1122
+ except Exception as heal_exc: # noqa: BLE001 — snapshot must still degrade
1123
+ eprint(f"[heal] dashboard stats auto-heal failed: {heal_exc}")
1124
+ return False
1125
+
1126
+
1032
1127
  @dataclass
1033
1128
  class DataSnapshot:
1034
1129
  """All data needed to render one TUI frame. Produced by sync thread,
@@ -1040,6 +1135,7 @@ class DataSnapshot:
1040
1135
  last_sync_at: float | None # monotonic (time.monotonic())
1041
1136
  last_sync_error: str | None
1042
1137
  generated_at: dt.datetime # wall-clock UTC for displayed timestamps
1138
+ sync_failures: tuple[SyncFailureAttribution, ...] = ()
1043
1139
  # ---- v2 additions (spec §4.5) ----
1044
1140
  percent_milestones: list[TuiPercentMilestone] = field(default_factory=list)
1045
1141
  weekly_history: list[TuiTrendRow] = field(default_factory=list)
@@ -1724,6 +1820,7 @@ def _tui_build_sessions(
1724
1820
  limit: int = 100,
1725
1821
  skip_sync: bool = False,
1726
1822
  use_session_cache: bool = False,
1823
+ with_titles: bool = False,
1727
1824
  ) -> list[TuiSessionRow]:
1728
1825
  """Load the last `limit` Claude sessions (merged across resumes).
1729
1826
 
@@ -1753,6 +1850,13 @@ def _tui_build_sessions(
1753
1850
  ``False`` → the from-scratch 365-day fetch, so a non-sync-thread caller
1754
1851
  with a shifted ``now`` can NEVER pollute the shared cache (the Bundle 2
1755
1852
  Group A lesson). The visible rows are byte-identical either way.
1853
+
1854
+ ``with_titles``: attach each row's transcript-derived title from the
1855
+ independent conversation store. DASHBOARD-only — ``TuiSessionRow.title`` is
1856
+ read by the dashboard envelope alone (the terminal TUI never renders it), so
1857
+ the default keeps the core/TUI build free of any transcript-store access
1858
+ (#320). The read itself is bounded and fail-soft; see
1859
+ ``read_session_titles_bounded``.
1756
1860
  """
1757
1861
  # Bounded scan window — the sessions pane promises "last `limit`". A
1758
1862
  # 365-day scan covers virtually all users (even one-session-every-few-days
@@ -1781,12 +1885,33 @@ def _tui_build_sessions(
1781
1885
  (), now_utc=now_utc, limit=limit, display_tz=None,
1782
1886
  aggregated_override=aggregated_override,
1783
1887
  )
1784
- # #320: transcript-derived titles are optional decoration. The core
1785
- # dashboard/TUI snapshot must never open conversations.db, because even a
1786
- # fail-soft read pays SQLite's lock timeout before it can fail. Conversation
1787
- # routes retain title derivation; the accounting Sessions panel renders its
1788
- # existing em-dash fallback when the independent store is unavailable.
1789
- return list(view.rows)
1888
+ rows = list(view.rows)
1889
+ if not with_titles:
1890
+ # #320: transcript-derived titles are optional decoration, and the TUI
1891
+ # has no consumer for them (the field is dashboard-only), so the core
1892
+ # build never touches the independent transcript store at all.
1893
+ return rows
1894
+ # Dashboard build (see ``with_titles`` in the docstring): re-attach the
1895
+ # Session-column titles the #320 store split dropped. ``read_session_titles_bounded``
1896
+ # never uses ``open_conversations_db`` and never waits out a lock — a store
1897
+ # that is missing, locked, or mid-rebuild yields no titles and the panel
1898
+ # renders its em-dash fallback, which self-heals on a later tick. Titles are
1899
+ # stashed unconditionally on this server-internal row; the privacy gate is
1900
+ # applied later, at envelope serialization
1901
+ # (``snapshot_to_envelope(transcripts_visible=...)``).
1902
+ session_ids = [r.session_id for r in rows if r.session_id]
1903
+ if not session_ids:
1904
+ return rows
1905
+ titles = c._load_sibling("_cctally_cache").read_session_titles_bounded(
1906
+ session_ids,
1907
+ )
1908
+ if not titles:
1909
+ return rows
1910
+ return [
1911
+ dataclasses.replace(r, title=titles[r.session_id])
1912
+ if r.session_id in titles else r
1913
+ for r in rows
1914
+ ]
1790
1915
 
1791
1916
 
1792
1917
  def _tui_sessions_cached(
@@ -1892,7 +2017,8 @@ def _fetch_affected_session_entries(
1892
2017
  sql = (
1893
2018
  "SELECT se.timestamp_utc, se.model, se.input_tokens, se.output_tokens, "
1894
2019
  " se.cache_create_tokens, se.cache_read_tokens, se.source_path, "
1895
- " sf.session_id, sf.project_path, se.cost_usd_raw, se.speed "
2020
+ " sf.session_id, sf.project_path, se.cost_usd_raw, se.speed, "
2021
+ " se.cache_create_1h_tokens "
1896
2022
  "FROM session_entries se "
1897
2023
  "LEFT JOIN session_files sf ON sf.path = se.source_path "
1898
2024
  "WHERE se.timestamp_utc >= ? AND se.timestamp_utc <= ? "
@@ -1928,6 +2054,7 @@ def _fetch_affected_session_entries(
1928
2054
  project_path=row[8],
1929
2055
  cost_usd=row[9],
1930
2056
  usage_extra=({"speed": row[10]} if row[10] is not None else None),
2057
+ cache_1h_tokens=row[11], # #195
1931
2058
  )
1932
2059
  for row in rows
1933
2060
  ]
@@ -2008,7 +2135,7 @@ def _tui_build_session_detail_indexed(
2008
2135
  f" se.input_tokens, se.output_tokens, "
2009
2136
  f" se.cache_create_tokens, se.cache_read_tokens, "
2010
2137
  f" se.source_path, sf.session_id, sf.project_path, "
2011
- f" se.cost_usd_raw "
2138
+ f" se.cost_usd_raw, se.speed, se.cache_create_1h_tokens "
2012
2139
  f"FROM session_entries se "
2013
2140
  f"LEFT JOIN session_files sf ON sf.path = se.source_path "
2014
2141
  f"WHERE se.timestamp_utc >= ? AND se.timestamp_utc <= ? "
@@ -2028,6 +2155,8 @@ def _tui_build_session_detail_indexed(
2028
2155
  session_id=row[7],
2029
2156
  project_path=row[8],
2030
2157
  cost_usd=row[9],
2158
+ usage_extra=({"speed": row[10]} if row[10] is not None else None),
2159
+ cache_1h_tokens=row[11], # #195
2031
2160
  )
2032
2161
  for row in cur
2033
2162
  ]
@@ -2354,6 +2483,73 @@ def _tui_project_claude_source_data(legacy_envelope: object) -> dict[str, object
2354
2483
  }
2355
2484
 
2356
2485
 
2486
+ def _tui_claude_domain_freshness(
2487
+ source_data: dict[str, object] | None,
2488
+ ) -> dict[str, str]:
2489
+ """Derive Claude axes from its selected weekly snapshot evidence.
2490
+
2491
+ The legacy current-week label has a third presentation-only ``aging``
2492
+ state. The source contract deliberately keeps the frozen fresh/stale
2493
+ vocabulary: only the exact stale label moves the weekly hero/quota axes.
2494
+ Missing evidence remains a capability/availability concern.
2495
+ """
2496
+ data = source_data if isinstance(source_data, dict) else {}
2497
+ hero = data.get("hero")
2498
+ current_week = hero.get("current_week") if isinstance(hero, dict) else None
2499
+ freshness = (
2500
+ current_week.get("freshness")
2501
+ if isinstance(current_week, dict) else None
2502
+ )
2503
+ weekly = (
2504
+ "stale"
2505
+ if isinstance(freshness, dict) and freshness.get("label") == "stale"
2506
+ else "fresh"
2507
+ )
2508
+ return {"hero": weekly, "quota": weekly, "sessions": "fresh"}
2509
+
2510
+
2511
+ def _refresh_claude_source_clock(
2512
+ state: SourceDashboardState,
2513
+ *,
2514
+ current_week: object,
2515
+ now_utc: dt.datetime,
2516
+ raw_config: dict[str, object],
2517
+ ) -> SourceDashboardState:
2518
+ """Advance Claude's weekly axes from frozen snapshot evidence only."""
2519
+ if state.source != "claude":
2520
+ return state
2521
+ if now_utc.tzinfo is None or now_utc.utcoffset() is None:
2522
+ raise ValueError("now_utc must be timezone-aware")
2523
+ captured = getattr(current_week, "latest_snapshot_at", None)
2524
+ if not isinstance(captured, dt.datetime):
2525
+ return state
2526
+ if captured.tzinfo is None or captured.utcoffset() is None:
2527
+ captured = captured.replace(tzinfo=dt.timezone.utc)
2528
+ age_seconds = max(
2529
+ 0.0,
2530
+ (
2531
+ now_utc.astimezone(dt.timezone.utc)
2532
+ - captured.astimezone(dt.timezone.utc)
2533
+ ).total_seconds(),
2534
+ )
2535
+ try:
2536
+ freshness_config = _get_oauth_usage_config(raw_config)
2537
+ except Exception:
2538
+ freshness_config = _OAUTH_USAGE_DEFAULTS
2539
+ weekly = (
2540
+ "stale"
2541
+ if _freshness_label(age_seconds, freshness_config) == "stale"
2542
+ else "fresh"
2543
+ )
2544
+ domain_freshness = dict(state.domain_freshness or {})
2545
+ domain_freshness.update({"hero": weekly, "quota": weekly})
2546
+ refreshed = dataclasses.replace(
2547
+ state,
2548
+ domain_freshness=domain_freshness,
2549
+ )
2550
+ return state if refreshed == state else refreshed
2551
+
2552
+
2357
2553
  def _tui_build_source_bundle(
2358
2554
  *,
2359
2555
  stats_conn,
@@ -2379,18 +2575,18 @@ def _tui_build_source_bundle(
2379
2575
  c = _cctally()
2380
2576
  cache_conn = c.open_cache_db()
2381
2577
  cache_read_tx = False
2382
- stats_read_tx = False
2383
2578
  try:
2384
- # Read both databases through stable snapshots. cache.db and stats.db
2385
- # cannot share one SQLite transaction, so a post-build signature check
2386
- # below rejects a generation that moves while the two snapshots are
2387
- # being assembled.
2579
+ # Keep cache.db on one stable snapshot, but leave stats.db in
2580
+ # statement-scoped autocommit. A dashboard source build can spend
2581
+ # seconds folding provider rows; holding one stats read transaction
2582
+ # across that CPU work pins every intervening WAL frame and defeats
2583
+ # SQLite's default 1,000-page autocheckpoint (#393). The before/after
2584
+ # composite signature below is already the cross-database consistency
2585
+ # gate: any stats generation movement rejects this build, so a long
2586
+ # stats snapshot buys no correctness and creates unbounded WAL growth.
2388
2587
  if not cache_conn.in_transaction:
2389
2588
  cache_conn.execute("BEGIN")
2390
2589
  cache_read_tx = True
2391
- if not stats_conn.in_transaction:
2392
- stats_conn.execute("BEGIN")
2393
- stats_read_tx = True
2394
2590
  if common_range_start is None:
2395
2591
  common_range_start = now_utc - dt.timedelta(days=30)
2396
2592
  if common_range_start.tzinfo is None or common_range_start.utcoffset() is None:
@@ -2517,6 +2713,7 @@ def _tui_build_source_bundle(
2517
2713
  ),
2518
2714
  **({"accounts": claude_accounts} if claude_accounts else {}),
2519
2715
  },
2716
+ domain_freshness=_tui_claude_domain_freshness(claude_data),
2520
2717
  )
2521
2718
  if codex_ingest_failed:
2522
2719
  warning = SourceDashboardWarning(
@@ -2542,10 +2739,20 @@ def _tui_build_source_bundle(
2542
2739
  # incoherence generation is intentionally not retained through
2543
2740
  # that repair opportunity; all other fresh partial states remain
2544
2741
  # eligible for exact reuse.
2742
+ # #350 spec §3.3: a passed cycle decision deadline forces an
2743
+ # AUTHORITATIVE rebuild. Weekly-cycle resolution is time-dependent
2744
+ # even on frozen evidence, and the reuse path would otherwise hand
2745
+ # back the exact prior object with no re-check at all — so an idle
2746
+ # dashboard would diverge from a freshly rebuilt one. Leaving
2747
+ # ``codex = None`` routes to the existing build below. Bounded by
2748
+ # construction: one rebuild per crossing, not one per tick.
2545
2749
  codex = (
2546
- None if prior_codex is not None and any(
2547
- warning.code == "codex_projection_incoherent"
2548
- for warning in prior_codex.warnings
2750
+ None if prior_codex is not None and (
2751
+ any(
2752
+ warning.code == "codex_projection_incoherent"
2753
+ for warning in prior_codex.warnings
2754
+ )
2755
+ or codex_decision_deadline_passed(prior_codex, now_utc)
2549
2756
  ) else reuse_coherent_source_state(
2550
2757
  prior_codex, data_version=codex_version,
2551
2758
  )
@@ -2582,6 +2789,14 @@ def _tui_build_source_bundle(
2582
2789
  if prior_codex is not None
2583
2790
  else unavailable_source_state("codex", warning)
2584
2791
  )
2792
+ # #350 spec §3.3: clock Codex UNCONDITIONALLY — after every build /
2793
+ # reuse / degrade branch and before composition — so the retained
2794
+ # cycle's expiry invariant holds on EVERY path, including the reuse
2795
+ # path that returns the exact prior object (§2.5). Same-instant identity
2796
+ # is preserved by ``refresh_codex_source_clock``'s own data-equality
2797
+ # guard, so a freshly built state is handed back unchanged. Claude is
2798
+ # deliberately untouched.
2799
+ codex = refresh_codex_source_clock(codex, now_utc=now_utc)
2585
2800
  combined = compose_all_state(claude, codex)
2586
2801
  bundle = SourceDashboardBundle(
2587
2802
  source_schema_version=1,
@@ -2595,9 +2810,6 @@ def _tui_build_source_bundle(
2595
2810
  if cache_read_tx:
2596
2811
  cache_conn.rollback()
2597
2812
  cache_read_tx = False
2598
- if stats_read_tx:
2599
- stats_conn.rollback()
2600
- stats_read_tx = False
2601
2813
  post_stats_digest = codex_stats_digest(stats_conn)
2602
2814
  post_signature = c.compute_signature(
2603
2815
  cache_conn,
@@ -2614,8 +2826,6 @@ def _tui_build_source_bundle(
2614
2826
  finally:
2615
2827
  if cache_read_tx:
2616
2828
  cache_conn.rollback()
2617
- if stats_read_tx:
2618
- stats_conn.rollback()
2619
2829
  cache_conn.close()
2620
2830
 
2621
2831
 
@@ -2637,6 +2847,7 @@ def _tui_hydrating_source_bundle() -> SourceDashboardBundle:
2637
2847
  last_success_at=None,
2638
2848
  capabilities={},
2639
2849
  data=None,
2850
+ domain_freshness={"hero": "stale", "quota": "stale", "sessions": "stale"},
2640
2851
  )
2641
2852
  codex = SourceDashboardState(
2642
2853
  source="codex",
@@ -2647,6 +2858,7 @@ def _tui_hydrating_source_bundle() -> SourceDashboardBundle:
2647
2858
  last_success_at=None,
2648
2859
  capabilities={},
2649
2860
  data=None,
2861
+ domain_freshness={"hero": "stale", "quota": "stale", "sessions": "stale"},
2650
2862
  )
2651
2863
  return SourceDashboardBundle(
2652
2864
  source_schema_version=1,
@@ -2671,6 +2883,8 @@ def _tui_source_bundle_can_idle(bundle: SourceDashboardBundle | None) -> bool:
2671
2883
  state = bundle.sources.get(source)
2672
2884
  if not isinstance(state, SourceDashboardState):
2673
2885
  return False
2886
+ # Idle eligibility is provider-generation coherence, deliberately not a
2887
+ # hero/quota/sessions age aggregate.
2674
2888
  if (state.availability not in ("ok", "empty")
2675
2889
  or state.freshness != "fresh"
2676
2890
  or state.data is None):
@@ -2705,6 +2919,55 @@ def _tui_build_snapshot(
2705
2919
  display_tz_pref_override: "str | None" = None,
2706
2920
  precompute_envelope: bool = False,
2707
2921
  runtime_bind: "str | None" = None,
2922
+ ) -> DataSnapshot:
2923
+ """Build once, then perform at most one post-query stats heal/reopen."""
2924
+
2925
+ try:
2926
+ return _tui_build_snapshot_once(
2927
+ now_utc=now_utc,
2928
+ skip_sync=skip_sync,
2929
+ display_tz_pref_override=display_tz_pref_override,
2930
+ precompute_envelope=precompute_envelope,
2931
+ runtime_bind=runtime_bind,
2932
+ stats_heal_attempted=False,
2933
+ )
2934
+ except _StatsSnapshotCorruption as fault:
2935
+ # ``_tui_build_snapshot_once`` closes its live stats connection before
2936
+ # this boundary. The replacement-capable hook can therefore satisfy
2937
+ # the whole-family drain gate, and the retry opens the published family.
2938
+ _tui_heal_post_query_stats(fault.cause)
2939
+ try:
2940
+ return _tui_build_snapshot_once(
2941
+ now_utc=now_utc,
2942
+ # The first attempt already completed the one cache ingest plan.
2943
+ skip_sync=True,
2944
+ display_tz_pref_override=display_tz_pref_override,
2945
+ precompute_envelope=precompute_envelope,
2946
+ runtime_bind=runtime_bind,
2947
+ stats_heal_attempted=True,
2948
+ )
2949
+ except Exception as retry_exc:
2950
+ # A fresh opener can still lose a race to damage/maintenance after
2951
+ # the heal returns. Corruption on this one retry is a stable,
2952
+ # typed degraded frame—not a third attempt or a dashboard crash.
2953
+ if not _cctally()._is_sqlite_corruption_error(retry_exc):
2954
+ raise
2955
+ return _tui_stats_retry_degraded_snapshot(
2956
+ now_utc=now_utc or dt.datetime.now(dt.timezone.utc),
2957
+ exc=retry_exc,
2958
+ precompute_envelope=precompute_envelope,
2959
+ runtime_bind=runtime_bind,
2960
+ )
2961
+
2962
+
2963
+ def _tui_build_snapshot_once(
2964
+ *,
2965
+ now_utc: dt.datetime | None = None,
2966
+ skip_sync: bool = False,
2967
+ display_tz_pref_override: "str | None" = None,
2968
+ precompute_envelope: bool = False,
2969
+ runtime_bind: "str | None" = None,
2970
+ stats_heal_attempted: bool,
2708
2971
  ) -> DataSnapshot:
2709
2972
  """Single-shot build of a DataSnapshot from the DB + cache.
2710
2973
 
@@ -2755,6 +3018,23 @@ def _tui_build_snapshot(
2755
3018
  conn = open_db()
2756
3019
  try:
2757
3020
  errors: list[str] = []
3021
+ sync_failures: list[SyncFailureAttribution] = []
3022
+
3023
+ def capture_failure(
3024
+ leg: str,
3025
+ database: str,
3026
+ exc: Exception,
3027
+ ) -> None:
3028
+ _tui_capture_sync_failure(
3029
+ conn,
3030
+ errors,
3031
+ sync_failures,
3032
+ leg=leg,
3033
+ database=database,
3034
+ exc=exc,
3035
+ stats_heal_attempted=stats_heal_attempted,
3036
+ )
3037
+
2758
3038
  cw: TuiCurrentWeek | None = None
2759
3039
  fc: Any | None = None
2760
3040
  trend: list[TuiTrendRow] = []
@@ -2803,6 +3083,7 @@ def _tui_build_snapshot(
2803
3083
  return None, exc
2804
3084
 
2805
3085
  operations = [_claude_leg]
3086
+ operation_origins = ["view_model.claude.sync"]
2806
3087
  if precompute_envelope:
2807
3088
  def _codex_leg(active_conn):
2808
3089
  try:
@@ -2817,10 +3098,13 @@ def _tui_build_snapshot(
2817
3098
  return None, exc
2818
3099
 
2819
3100
  operations.append(_codex_leg)
3101
+ operation_origins.append("view_model.codex.sync")
2820
3102
 
2821
3103
  results, cache_conn = (
2822
3104
  cache_mod._run_cache_plan_with_recovery(
2823
- cache_conn, tuple(operations)
3105
+ cache_conn,
3106
+ tuple(operations),
3107
+ origins=tuple(operation_origins),
2824
3108
  )
2825
3109
  )
2826
3110
  claude_ingest, claude_error = results[0]
@@ -2830,7 +3114,9 @@ def _tui_build_snapshot(
2830
3114
  )
2831
3115
  else:
2832
3116
  claude_ingest_failed = True
2833
- errors.append(f"sync-cache: {claude_error}")
3117
+ capture_failure(
3118
+ "sync-cache", "cache", claude_error
3119
+ )
2834
3120
  if precompute_envelope:
2835
3121
  codex_ingest, codex_error = results[1]
2836
3122
  if codex_error is None:
@@ -2839,14 +3125,16 @@ def _tui_build_snapshot(
2839
3125
  )
2840
3126
  else:
2841
3127
  codex_ingest_failed = True
2842
- errors.append(f"sync-codex-cache: {codex_error}")
3128
+ capture_failure(
3129
+ "sync-codex-cache", "cache", codex_error
3130
+ )
2843
3131
  finally:
2844
3132
  cache_conn.close()
2845
3133
  except Exception as exc:
2846
3134
  claude_ingest_failed = True
2847
3135
  if precompute_envelope:
2848
3136
  codex_ingest_failed = True
2849
- errors.append(f"sync-cache-open: {exc}")
3137
+ capture_failure("sync-cache-open", "cache", exc)
2850
3138
  # Force pure reads for every view builder below, independent of the
2851
3139
  # caller's flag: the single ingest above is the only glob per tick.
2852
3140
  skip_sync = True
@@ -2915,13 +3203,13 @@ def _tui_build_snapshot(
2915
3203
  if prior_snap is not None:
2916
3204
  prior_source_bundle = getattr(prior_snap, "source_bundle", None)
2917
3205
  except Exception as exc:
2918
- errors.append(f"prior-source-bundle: {exc}")
3206
+ capture_failure("prior-source-bundle", "other", exc)
2919
3207
  dispatch_sig = None
2920
3208
  with _perf.phase("signature"):
2921
3209
  try:
2922
3210
  dispatch_sig = _tui_compute_dispatch_signature(conn)
2923
3211
  except Exception as exc:
2924
- errors.append(f"dispatch-signature: {exc}")
3212
+ capture_failure("dispatch-signature", "stats_or_cache", exc)
2925
3213
  dispatch_sig = None
2926
3214
  if dispatch_sig is not None:
2927
3215
  # The idle decision keys on the DB signature AND a render key
@@ -2945,6 +3233,12 @@ def _tui_build_snapshot(
2945
3233
  data_version = _snapshot_data_version(dispatch_sig)
2946
3234
  if (prior_snap is not None and prior_key is not None
2947
3235
  and dispatch_key == prior_key
3236
+ and not any(
3237
+ failure.database == "stats" and failure.corruption
3238
+ for failure in getattr(
3239
+ prior_snap, "sync_failures", ()
3240
+ )
3241
+ )
2948
3242
  and not _snapshot_period_rolled_over(
2949
3243
  prior_snap, now_utc, _build_display_tz)):
2950
3244
  with _perf.phase("idle-decision"):
@@ -3061,12 +3355,12 @@ def _tui_build_snapshot(
3061
3355
  finally:
3062
3356
  _rc_cache_conn.close()
3063
3357
  except Exception as exc:
3064
- errors.append(f"snapshot-cache-reconcile: {exc}")
3358
+ capture_failure("snapshot-cache-reconcile", "cache", exc)
3065
3359
  with _perf.phase("build.current_week"):
3066
3360
  try:
3067
3361
  cw = _tui_build_current_week(conn, now_utc, skip_sync=skip_sync)
3068
3362
  except Exception as exc:
3069
- errors.append(f"current-week: {exc}")
3363
+ capture_failure("current-week", "stats_or_cache", exc)
3070
3364
  fc_view = None
3071
3365
  with _perf.phase("build.forecast"):
3072
3366
  try:
@@ -3080,7 +3374,7 @@ def _tui_build_snapshot(
3080
3374
  )
3081
3375
  fc = fc_view.output if fc_view is not None else None
3082
3376
  except Exception as exc:
3083
- errors.append(f"forecast: {exc}")
3377
+ capture_failure("forecast", "stats_or_cache", exc)
3084
3378
  # Trend: source from build_trend_view so we capture the 3-sample
3085
3379
  # avg_dollars_per_pct alongside the rows. The TUI build path
3086
3380
  # historically called _tui_build_trend (which now wraps the
@@ -3098,7 +3392,7 @@ def _tui_build_snapshot(
3098
3392
  trend = list(_trend_view.rows)
3099
3393
  trend_avg_dpp = _trend_view.avg_dollars_per_pct
3100
3394
  except Exception as exc:
3101
- errors.append(f"trend: {exc}")
3395
+ capture_failure("trend", "stats_or_cache", exc)
3102
3396
  with _perf.phase("build.sessions"):
3103
3397
  try:
3104
3398
  # The sessions aggregator goes through
@@ -3108,16 +3402,22 @@ def _tui_build_snapshot(
3108
3402
  # both avoid ingest latency/lock contention.
3109
3403
  sessions = _tui_build_sessions(
3110
3404
  now_utc, skip_sync=skip_sync, use_session_cache=True,
3405
+ # ``precompute_envelope`` is this build's documented
3406
+ # DASHBOARD marker (set by the sync-thread rebuild + the
3407
+ # initial snapshot, never by the terminal TUI), and the
3408
+ # Session-column title is a dashboard-only field — so it
3409
+ # also gates the bounded transcript-store title read.
3410
+ with_titles=precompute_envelope,
3111
3411
  )
3112
3412
  except Exception as exc:
3113
- errors.append(f"sessions: {exc}")
3413
+ capture_failure("sessions", "cache", exc)
3114
3414
  # ---- v2 additions ----
3115
3415
  with _perf.phase("build.milestones"):
3116
3416
  try:
3117
3417
  if cw is not None:
3118
3418
  milestones = _tui_build_percent_milestones(conn)
3119
3419
  except Exception as exc:
3120
- errors.append(f"milestones: {exc}")
3420
+ capture_failure("milestones", "stats", exc)
3121
3421
  history: list = []
3122
3422
  history_median_dpp: "float | None" = None
3123
3423
  with _perf.phase("build.weekly_history"):
@@ -3134,7 +3434,7 @@ def _tui_build_snapshot(
3134
3434
  history = list(history_view.rows)
3135
3435
  history_median_dpp = history_view.median_dpp_non_current_4w
3136
3436
  except Exception as exc:
3137
- errors.append(f"weekly-history: {exc}")
3437
+ capture_failure("weekly-history", "stats_or_cache", exc)
3138
3438
  # ---- v2.1 additions: dashboard Weekly / Monthly panels ----
3139
3439
  # Sync-thread view-model totals (spec §6.6): sum directly over
3140
3440
  # the panel rows the dashboard ACTUALLY renders. The previous
@@ -3167,7 +3467,7 @@ def _tui_build_snapshot(
3167
3467
  (r.total_tokens for r in weekly_periods), 0,
3168
3468
  )
3169
3469
  except Exception as exc:
3170
- errors.append(f"weekly-periods: {exc}")
3470
+ capture_failure("weekly-periods", "stats_or_cache", exc)
3171
3471
  # Sync-thread view-model totals (spec §6.6): sum-over-visible-rows
3172
3472
  # (same invariant as weekly above). Monthly has no Bug-K analogue,
3173
3473
  # but coupling the footer total to the panel-row source of truth
@@ -3189,7 +3489,7 @@ def _tui_build_snapshot(
3189
3489
  (r.total_tokens for r in monthly_periods), 0,
3190
3490
  )
3191
3491
  except Exception as exc:
3192
- errors.append(f"monthly-periods: {exc}")
3492
+ capture_failure("monthly-periods", "cache", exc)
3193
3493
  # ---- v2.2 additions: dashboard Blocks / Daily panels ----
3194
3494
  # Issue #56: build the BlocksView once and read both rows
3195
3495
  # (presentation) and totals (envelope scalars) from the same
@@ -3212,7 +3512,7 @@ def _tui_build_snapshot(
3212
3512
  blocks_total_cost_usd = _blocks_view.total_cost_usd
3213
3513
  blocks_total_tokens = _blocks_view.total_tokens
3214
3514
  except Exception as exc:
3215
- errors.append(f"blocks-panel: {exc}")
3515
+ capture_failure("blocks-panel", "stats_or_cache", exc)
3216
3516
  # Sync-thread view-model totals (Bundle 1 / spec §6.6):
3217
3517
  # sum-over-visible-rows (same invariant as weekly/monthly above).
3218
3518
  # Gap days in the materialized panel carry ``cost_usd=0.0`` /
@@ -3235,7 +3535,7 @@ def _tui_build_snapshot(
3235
3535
  (r.total_tokens for r in daily_panel), 0,
3236
3536
  )
3237
3537
  except Exception as exc:
3238
- errors.append(f"daily-panel: {exc}")
3538
+ capture_failure("daily-panel", "cache", exc)
3239
3539
  # ---- threshold-actions T5: alerts envelope array ----
3240
3540
  # Precomputed at sync time so `snapshot_to_envelope` stays a pure
3241
3541
  # renderer (no DB I/O on the dashboard hot path; mirrors how
@@ -3245,7 +3545,7 @@ def _tui_build_snapshot(
3245
3545
  try:
3246
3546
  alerts = _build_alerts_envelope_array(conn)
3247
3547
  except Exception as exc:
3248
- errors.append(f"alerts: {exc}")
3548
+ capture_failure("alerts", "stats", exc)
3249
3549
  # ---- 5h in-place credit (v1.7.x) ----
3250
3550
  # Load 5h milestones (pre + post credit) for the current
3251
3551
  # block's window so CurrentWeekModal can render a merged
@@ -3259,7 +3559,7 @@ def _tui_build_snapshot(
3259
3559
  win_key = cw.five_hour_block.get("five_hour_window_key")
3260
3560
  fh_milestones = _tui_build_five_hour_milestones(conn, win_key)
3261
3561
  except Exception as exc:
3262
- errors.append(f"five-hour-milestones: {exc}")
3562
+ capture_failure("five-hour-milestones", "stats", exc)
3263
3563
  # ---- hero-modal historical milestones week index (spec §1a/§3) ----
3264
3564
  # Built ONLY here on the non-idle rebuild (the idle short-circuit
3265
3565
  # returns before this phase and carries the prior index forward via
@@ -3271,7 +3571,7 @@ def _tui_build_snapshot(
3271
3571
  try:
3272
3572
  week_index = sys.modules["cctally"].build_claude_week_index(conn)
3273
3573
  except Exception as exc:
3274
- errors.append(f"week-index: {exc}")
3574
+ capture_failure("week-index", "stats", exc)
3275
3575
  # ---- Projects panel + modal envelope (spec §5.2, plan Task 1) -----
3276
3576
  # Per-tick aggregation lives on the sync thread; the dashboard's
3277
3577
  # pure ``snapshot_to_envelope`` reads ``snap.projects_envelope``
@@ -3321,7 +3621,7 @@ def _tui_build_snapshot(
3321
3621
  use_projects_env_cache=use_projects_env_cache,
3322
3622
  )
3323
3623
  except Exception as exc:
3324
- errors.append(f"projects-envelope: {exc}")
3624
+ capture_failure("projects-envelope", "stats_or_cache", exc)
3325
3625
  finally:
3326
3626
  try:
3327
3627
  conn.execute("DROP VIEW IF EXISTS session_entries")
@@ -3381,7 +3681,9 @@ def _tui_build_snapshot(
3381
3681
  )
3382
3682
  sessions = annotated
3383
3683
  except Exception as exc:
3384
- errors.append(f"projects-cross-nav-bind: {exc}")
3684
+ capture_failure(
3685
+ "projects-cross-nav-bind", "stats_or_cache", exc
3686
+ )
3385
3687
 
3386
3688
  # Cache-report panel + modal envelope block (spec
3387
3689
  # 2026-05-21-cache-report-panel-design.md §5.2). Per-tick build
@@ -3416,7 +3718,7 @@ def _tui_build_snapshot(
3416
3718
  use_cache_report_cache=use_cache_report_cache,
3417
3719
  )
3418
3720
  except Exception as exc:
3419
- errors.append(f"cache-report: {exc}")
3721
+ capture_failure("cache-report", "cache", exc)
3420
3722
 
3421
3723
  # ---- #268 M4: doctor / config / update-state precompute (spec §6) ----
3422
3724
  # Precompute the envelope's doctor / config / update-state reads ONCE
@@ -3434,14 +3736,14 @@ def _tui_build_snapshot(
3434
3736
  now_utc, runtime_bind,
3435
3737
  )
3436
3738
  except Exception as exc:
3437
- errors.append(f"doctor-precompute: {exc}")
3739
+ capture_failure("doctor-precompute", "other", exc)
3438
3740
  with _perf.phase("envelope.precompute"):
3439
3741
  try:
3440
3742
  envelope_precompute_block = _tui_precompute_envelope_config(
3441
3743
  raw_config,
3442
3744
  )
3443
3745
  except Exception as exc:
3444
- errors.append(f"envelope-precompute: {exc}")
3746
+ capture_failure("envelope-precompute", "other", exc)
3445
3747
 
3446
3748
  # Determine the shared visible interval before publishing either source.
3447
3749
  # The actual source bundle is built after ``snap`` exists, so Claude's
@@ -3460,6 +3762,7 @@ def _tui_build_snapshot(
3460
3762
  last_sync_at=time.monotonic(),
3461
3763
  last_sync_error=("; ".join(errors) if errors else None),
3462
3764
  generated_at=now_utc,
3765
+ sync_failures=tuple(sync_failures),
3463
3766
  percent_milestones=milestones,
3464
3767
  weekly_history=history,
3465
3768
  weekly_periods=weekly_periods,
@@ -3536,11 +3839,12 @@ def _tui_build_snapshot(
3536
3839
  except Exception as exc:
3537
3840
  # Public source warnings are stable/sanitized; the detailed
3538
3841
  # exception remains only on the internal rebuild-error string.
3539
- errors.append(f"source-bundle: {exc}")
3842
+ capture_failure("source-bundle", "stats_or_cache", exc)
3540
3843
  source_bundle = prior_source_bundle
3541
3844
  snap = dataclasses.replace(
3542
3845
  snap,
3543
3846
  last_sync_error=("; ".join(errors) if errors else None),
3847
+ sync_failures=tuple(sync_failures),
3544
3848
  source_bundle=source_bundle,
3545
3849
  )
3546
3850
  # #268 M5.1: record the (signature+render key, snapshot) so the next
@@ -3562,6 +3866,9 @@ def _tui_build_snapshot(
3562
3866
  generated_at=now_utc.isoformat(),
3563
3867
  )
3564
3868
  return snap
3869
+ except _StatsSnapshotCorruption:
3870
+ _p_snapshot.__exit__(*sys.exc_info())
3871
+ raise
3565
3872
  finally:
3566
3873
  conn.close()
3567
3874
 
@@ -3773,17 +4080,30 @@ def _tui_build_idle_snapshot(prior, *, now_utc, precompute_envelope,
3773
4080
  try:
3774
4081
  prior_claude = source_bundle.sources["claude"]
3775
4082
  prior_codex = source_bundle.sources["codex"]
3776
- if _tui_source_bundle_can_idle(source_bundle):
4083
+ # #350 spec §3.3: once the Codex cycle decision deadline has passed
4084
+ # the idle clock is no longer entitled to speak for the cycle — its
4085
+ # public-history view cannot re-resolve it — so fall through to the
4086
+ # bounded source-adapter path, which rebuilds Codex authoritatively.
4087
+ if (
4088
+ _tui_source_bundle_can_idle(source_bundle)
4089
+ and not codex_decision_deadline_passed(prior_codex, now_utc)
4090
+ ):
4091
+ claude = _refresh_claude_source_clock(
4092
+ prior_claude,
4093
+ current_week=prior.current_week,
4094
+ now_utc=now_utc,
4095
+ raw_config=raw_config,
4096
+ )
3777
4097
  codex = refresh_codex_source_clock(prior_codex, now_utc=now_utc)
3778
- if codex is not prior_codex:
4098
+ if claude is not prior_claude or codex is not prior_codex:
3779
4099
  source_bundle = SourceDashboardBundle(
3780
4100
  source_schema_version=source_bundle.source_schema_version,
3781
4101
  default_source=source_bundle.default_source,
3782
4102
  source_order=source_bundle.source_order,
3783
4103
  sources={
3784
- "claude": prior_claude,
4104
+ "claude": claude,
3785
4105
  "codex": codex,
3786
- "all": compose_all_state(prior_claude, codex),
4106
+ "all": compose_all_state(claude, codex),
3787
4107
  },
3788
4108
  )
3789
4109
  elif source_stats_conn is not None:
@@ -3835,6 +4155,7 @@ def _tui_build_idle_snapshot(prior, *, now_utc, precompute_envelope,
3835
4155
  generated_at=now_utc,
3836
4156
  last_sync_at=time.monotonic(),
3837
4157
  last_sync_error=("; ".join(errors) if errors else None),
4158
+ sync_failures=(),
3838
4159
  doctor_payload=doctor_payload,
3839
4160
  envelope_precompute=envelope_precompute,
3840
4161
  source_bundle=source_bundle,
@@ -3856,6 +4177,44 @@ def _tui_empty_snapshot(now_utc: dt.datetime) -> DataSnapshot:
3856
4177
  )
3857
4178
 
3858
4179
 
4180
+ def _tui_stats_retry_degraded_snapshot(
4181
+ *,
4182
+ now_utc: dt.datetime,
4183
+ exc: Exception,
4184
+ precompute_envelope: bool,
4185
+ runtime_bind: "str | None",
4186
+ ) -> DataSnapshot:
4187
+ """Return a stable typed frame when the one fresh retry cannot open."""
4188
+
4189
+ errors = [f"stats-open: {exc}"]
4190
+ doctor_payload = None
4191
+ envelope_precompute = None
4192
+ if precompute_envelope:
4193
+ try:
4194
+ envelope_precompute = _tui_precompute_envelope_config(load_config())
4195
+ except Exception as precompute_exc: # noqa: BLE001
4196
+ errors.append(f"envelope-precompute: {precompute_exc}")
4197
+ try:
4198
+ doctor_payload = _tui_precompute_doctor_payload(now_utc, runtime_bind)
4199
+ except Exception as doctor_exc: # noqa: BLE001
4200
+ errors.append(f"doctor-precompute: {doctor_exc}")
4201
+ return dataclasses.replace(
4202
+ _tui_empty_snapshot(now_utc),
4203
+ last_sync_at=time.monotonic(),
4204
+ last_sync_error="; ".join(errors),
4205
+ sync_failures=(
4206
+ SyncFailureAttribution(
4207
+ leg="stats-open",
4208
+ database="stats",
4209
+ corruption=True,
4210
+ ),
4211
+ ),
4212
+ doctor_payload=doctor_payload,
4213
+ envelope_precompute=envelope_precompute,
4214
+ hydrating=False,
4215
+ )
4216
+
4217
+
3859
4218
  class TuiKeyReader:
3860
4219
  """Context manager for raw-mode stdin reads.
3861
4220
 
@@ -6187,6 +6546,10 @@ def _make_run_sync_now_locked(*, ref, hub, pinned_now, display_tz_pref_override,
6187
6546
  ),
6188
6547
  lambda active_conn: sync_codex_cache(active_conn),
6189
6548
  ),
6549
+ origins=(
6550
+ "dashboard.refresh.claude_sync",
6551
+ "dashboard.refresh.codex_sync",
6552
+ ),
6190
6553
  )
6191
6554
  except Exception as exc: # noqa: BLE001 — surfaced on the snap
6192
6555
  sync_error = f"sync-cache: {exc}"
@@ -6220,6 +6583,10 @@ def _make_run_sync_now_locked(*, ref, hub, pinned_now, display_tz_pref_override,
6220
6583
  crashed = dataclasses.replace(
6221
6584
  prev,
6222
6585
  last_sync_error=f"sync crashed: {exc}",
6586
+ # The new crash supersedes the prior typed leg failures. Keeping
6587
+ # them would let a stale stats attribution win this unrelated
6588
+ # failure in the privacy-safe envelope classifier.
6589
+ sync_failures=(),
6223
6590
  generated_at=dt.datetime.now(dt.timezone.utc),
6224
6591
  # #278 §1.4.1: a crash-carry snapshot is stable (not mid-
6225
6592
  # assembly); clear the latch even if ``prev`` was a hydrating