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.
- package/CHANGELOG.md +70 -0
- package/README.md +52 -74
- package/bin/_cctally_alerts.py +8 -1
- package/bin/_cctally_cache.py +963 -149
- package/bin/_cctally_config.py +43 -4
- package/bin/_cctally_core.py +933 -759
- package/bin/_cctally_dashboard.py +157 -47
- package/bin/_cctally_dashboard_cache_report.py +13 -6
- package/bin/_cctally_dashboard_conversation.py +1 -0
- package/bin/_cctally_dashboard_envelope.py +186 -8
- package/bin/_cctally_dashboard_share.py +60 -20
- package/bin/_cctally_dashboard_sources.py +427 -128
- package/bin/_cctally_db.py +605 -128
- package/bin/_cctally_doctor.py +413 -28
- package/bin/_cctally_five_hour.py +12 -5
- package/bin/_cctally_journal.py +2050 -156
- package/bin/_cctally_journal_repair.py +519 -0
- package/bin/_cctally_milestone_history.py +142 -56
- package/bin/_cctally_milestones.py +179 -111
- package/bin/_cctally_parser.py +42 -0
- package/bin/_cctally_project.py +24 -18
- package/bin/_cctally_quota.py +139 -25
- package/bin/_cctally_record.py +279 -108
- package/bin/_cctally_rederive.py +1052 -0
- package/bin/_cctally_reporting.py +58 -53
- package/bin/_cctally_setup.py +1 -0
- package/bin/_cctally_source_analytics.py +4 -1
- package/bin/_cctally_statusline.py +11 -11
- package/bin/_cctally_store.py +1039 -31
- package/bin/_cctally_sync_week.py +17 -8
- package/bin/_cctally_tui.py +421 -54
- package/bin/_cctally_update.py +133 -8
- package/bin/_cctally_weekrefs.py +14 -0
- package/bin/_lib_aggregators.py +10 -6
- package/bin/_lib_cache_report.py +101 -9
- package/bin/_lib_codex_pools.py +82 -0
- package/bin/_lib_conversation_query.py +126 -33
- package/bin/_lib_dashboard_sources.py +126 -1
- package/bin/_lib_diff_kernel.py +28 -15
- package/bin/_lib_doctor.py +342 -4
- package/bin/_lib_journal.py +924 -2
- package/bin/_lib_jsonl.py +43 -14
- package/bin/_lib_pricing.py +140 -21
- package/bin/_lib_readme_refresh.py +401 -0
- package/bin/_lib_rederive.py +395 -0
- package/bin/_lib_share.py +58 -2
- package/bin/cctally +56 -8
- package/dashboard/static/assets/{index-DJP4gEB7.js → index-3bgCMVHb.js} +52 -52
- package/dashboard/static/assets/index-D27EIHEI.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +6 -1
- package/dashboard/static/assets/index-Dk1nplOz.css +0 -1
package/bin/_cctally_tui.py
CHANGED
|
@@ -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
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
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
|
-
#
|
|
2385
|
-
#
|
|
2386
|
-
#
|
|
2387
|
-
#
|
|
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
|
|
2547
|
-
|
|
2548
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
4104
|
+
"claude": claude,
|
|
3785
4105
|
"codex": codex,
|
|
3786
|
-
"all": compose_all_state(
|
|
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
|