cctally 1.82.1 → 1.83.1

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 (51) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +12 -5
  3. package/bin/_cctally_alerts.py +8 -1
  4. package/bin/_cctally_cache.py +912 -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 +116 -8
  11. package/bin/_cctally_dashboard_share.py +50 -19
  12. package/bin/_cctally_dashboard_sources.py +223 -48
  13. package/bin/_cctally_db.py +605 -128
  14. package/bin/_cctally_doctor.py +417 -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 +350 -44
  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 +81 -33
  38. package/bin/_lib_dashboard_sources.py +75 -0
  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_rederive.py +395 -0
  45. package/bin/_lib_share.py +58 -2
  46. package/bin/cctally +56 -8
  47. package/dashboard/static/assets/{index-BKM43pxK.js → index-3bgCMVHb.js} +52 -52
  48. package/dashboard/static/assets/index-D27EIHEI.css +1 -0
  49. package/dashboard/static/dashboard.html +2 -2
  50. package/package.json +5 -1
  51. package/dashboard/static/assets/index-Dk1nplOz.css +0 -1
@@ -1030,6 +1030,100 @@ def _tui_build_five_hour_milestones(
1030
1030
  return out
1031
1031
 
1032
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
+
1033
1127
  @dataclass
1034
1128
  class DataSnapshot:
1035
1129
  """All data needed to render one TUI frame. Produced by sync thread,
@@ -1041,6 +1135,7 @@ class DataSnapshot:
1041
1135
  last_sync_at: float | None # monotonic (time.monotonic())
1042
1136
  last_sync_error: str | None
1043
1137
  generated_at: dt.datetime # wall-clock UTC for displayed timestamps
1138
+ sync_failures: tuple[SyncFailureAttribution, ...] = ()
1044
1139
  # ---- v2 additions (spec §4.5) ----
1045
1140
  percent_milestones: list[TuiPercentMilestone] = field(default_factory=list)
1046
1141
  weekly_history: list[TuiTrendRow] = field(default_factory=list)
@@ -1922,7 +2017,8 @@ def _fetch_affected_session_entries(
1922
2017
  sql = (
1923
2018
  "SELECT se.timestamp_utc, se.model, se.input_tokens, se.output_tokens, "
1924
2019
  " se.cache_create_tokens, se.cache_read_tokens, se.source_path, "
1925
- " 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 "
1926
2022
  "FROM session_entries se "
1927
2023
  "LEFT JOIN session_files sf ON sf.path = se.source_path "
1928
2024
  "WHERE se.timestamp_utc >= ? AND se.timestamp_utc <= ? "
@@ -1958,6 +2054,7 @@ def _fetch_affected_session_entries(
1958
2054
  project_path=row[8],
1959
2055
  cost_usd=row[9],
1960
2056
  usage_extra=({"speed": row[10]} if row[10] is not None else None),
2057
+ cache_1h_tokens=row[11], # #195
1961
2058
  )
1962
2059
  for row in rows
1963
2060
  ]
@@ -2038,7 +2135,7 @@ def _tui_build_session_detail_indexed(
2038
2135
  f" se.input_tokens, se.output_tokens, "
2039
2136
  f" se.cache_create_tokens, se.cache_read_tokens, "
2040
2137
  f" se.source_path, sf.session_id, sf.project_path, "
2041
- f" se.cost_usd_raw "
2138
+ f" se.cost_usd_raw, se.speed, se.cache_create_1h_tokens "
2042
2139
  f"FROM session_entries se "
2043
2140
  f"LEFT JOIN session_files sf ON sf.path = se.source_path "
2044
2141
  f"WHERE se.timestamp_utc >= ? AND se.timestamp_utc <= ? "
@@ -2058,6 +2155,8 @@ def _tui_build_session_detail_indexed(
2058
2155
  session_id=row[7],
2059
2156
  project_path=row[8],
2060
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
2061
2160
  )
2062
2161
  for row in cur
2063
2162
  ]
@@ -2384,6 +2483,73 @@ def _tui_project_claude_source_data(legacy_envelope: object) -> dict[str, object
2384
2483
  }
2385
2484
 
2386
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
+
2387
2553
  def _tui_build_source_bundle(
2388
2554
  *,
2389
2555
  stats_conn,
@@ -2409,18 +2575,18 @@ def _tui_build_source_bundle(
2409
2575
  c = _cctally()
2410
2576
  cache_conn = c.open_cache_db()
2411
2577
  cache_read_tx = False
2412
- stats_read_tx = False
2413
2578
  try:
2414
- # Read both databases through stable snapshots. cache.db and stats.db
2415
- # cannot share one SQLite transaction, so a post-build signature check
2416
- # below rejects a generation that moves while the two snapshots are
2417
- # 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.
2418
2587
  if not cache_conn.in_transaction:
2419
2588
  cache_conn.execute("BEGIN")
2420
2589
  cache_read_tx = True
2421
- if not stats_conn.in_transaction:
2422
- stats_conn.execute("BEGIN")
2423
- stats_read_tx = True
2424
2590
  if common_range_start is None:
2425
2591
  common_range_start = now_utc - dt.timedelta(days=30)
2426
2592
  if common_range_start.tzinfo is None or common_range_start.utcoffset() is None:
@@ -2547,6 +2713,7 @@ def _tui_build_source_bundle(
2547
2713
  ),
2548
2714
  **({"accounts": claude_accounts} if claude_accounts else {}),
2549
2715
  },
2716
+ domain_freshness=_tui_claude_domain_freshness(claude_data),
2550
2717
  )
2551
2718
  if codex_ingest_failed:
2552
2719
  warning = SourceDashboardWarning(
@@ -2643,9 +2810,6 @@ def _tui_build_source_bundle(
2643
2810
  if cache_read_tx:
2644
2811
  cache_conn.rollback()
2645
2812
  cache_read_tx = False
2646
- if stats_read_tx:
2647
- stats_conn.rollback()
2648
- stats_read_tx = False
2649
2813
  post_stats_digest = codex_stats_digest(stats_conn)
2650
2814
  post_signature = c.compute_signature(
2651
2815
  cache_conn,
@@ -2662,8 +2826,6 @@ def _tui_build_source_bundle(
2662
2826
  finally:
2663
2827
  if cache_read_tx:
2664
2828
  cache_conn.rollback()
2665
- if stats_read_tx:
2666
- stats_conn.rollback()
2667
2829
  cache_conn.close()
2668
2830
 
2669
2831
 
@@ -2685,6 +2847,7 @@ def _tui_hydrating_source_bundle() -> SourceDashboardBundle:
2685
2847
  last_success_at=None,
2686
2848
  capabilities={},
2687
2849
  data=None,
2850
+ domain_freshness={"hero": "stale", "quota": "stale", "sessions": "stale"},
2688
2851
  )
2689
2852
  codex = SourceDashboardState(
2690
2853
  source="codex",
@@ -2695,6 +2858,7 @@ def _tui_hydrating_source_bundle() -> SourceDashboardBundle:
2695
2858
  last_success_at=None,
2696
2859
  capabilities={},
2697
2860
  data=None,
2861
+ domain_freshness={"hero": "stale", "quota": "stale", "sessions": "stale"},
2698
2862
  )
2699
2863
  return SourceDashboardBundle(
2700
2864
  source_schema_version=1,
@@ -2719,6 +2883,8 @@ def _tui_source_bundle_can_idle(bundle: SourceDashboardBundle | None) -> bool:
2719
2883
  state = bundle.sources.get(source)
2720
2884
  if not isinstance(state, SourceDashboardState):
2721
2885
  return False
2886
+ # Idle eligibility is provider-generation coherence, deliberately not a
2887
+ # hero/quota/sessions age aggregate.
2722
2888
  if (state.availability not in ("ok", "empty")
2723
2889
  or state.freshness != "fresh"
2724
2890
  or state.data is None):
@@ -2753,6 +2919,55 @@ def _tui_build_snapshot(
2753
2919
  display_tz_pref_override: "str | None" = None,
2754
2920
  precompute_envelope: bool = False,
2755
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,
2756
2971
  ) -> DataSnapshot:
2757
2972
  """Single-shot build of a DataSnapshot from the DB + cache.
2758
2973
 
@@ -2803,6 +3018,23 @@ def _tui_build_snapshot(
2803
3018
  conn = open_db()
2804
3019
  try:
2805
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
+
2806
3038
  cw: TuiCurrentWeek | None = None
2807
3039
  fc: Any | None = None
2808
3040
  trend: list[TuiTrendRow] = []
@@ -2851,6 +3083,7 @@ def _tui_build_snapshot(
2851
3083
  return None, exc
2852
3084
 
2853
3085
  operations = [_claude_leg]
3086
+ operation_origins = ["view_model.claude.sync"]
2854
3087
  if precompute_envelope:
2855
3088
  def _codex_leg(active_conn):
2856
3089
  try:
@@ -2865,10 +3098,13 @@ def _tui_build_snapshot(
2865
3098
  return None, exc
2866
3099
 
2867
3100
  operations.append(_codex_leg)
3101
+ operation_origins.append("view_model.codex.sync")
2868
3102
 
2869
3103
  results, cache_conn = (
2870
3104
  cache_mod._run_cache_plan_with_recovery(
2871
- cache_conn, tuple(operations)
3105
+ cache_conn,
3106
+ tuple(operations),
3107
+ origins=tuple(operation_origins),
2872
3108
  )
2873
3109
  )
2874
3110
  claude_ingest, claude_error = results[0]
@@ -2878,7 +3114,9 @@ def _tui_build_snapshot(
2878
3114
  )
2879
3115
  else:
2880
3116
  claude_ingest_failed = True
2881
- errors.append(f"sync-cache: {claude_error}")
3117
+ capture_failure(
3118
+ "sync-cache", "cache", claude_error
3119
+ )
2882
3120
  if precompute_envelope:
2883
3121
  codex_ingest, codex_error = results[1]
2884
3122
  if codex_error is None:
@@ -2887,14 +3125,16 @@ def _tui_build_snapshot(
2887
3125
  )
2888
3126
  else:
2889
3127
  codex_ingest_failed = True
2890
- errors.append(f"sync-codex-cache: {codex_error}")
3128
+ capture_failure(
3129
+ "sync-codex-cache", "cache", codex_error
3130
+ )
2891
3131
  finally:
2892
3132
  cache_conn.close()
2893
3133
  except Exception as exc:
2894
3134
  claude_ingest_failed = True
2895
3135
  if precompute_envelope:
2896
3136
  codex_ingest_failed = True
2897
- errors.append(f"sync-cache-open: {exc}")
3137
+ capture_failure("sync-cache-open", "cache", exc)
2898
3138
  # Force pure reads for every view builder below, independent of the
2899
3139
  # caller's flag: the single ingest above is the only glob per tick.
2900
3140
  skip_sync = True
@@ -2963,13 +3203,13 @@ def _tui_build_snapshot(
2963
3203
  if prior_snap is not None:
2964
3204
  prior_source_bundle = getattr(prior_snap, "source_bundle", None)
2965
3205
  except Exception as exc:
2966
- errors.append(f"prior-source-bundle: {exc}")
3206
+ capture_failure("prior-source-bundle", "other", exc)
2967
3207
  dispatch_sig = None
2968
3208
  with _perf.phase("signature"):
2969
3209
  try:
2970
3210
  dispatch_sig = _tui_compute_dispatch_signature(conn)
2971
3211
  except Exception as exc:
2972
- errors.append(f"dispatch-signature: {exc}")
3212
+ capture_failure("dispatch-signature", "stats_or_cache", exc)
2973
3213
  dispatch_sig = None
2974
3214
  if dispatch_sig is not None:
2975
3215
  # The idle decision keys on the DB signature AND a render key
@@ -2993,6 +3233,12 @@ def _tui_build_snapshot(
2993
3233
  data_version = _snapshot_data_version(dispatch_sig)
2994
3234
  if (prior_snap is not None and prior_key is not None
2995
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
+ )
2996
3242
  and not _snapshot_period_rolled_over(
2997
3243
  prior_snap, now_utc, _build_display_tz)):
2998
3244
  with _perf.phase("idle-decision"):
@@ -3109,12 +3355,12 @@ def _tui_build_snapshot(
3109
3355
  finally:
3110
3356
  _rc_cache_conn.close()
3111
3357
  except Exception as exc:
3112
- errors.append(f"snapshot-cache-reconcile: {exc}")
3358
+ capture_failure("snapshot-cache-reconcile", "cache", exc)
3113
3359
  with _perf.phase("build.current_week"):
3114
3360
  try:
3115
3361
  cw = _tui_build_current_week(conn, now_utc, skip_sync=skip_sync)
3116
3362
  except Exception as exc:
3117
- errors.append(f"current-week: {exc}")
3363
+ capture_failure("current-week", "stats_or_cache", exc)
3118
3364
  fc_view = None
3119
3365
  with _perf.phase("build.forecast"):
3120
3366
  try:
@@ -3128,7 +3374,7 @@ def _tui_build_snapshot(
3128
3374
  )
3129
3375
  fc = fc_view.output if fc_view is not None else None
3130
3376
  except Exception as exc:
3131
- errors.append(f"forecast: {exc}")
3377
+ capture_failure("forecast", "stats_or_cache", exc)
3132
3378
  # Trend: source from build_trend_view so we capture the 3-sample
3133
3379
  # avg_dollars_per_pct alongside the rows. The TUI build path
3134
3380
  # historically called _tui_build_trend (which now wraps the
@@ -3146,7 +3392,7 @@ def _tui_build_snapshot(
3146
3392
  trend = list(_trend_view.rows)
3147
3393
  trend_avg_dpp = _trend_view.avg_dollars_per_pct
3148
3394
  except Exception as exc:
3149
- errors.append(f"trend: {exc}")
3395
+ capture_failure("trend", "stats_or_cache", exc)
3150
3396
  with _perf.phase("build.sessions"):
3151
3397
  try:
3152
3398
  # The sessions aggregator goes through
@@ -3164,14 +3410,14 @@ def _tui_build_snapshot(
3164
3410
  with_titles=precompute_envelope,
3165
3411
  )
3166
3412
  except Exception as exc:
3167
- errors.append(f"sessions: {exc}")
3413
+ capture_failure("sessions", "cache", exc)
3168
3414
  # ---- v2 additions ----
3169
3415
  with _perf.phase("build.milestones"):
3170
3416
  try:
3171
3417
  if cw is not None:
3172
3418
  milestones = _tui_build_percent_milestones(conn)
3173
3419
  except Exception as exc:
3174
- errors.append(f"milestones: {exc}")
3420
+ capture_failure("milestones", "stats", exc)
3175
3421
  history: list = []
3176
3422
  history_median_dpp: "float | None" = None
3177
3423
  with _perf.phase("build.weekly_history"):
@@ -3188,7 +3434,7 @@ def _tui_build_snapshot(
3188
3434
  history = list(history_view.rows)
3189
3435
  history_median_dpp = history_view.median_dpp_non_current_4w
3190
3436
  except Exception as exc:
3191
- errors.append(f"weekly-history: {exc}")
3437
+ capture_failure("weekly-history", "stats_or_cache", exc)
3192
3438
  # ---- v2.1 additions: dashboard Weekly / Monthly panels ----
3193
3439
  # Sync-thread view-model totals (spec §6.6): sum directly over
3194
3440
  # the panel rows the dashboard ACTUALLY renders. The previous
@@ -3221,7 +3467,7 @@ def _tui_build_snapshot(
3221
3467
  (r.total_tokens for r in weekly_periods), 0,
3222
3468
  )
3223
3469
  except Exception as exc:
3224
- errors.append(f"weekly-periods: {exc}")
3470
+ capture_failure("weekly-periods", "stats_or_cache", exc)
3225
3471
  # Sync-thread view-model totals (spec §6.6): sum-over-visible-rows
3226
3472
  # (same invariant as weekly above). Monthly has no Bug-K analogue,
3227
3473
  # but coupling the footer total to the panel-row source of truth
@@ -3243,7 +3489,7 @@ def _tui_build_snapshot(
3243
3489
  (r.total_tokens for r in monthly_periods), 0,
3244
3490
  )
3245
3491
  except Exception as exc:
3246
- errors.append(f"monthly-periods: {exc}")
3492
+ capture_failure("monthly-periods", "cache", exc)
3247
3493
  # ---- v2.2 additions: dashboard Blocks / Daily panels ----
3248
3494
  # Issue #56: build the BlocksView once and read both rows
3249
3495
  # (presentation) and totals (envelope scalars) from the same
@@ -3266,7 +3512,7 @@ def _tui_build_snapshot(
3266
3512
  blocks_total_cost_usd = _blocks_view.total_cost_usd
3267
3513
  blocks_total_tokens = _blocks_view.total_tokens
3268
3514
  except Exception as exc:
3269
- errors.append(f"blocks-panel: {exc}")
3515
+ capture_failure("blocks-panel", "stats_or_cache", exc)
3270
3516
  # Sync-thread view-model totals (Bundle 1 / spec §6.6):
3271
3517
  # sum-over-visible-rows (same invariant as weekly/monthly above).
3272
3518
  # Gap days in the materialized panel carry ``cost_usd=0.0`` /
@@ -3289,7 +3535,7 @@ def _tui_build_snapshot(
3289
3535
  (r.total_tokens for r in daily_panel), 0,
3290
3536
  )
3291
3537
  except Exception as exc:
3292
- errors.append(f"daily-panel: {exc}")
3538
+ capture_failure("daily-panel", "cache", exc)
3293
3539
  # ---- threshold-actions T5: alerts envelope array ----
3294
3540
  # Precomputed at sync time so `snapshot_to_envelope` stays a pure
3295
3541
  # renderer (no DB I/O on the dashboard hot path; mirrors how
@@ -3299,7 +3545,7 @@ def _tui_build_snapshot(
3299
3545
  try:
3300
3546
  alerts = _build_alerts_envelope_array(conn)
3301
3547
  except Exception as exc:
3302
- errors.append(f"alerts: {exc}")
3548
+ capture_failure("alerts", "stats", exc)
3303
3549
  # ---- 5h in-place credit (v1.7.x) ----
3304
3550
  # Load 5h milestones (pre + post credit) for the current
3305
3551
  # block's window so CurrentWeekModal can render a merged
@@ -3313,7 +3559,7 @@ def _tui_build_snapshot(
3313
3559
  win_key = cw.five_hour_block.get("five_hour_window_key")
3314
3560
  fh_milestones = _tui_build_five_hour_milestones(conn, win_key)
3315
3561
  except Exception as exc:
3316
- errors.append(f"five-hour-milestones: {exc}")
3562
+ capture_failure("five-hour-milestones", "stats", exc)
3317
3563
  # ---- hero-modal historical milestones week index (spec §1a/§3) ----
3318
3564
  # Built ONLY here on the non-idle rebuild (the idle short-circuit
3319
3565
  # returns before this phase and carries the prior index forward via
@@ -3325,7 +3571,7 @@ def _tui_build_snapshot(
3325
3571
  try:
3326
3572
  week_index = sys.modules["cctally"].build_claude_week_index(conn)
3327
3573
  except Exception as exc:
3328
- errors.append(f"week-index: {exc}")
3574
+ capture_failure("week-index", "stats", exc)
3329
3575
  # ---- Projects panel + modal envelope (spec §5.2, plan Task 1) -----
3330
3576
  # Per-tick aggregation lives on the sync thread; the dashboard's
3331
3577
  # pure ``snapshot_to_envelope`` reads ``snap.projects_envelope``
@@ -3375,7 +3621,7 @@ def _tui_build_snapshot(
3375
3621
  use_projects_env_cache=use_projects_env_cache,
3376
3622
  )
3377
3623
  except Exception as exc:
3378
- errors.append(f"projects-envelope: {exc}")
3624
+ capture_failure("projects-envelope", "stats_or_cache", exc)
3379
3625
  finally:
3380
3626
  try:
3381
3627
  conn.execute("DROP VIEW IF EXISTS session_entries")
@@ -3435,7 +3681,9 @@ def _tui_build_snapshot(
3435
3681
  )
3436
3682
  sessions = annotated
3437
3683
  except Exception as exc:
3438
- errors.append(f"projects-cross-nav-bind: {exc}")
3684
+ capture_failure(
3685
+ "projects-cross-nav-bind", "stats_or_cache", exc
3686
+ )
3439
3687
 
3440
3688
  # Cache-report panel + modal envelope block (spec
3441
3689
  # 2026-05-21-cache-report-panel-design.md §5.2). Per-tick build
@@ -3470,7 +3718,7 @@ def _tui_build_snapshot(
3470
3718
  use_cache_report_cache=use_cache_report_cache,
3471
3719
  )
3472
3720
  except Exception as exc:
3473
- errors.append(f"cache-report: {exc}")
3721
+ capture_failure("cache-report", "cache", exc)
3474
3722
 
3475
3723
  # ---- #268 M4: doctor / config / update-state precompute (spec §6) ----
3476
3724
  # Precompute the envelope's doctor / config / update-state reads ONCE
@@ -3488,14 +3736,14 @@ def _tui_build_snapshot(
3488
3736
  now_utc, runtime_bind,
3489
3737
  )
3490
3738
  except Exception as exc:
3491
- errors.append(f"doctor-precompute: {exc}")
3739
+ capture_failure("doctor-precompute", "other", exc)
3492
3740
  with _perf.phase("envelope.precompute"):
3493
3741
  try:
3494
3742
  envelope_precompute_block = _tui_precompute_envelope_config(
3495
3743
  raw_config,
3496
3744
  )
3497
3745
  except Exception as exc:
3498
- errors.append(f"envelope-precompute: {exc}")
3746
+ capture_failure("envelope-precompute", "other", exc)
3499
3747
 
3500
3748
  # Determine the shared visible interval before publishing either source.
3501
3749
  # The actual source bundle is built after ``snap`` exists, so Claude's
@@ -3514,6 +3762,7 @@ def _tui_build_snapshot(
3514
3762
  last_sync_at=time.monotonic(),
3515
3763
  last_sync_error=("; ".join(errors) if errors else None),
3516
3764
  generated_at=now_utc,
3765
+ sync_failures=tuple(sync_failures),
3517
3766
  percent_milestones=milestones,
3518
3767
  weekly_history=history,
3519
3768
  weekly_periods=weekly_periods,
@@ -3590,11 +3839,12 @@ def _tui_build_snapshot(
3590
3839
  except Exception as exc:
3591
3840
  # Public source warnings are stable/sanitized; the detailed
3592
3841
  # exception remains only on the internal rebuild-error string.
3593
- errors.append(f"source-bundle: {exc}")
3842
+ capture_failure("source-bundle", "stats_or_cache", exc)
3594
3843
  source_bundle = prior_source_bundle
3595
3844
  snap = dataclasses.replace(
3596
3845
  snap,
3597
3846
  last_sync_error=("; ".join(errors) if errors else None),
3847
+ sync_failures=tuple(sync_failures),
3598
3848
  source_bundle=source_bundle,
3599
3849
  )
3600
3850
  # #268 M5.1: record the (signature+render key, snapshot) so the next
@@ -3616,6 +3866,9 @@ def _tui_build_snapshot(
3616
3866
  generated_at=now_utc.isoformat(),
3617
3867
  )
3618
3868
  return snap
3869
+ except _StatsSnapshotCorruption:
3870
+ _p_snapshot.__exit__(*sys.exc_info())
3871
+ raise
3619
3872
  finally:
3620
3873
  conn.close()
3621
3874
 
@@ -3835,16 +4088,22 @@ def _tui_build_idle_snapshot(prior, *, now_utc, precompute_envelope,
3835
4088
  _tui_source_bundle_can_idle(source_bundle)
3836
4089
  and not codex_decision_deadline_passed(prior_codex, now_utc)
3837
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
+ )
3838
4097
  codex = refresh_codex_source_clock(prior_codex, now_utc=now_utc)
3839
- if codex is not prior_codex:
4098
+ if claude is not prior_claude or codex is not prior_codex:
3840
4099
  source_bundle = SourceDashboardBundle(
3841
4100
  source_schema_version=source_bundle.source_schema_version,
3842
4101
  default_source=source_bundle.default_source,
3843
4102
  source_order=source_bundle.source_order,
3844
4103
  sources={
3845
- "claude": prior_claude,
4104
+ "claude": claude,
3846
4105
  "codex": codex,
3847
- "all": compose_all_state(prior_claude, codex),
4106
+ "all": compose_all_state(claude, codex),
3848
4107
  },
3849
4108
  )
3850
4109
  elif source_stats_conn is not None:
@@ -3896,6 +4155,7 @@ def _tui_build_idle_snapshot(prior, *, now_utc, precompute_envelope,
3896
4155
  generated_at=now_utc,
3897
4156
  last_sync_at=time.monotonic(),
3898
4157
  last_sync_error=("; ".join(errors) if errors else None),
4158
+ sync_failures=(),
3899
4159
  doctor_payload=doctor_payload,
3900
4160
  envelope_precompute=envelope_precompute,
3901
4161
  source_bundle=source_bundle,
@@ -3917,6 +4177,44 @@ def _tui_empty_snapshot(now_utc: dt.datetime) -> DataSnapshot:
3917
4177
  )
3918
4178
 
3919
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
+
3920
4218
  class TuiKeyReader:
3921
4219
  """Context manager for raw-mode stdin reads.
3922
4220
 
@@ -6248,6 +6546,10 @@ def _make_run_sync_now_locked(*, ref, hub, pinned_now, display_tz_pref_override,
6248
6546
  ),
6249
6547
  lambda active_conn: sync_codex_cache(active_conn),
6250
6548
  ),
6549
+ origins=(
6550
+ "dashboard.refresh.claude_sync",
6551
+ "dashboard.refresh.codex_sync",
6552
+ ),
6251
6553
  )
6252
6554
  except Exception as exc: # noqa: BLE001 — surfaced on the snap
6253
6555
  sync_error = f"sync-cache: {exc}"
@@ -6281,6 +6583,10 @@ def _make_run_sync_now_locked(*, ref, hub, pinned_now, display_tz_pref_override,
6281
6583
  crashed = dataclasses.replace(
6282
6584
  prev,
6283
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=(),
6284
6590
  generated_at=dt.datetime.now(dt.timezone.utc),
6285
6591
  # #278 §1.4.1: a crash-carry snapshot is stable (not mid-
6286
6592
  # assembly); clear the latch even if ``prev`` was a hydrating