cctally 1.113.0 → 1.115.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 (97) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +4 -6
  3. package/bin/_cctally_account.py +32 -8
  4. package/bin/_cctally_cache.py +6035 -722
  5. package/bin/_cctally_completion.py +1287 -0
  6. package/bin/_cctally_config.py +37 -0
  7. package/bin/_cctally_core.py +400 -2
  8. package/bin/_cctally_dashboard.py +98 -20
  9. package/bin/_cctally_dashboard_conversation.py +292 -23
  10. package/bin/_cctally_dashboard_envelope.py +16 -3
  11. package/bin/_cctally_dashboard_perf.py +128 -9
  12. package/bin/_cctally_dashboard_sources.py +285 -213
  13. package/bin/_cctally_db.py +1177 -23
  14. package/bin/_cctally_diagnosis_sources.py +22 -4
  15. package/bin/_cctally_doctor.py +572 -45
  16. package/bin/_cctally_five_hour.py +84 -33
  17. package/bin/_cctally_forecast.py +108 -15
  18. package/bin/_cctally_journal.py +3950 -116
  19. package/bin/_cctally_journal_repair.py +101 -12
  20. package/bin/_cctally_milestone_history.py +82 -9
  21. package/bin/_cctally_parser.py +42 -0
  22. package/bin/_cctally_percent_breakdown.py +3 -2
  23. package/bin/_cctally_project.py +8 -2
  24. package/bin/_cctally_quarantine.py +652 -0
  25. package/bin/_cctally_quota.py +227 -37
  26. package/bin/_cctally_quota_model.py +360 -83
  27. package/bin/_cctally_record.py +691 -149
  28. package/bin/_cctally_rederive.py +320 -41
  29. package/bin/_cctally_refresh.py +84 -13
  30. package/bin/_cctally_source_analytics.py +83 -17
  31. package/bin/_cctally_statusline.py +6154 -517
  32. package/bin/_cctally_store.py +367 -53
  33. package/bin/_cctally_transcript.py +8 -7
  34. package/bin/_cctally_tui.py +61 -56
  35. package/bin/_cctally_weekrefs.py +128 -21
  36. package/bin/_lib_account_binding.py +152 -0
  37. package/bin/_lib_account_protocol.py +386 -0
  38. package/bin/_lib_aggregators.py +4 -0
  39. package/bin/_lib_cache_coverage.py +13 -1
  40. package/bin/_lib_cache_report.py +35 -6
  41. package/bin/_lib_cache_writer_lock.py +19 -3
  42. package/bin/_lib_clone_copy.py +142 -0
  43. package/bin/_lib_codex_conversation.py +32 -0
  44. package/bin/_lib_codex_conversation_query.py +297 -49
  45. package/bin/_lib_codex_conversation_watch.py +59 -14
  46. package/bin/_lib_codex_drain_admission.py +214 -0
  47. package/bin/_lib_codex_metadata.py +18 -1
  48. package/bin/_lib_codex_publication_barrier.py +277 -0
  49. package/bin/_lib_codex_rollout_container.py +502 -0
  50. package/bin/_lib_conversation_dispatch.py +3 -0
  51. package/bin/_lib_conversation_query.py +106 -59
  52. package/bin/_lib_conversation_retention.py +1605 -640
  53. package/bin/_lib_cost_provenance.py +53 -2
  54. package/bin/_lib_detector_access_registry.py +3817 -0
  55. package/bin/_lib_doctor.py +714 -69
  56. package/bin/_lib_five_hour.py +126 -0
  57. package/bin/_lib_fmt.py +11 -0
  58. package/bin/_lib_ingest_frontier.py +414 -20
  59. package/bin/_lib_journal.py +141 -10
  60. package/bin/_lib_jsonl.py +5 -4
  61. package/bin/_lib_legacy_spool.py +126 -0
  62. package/bin/_lib_legacy_writer_evidence.py +136 -0
  63. package/bin/_lib_merge_gate.py +231 -86
  64. package/bin/_lib_meter_rate_change.py +10 -0
  65. package/bin/_lib_outcome_quarantine.py +690 -0
  66. package/bin/_lib_perf.py +27 -1
  67. package/bin/_lib_pricing.py +199 -9
  68. package/bin/_lib_pricing_check.py +20 -4
  69. package/bin/_lib_quarantine.py +154 -0
  70. package/bin/_lib_quota_calibration.py +53 -8
  71. package/bin/_lib_quota_copy.py +2 -1
  72. package/bin/_lib_quota_model.py +288 -46
  73. package/bin/_lib_reclaim_planner.py +792 -0
  74. package/bin/_lib_rederive.py +319 -82
  75. package/bin/_lib_segment_summary.py +35 -2
  76. package/bin/_lib_snapshot_cache.py +21 -0
  77. package/bin/_lib_sqlite_close.py +81 -0
  78. package/bin/_lib_statusline_candidates.py +292 -23
  79. package/bin/_lib_subscription_weeks.py +100 -17
  80. package/bin/_lib_tick_stats.py +217 -17
  81. package/bin/_lib_wal_checkpoint.py +1077 -0
  82. package/bin/_lib_write_budget.py +483 -0
  83. package/bin/_lib_write_io.py +703 -0
  84. package/bin/cctally +31 -1
  85. package/dashboard/static/assets/{ConversationsView-CEZ-BUyi.js → ConversationsView-Bc8LCOAZ.js} +1 -1
  86. package/dashboard/static/assets/{DoctorModal-CE1oH_Zy.js → DoctorModal-D--qVBwe.js} +1 -1
  87. package/dashboard/static/assets/ModalRoot-DLPgyAM_.js +1 -0
  88. package/dashboard/static/assets/{ProjectsDrillPanel-B3DZoCeD.js → ProjectsDrillPanel-z53a4v4M.js} +1 -1
  89. package/dashboard/static/assets/{SourceDetailModal-CHp9EgLP.js → SourceDetailModal-Bw0T8W-W.js} +1 -1
  90. package/dashboard/static/assets/{UpdateModal-5iKST6jo.js → UpdateModal-D5AKx4Yw.js} +1 -1
  91. package/dashboard/static/assets/index-BKITk1CT.css +1 -0
  92. package/dashboard/static/assets/{index-Cm0VTxJC.js → index-BXDOzSng.js} +3 -3
  93. package/dashboard/static/assets/{outlineNavigation-DkXGL8J6.js → outlineNavigation-JObY7cUY.js} +1 -1
  94. package/dashboard/static/dashboard.html +2 -2
  95. package/package.json +19 -1
  96. package/dashboard/static/assets/ModalRoot-D5Y8IFUG.js +0 -1
  97. package/dashboard/static/assets/index-Dt4tqf43.css +0 -1
@@ -609,19 +609,25 @@ def _refresh_usage_inproc(timeout_seconds: float = 5.0) -> _RefreshUsageResult:
609
609
  reason="test stub",
610
610
  )
611
611
 
612
- token = c._resolve_oauth_token()
613
- if not token:
614
- return _RefreshUsageResult(status="no_oauth_token", reason="no token")
615
-
616
612
  # Force-refresh deliberately bypasses the pre-fetch backoff gate, but its
617
613
  # resulting 429/success transition must be ordered with the automatic
618
614
  # refresh writer. Hold the canonical selected-state lock from the fetch
619
615
  # through authoritative publication and the matching backoff update.
620
616
  try:
621
617
  with c._selected_state_lock():
618
+ # #825/#832 spec 4.6: the selected credential and its binding are
619
+ # captured TOGETHER, before the fetch.
620
+ binding, token = _capture_selected_credential(c)
621
+ if binding is None:
622
+ return _RefreshUsageResult(
623
+ status="record_failed",
624
+ reason="the active Claude identity is unreadable or changing")
625
+ if not token:
626
+ return _RefreshUsageResult(status="no_oauth_token", reason="no token")
622
627
  return _refresh_usage_inproc_locked(
623
628
  c,
624
629
  token=token,
630
+ binding=binding,
625
631
  timeout_seconds=timeout_seconds,
626
632
  )
627
633
  except OSError as exc:
@@ -632,12 +638,20 @@ def _refresh_usage_inproc_locked(
632
638
  c,
633
639
  *,
634
640
  token: str,
641
+ binding,
635
642
  timeout_seconds: float,
636
643
  ) -> _RefreshUsageResult:
637
- """Force-refresh with the canonical selected-state lock already held."""
644
+ """Force-refresh with the canonical selected-state lock already held.
645
+
646
+ ``binding`` was captured together with ``token`` before the fetch. The
647
+ response is revalidated against it before ANY mutation (spec 4.6)."""
638
648
  try:
639
649
  api = c._fetch_oauth_usage(token=token, timeout_seconds=timeout_seconds)
640
650
  except RefreshUsageRateLimitError as exc:
651
+ if not _revalidate_selected_credential(c, binding):
652
+ return _RefreshUsageResult(
653
+ status="record_failed",
654
+ reason="the selected identity changed during the fetch")
641
655
  # Force-refresh bypasses the backfill/backoff GATE (user asked for
642
656
  # it), but a resulting 429 MUST advance the shared backoff deadline
643
657
  # so automatic polling still honors it (spec §4).
@@ -698,6 +712,14 @@ def _refresh_usage_inproc_locked(
698
712
  f"ignoring unparseable five_hour fields: {exc}"
699
713
  )
700
714
 
715
+ # #825/#832 spec 4.6: publication proceeds only when the second stable
716
+ # binding equals the captured one AND the selected credential is still
717
+ # the one the response was fetched with. A->B, a torn read or a changed
718
+ # credential discards the response before any mutation.
719
+ if not _revalidate_selected_credential(c, binding):
720
+ return _RefreshUsageResult(
721
+ status="record_failed",
722
+ reason="the selected identity or credential changed during the fetch")
701
723
  record_args = argparse.Namespace(
702
724
  percent=seven_pct,
703
725
  resets_at=str(seven_resets_epoch),
@@ -716,6 +738,7 @@ def _refresh_usage_inproc_locked(
716
738
  *({"fiveHour"} if five_pct is not None else set()),
717
739
  },
718
740
  lock_held=True,
741
+ binding=binding,
719
742
  # #583 S2 §5.3: the dashboard calls this INSIDE its own `sync_lock`
720
743
  # while servicing a `refresh=1`, so an inner nudge would enqueue a
721
744
  # second rebuild for work already underway. `cmd_refresh_usage`
@@ -918,11 +941,46 @@ def cmd_refresh_usage(args: argparse.Namespace) -> int:
918
941
  # Hook-tick OAuth refresh path
919
942
  # =========================================================================
920
943
 
921
- def _hook_tick_oauth_skip_status(c, *, throttle_seconds: float) -> str | None:
922
- """Return the automatic-refresh suppression reason under selected lock."""
944
+ def _capture_selected_credential(c):
945
+ """``(binding, token)`` captured together under the selected-state lock
946
+ (#825/#832 spec 4.6), or ``(None, None)`` when the identity is torn or
947
+ changes across the credential read. The identity is read on both sides of
948
+ the credential read so a credential is never paired with an identity it
949
+ was not selected under; the binding then carries the credential's salted
950
+ digest (never the credential). ``(binding, None)``: stable, no token."""
951
+ import _cctally_statusline as _sl
952
+
953
+ first = _sl._capture_binding()
954
+ if first is None:
955
+ return None, None
956
+ token = c._resolve_oauth_token()
957
+ second = _sl._capture_binding()
958
+ if second is None or second != first:
959
+ return None, None
960
+ if not token:
961
+ return first, None
962
+ bound = _sl._capture_binding(credential=token)
963
+ if bound is None or bound.without_credential() != first:
964
+ return None, None
965
+ return bound, token
966
+
967
+
968
+ def _revalidate_selected_credential(c, binding) -> bool:
969
+ """The second stable read after the fetch: the identity AND the selected
970
+ credential must still be exactly the captured ones. A->B->A is accepted
971
+ only when the credential is unchanged (the response was fetched with A's
972
+ captured credential)."""
973
+ current, token = _capture_selected_credential(c)
974
+ return current is not None and bool(token) and current == binding
975
+
976
+
977
+ def _hook_tick_oauth_skip_status(c, *, throttle_seconds: float, binding) -> str | None:
978
+ """Return the automatic-refresh suppression reason under selected lock.
979
+ Repair need and selected freshness are the captured account's own."""
923
980
  now_epoch = int(time.time())
924
- needs_repair = c._authoritative_repair_required(now_epoch=now_epoch)
925
- obs_age = c._statusline_observe_age_seconds()
981
+ needs_repair = c._authoritative_repair_required(
982
+ now_epoch=now_epoch, binding=binding)
983
+ obs_age = c._statusline_observe_age_seconds(binding=binding)
926
984
  if (not needs_repair
927
985
  and obs_age < float(_cctally_core.OAUTH_BACKFILL_STALE_SECONDS)):
928
986
  return f"skipped(statusline-fresh:{int(obs_age)}s)"
@@ -995,9 +1053,6 @@ def _hook_tick_oauth_refresh(
995
1053
  `five_hour`) on success, or None on any non-ok branch.
996
1054
  """
997
1055
  c = _cctally()
998
- token = c._resolve_oauth_token()
999
- if not token:
1000
- return "skipped-no-token", None
1001
1056
  if throttle_seconds is None:
1002
1057
  try:
1003
1058
  throttle_seconds = float(_get_oauth_usage_config(c.load_config())["throttle_seconds"])
@@ -1010,8 +1065,15 @@ def _hook_tick_oauth_refresh(
1010
1065
  # during network I/O.
1011
1066
  try:
1012
1067
  with c._selected_state_lock():
1068
+ # #825/#832 spec 4.6: capture the selected credential and its
1069
+ # binding together before the fetch.
1070
+ binding, token = _capture_selected_credential(c)
1071
+ if binding is None:
1072
+ return "err(identity-unstable)", None
1073
+ if not token:
1074
+ return "skipped-no-token", None
1013
1075
  skipped = _hook_tick_oauth_skip_status(
1014
- c, throttle_seconds=throttle_seconds,
1076
+ c, throttle_seconds=throttle_seconds, binding=binding,
1015
1077
  )
1016
1078
  except OSError:
1017
1079
  return "err(record-usage=exc)", None
@@ -1023,6 +1085,8 @@ def _hook_tick_oauth_refresh(
1023
1085
  except RefreshUsageRateLimitError as exc:
1024
1086
  try:
1025
1087
  with c._selected_state_lock():
1088
+ if not _revalidate_selected_credential(c, binding):
1089
+ return "err(identity-changed)", None
1026
1090
  c._oauth_backoff_register_429(
1027
1091
  retry_after_deadline=getattr(
1028
1092
  exc, "retry_after_deadline", None,
@@ -1048,9 +1112,15 @@ def _hook_tick_oauth_refresh(
1048
1112
  deferred = []
1049
1113
  try:
1050
1114
  with c._selected_state_lock():
1115
+ # Revalidate under the reacquired lock BEFORE any mutation: the
1116
+ # response is published only for the identity AND credential it
1117
+ # was fetched with (spec 4.6).
1118
+ if not _revalidate_selected_credential(c, binding):
1119
+ return "err(identity-changed)", None
1051
1120
  skipped = _hook_tick_oauth_skip_status(
1052
1121
  c,
1053
1122
  throttle_seconds=throttle_seconds,
1123
+ binding=binding,
1054
1124
  )
1055
1125
  if skipped is not None:
1056
1126
  return skipped, None
@@ -1058,6 +1128,7 @@ def _hook_tick_oauth_refresh(
1058
1128
  record_args,
1059
1129
  axes,
1060
1130
  lock_held=True,
1131
+ binding=binding,
1061
1132
  nudge_sink=lambda: deferred.append(1),
1062
1133
  )
1063
1134
  if authoritative.status != "ok":
@@ -11,7 +11,7 @@ import sqlite3
11
11
  import sys
12
12
  from copy import copy
13
13
  from collections import defaultdict
14
- from typing import Iterable
14
+ from typing import Callable, Iterable
15
15
 
16
16
  from _cctally_core import (
17
17
  _command_as_of,
@@ -19,7 +19,7 @@ from _cctally_core import (
19
19
  get_week_start_name,
20
20
  parse_iso_datetime,
21
21
  )
22
- from _cctally_cache import _codex_provider_roots
22
+ from _cctally_cache import CodexHistoryUnavailable, _codex_provider_roots
23
23
  import _lib_accounts
24
24
  from _lib_codex_metadata import (
25
25
  DecodedCodexProjectMetadata,
@@ -153,18 +153,45 @@ _QUALIFIED_CODEX_ENTRIES_SQL = """
153
153
 
154
154
 
155
155
  def _qualified_codex_path_entries_sql(identity_count: int) -> str:
156
- """Return the qualified read constrained to physical cache identities."""
156
+ """Return the qualified read constrained to physical cache identities.
157
+
158
+ Unordered: ``_load_qualified_codex_rows`` orders its rows (#901
159
+ Amendment 19 T1). Seeking ``idx_codex_entries_root_path`` cannot also
160
+ deliver the window read's order, so the old ``ORDER BY`` sorted every
161
+ entry of the dirty rollouts through a temp b-tree, and one rollout file is
162
+ not a bound.
163
+ """
157
164
  predicates = " OR ".join(
158
165
  "(entries.source_root_key = ? AND entries.source_path = ?)"
159
166
  for _ in range(identity_count)
160
167
  )
161
- return _QUALIFIED_CODEX_ENTRIES_SQL.replace(
168
+ head, order_by, _tail = _QUALIFIED_CODEX_ENTRIES_SQL.partition(
169
+ " ORDER BY entries.timestamp_utc ASC")
170
+ if not order_by:
171
+ raise RuntimeError(
172
+ "_QUALIFIED_CODEX_ENTRIES_SQL no longer carries its ORDER BY")
173
+ return head.replace(
162
174
  "INDEXED BY idx_codex_entries_ts_root_conversation",
163
175
  "INDEXED BY idx_codex_entries_root_path",
164
- ).replace(
165
- " ORDER BY entries.timestamp_utc ASC",
166
- f" AND ({predicates})\n ORDER BY entries.timestamp_utc ASC",
167
- )
176
+ ) + f" AND ({predicates})\n"
177
+
178
+
179
+ def _qualified_codex_row_order(row) -> tuple:
180
+ """The window read's ``ORDER BY entries.timestamp_utc,
181
+ entries.source_root_key, entries.conversation_key, entries.id`` as a Python
182
+ key: NULL first, text by UTF-8 bytes (SQLite's BINARY collation). ``id``
183
+ is unique, so the order is total."""
184
+ def collate(value):
185
+ if value is None:
186
+ return (0, b"")
187
+ if isinstance(value, str):
188
+ return (2, value.encode("utf-8"))
189
+ if isinstance(value, (bytes, bytearray, memoryview)):
190
+ return (3, bytes(value))
191
+ return (1, value)
192
+
193
+ return (collate(row[0]), collate(row[3]), collate(row[4]),
194
+ collate(row[16]))
168
195
 
169
196
 
170
197
  #: The thread leg of the decode inventory (§4.3 item 1). ``{threads}`` is the
@@ -1184,6 +1211,32 @@ def _require_joined_metadata(row: sqlite3.Row) -> tuple[str, str]:
1184
1211
  return root_key, conversation_key
1185
1212
 
1186
1213
 
1214
+ def _load_qualified_codex_rows(
1215
+ conn: sqlite3.Connection, start: dt.datetime, end: dt.datetime,
1216
+ identities: "tuple[tuple[str, str], ...] | None",
1217
+ ) -> tuple:
1218
+ """The qualified accounting rows of ``[start, end)``, in
1219
+ ``(timestamp_utc, source_root_key, conversation_key, id)`` order: the whole
1220
+ window, or only the given dirty ``(root, path)`` identities.
1221
+
1222
+ The window read streams in that order from
1223
+ ``idx_codex_entries_ts_root_conversation``. The dirty-path read seeks
1224
+ ``idx_codex_entries_root_path`` instead and is ordered here, in Python, over
1225
+ the rows it returns whole anyway (#901 Amendment 19 T1); the rows keep the
1226
+ connection's row factory.
1227
+ """
1228
+ params: tuple[object, ...] = (
1229
+ start.astimezone(UTC).isoformat(), end.astimezone(UTC).isoformat(),
1230
+ *(value for identity in (identities or ()) for value in identity),
1231
+ )
1232
+ if identities is None:
1233
+ return tuple(conn.execute(_QUALIFIED_CODEX_ENTRIES_SQL, params))
1234
+ rows = conn.execute(
1235
+ _qualified_codex_path_entries_sql(len(identities)), params).fetchall()
1236
+ rows.sort(key=_qualified_codex_row_order)
1237
+ return tuple(rows)
1238
+
1239
+
1187
1240
  def load_qualified_codex_entries(
1188
1241
  start: dt.datetime,
1189
1242
  end: dt.datetime,
@@ -1194,6 +1247,7 @@ def load_qualified_codex_entries(
1194
1247
  cache_conn: sqlite3.Connection | None = None,
1195
1248
  source_identities: Iterable[tuple[str, str]] | None = None,
1196
1249
  inventory: "CodexThreadMetadataInventory | None" = None,
1250
+ _on_sync: "Callable[[object], object] | None" = None,
1197
1251
  ) -> tuple[QualifiedCodexEntry, ...]:
1198
1252
  """Load exactly one bounded, root-qualified Codex accounting read.
1199
1253
 
@@ -1240,16 +1294,10 @@ def load_qualified_codex_entries(
1240
1294
  )
1241
1295
  if stats.lock_contended:
1242
1296
  raise QualifiedMetadataUnavailable("Codex qualified project metadata is unavailable")
1297
+ if _on_sync is not None:
1298
+ _on_sync(stats)
1243
1299
  conn.row_factory = sqlite3.Row
1244
- sql = (
1245
- _QUALIFIED_CODEX_ENTRIES_SQL if identities is None
1246
- else _qualified_codex_path_entries_sql(len(identities))
1247
- )
1248
- params: tuple[object, ...] = (
1249
- start.astimezone(UTC).isoformat(), end.astimezone(UTC).isoformat(),
1250
- *(value for identity in (identities or ()) for value in identity),
1251
- )
1252
- rows = tuple(conn.execute(sql, params))
1300
+ rows = _load_qualified_codex_rows(conn, start, end, identities)
1253
1301
  # §4.3: one inventory read per build. The caller supplies the build's
1254
1302
  # own inventory when it has one; its alias leg is unbounded, which is a
1255
1303
  # SUPERSET of the `paths=identities` leg this read would have loaded,
@@ -1903,6 +1951,8 @@ def _source_entries(
1903
1951
  if qualified:
1904
1952
  values = load_qualified_codex_entries(
1905
1953
  start, query_end, speed=speed, sync=sync, group=group,
1954
+ _on_sync=_cctally()._load_sibling(
1955
+ "_cctally_cache")._warn_compressed_unreadable_once,
1906
1956
  )
1907
1957
  return assign_collision_safe_project_labels(values)
1908
1958
  return load_codex_accounting_entries(
@@ -3228,6 +3278,8 @@ def cmd_source_diff(args: object) -> int:
3228
3278
  except ValueError as exc:
3229
3279
  print(f"diff: {exc}", file=sys.stderr)
3230
3280
  return 2
3281
+ except CodexHistoryUnavailable:
3282
+ raise
3231
3283
  except RuntimeError as exc:
3232
3284
  print(str(exc), file=sys.stderr)
3233
3285
  return 1
@@ -3261,12 +3313,16 @@ def cmd_source_diff(args: object) -> int:
3261
3313
  except QualifiedMetadataUnavailable:
3262
3314
  try:
3263
3315
  entries = _source_entries(entry_args, start, end, qualified=False)
3316
+ except CodexHistoryUnavailable:
3317
+ raise
3264
3318
  except RuntimeError:
3265
3319
  return _emit_source_result(
3266
3320
  args, SourceResult("codex", "unavailable", None, (QUALIFIED_METADATA_WARNING,)),
3267
3321
  diff=True, claude=claude,
3268
3322
  )
3269
3323
  metadata_available = False
3324
+ except CodexHistoryUnavailable:
3325
+ raise
3270
3326
  except RuntimeError:
3271
3327
  return _emit_source_result(
3272
3328
  args, SourceResult("codex", "unavailable", None, (QUALIFIED_METADATA_WARNING,)),
@@ -3316,6 +3372,8 @@ def cmd_source_range_cost(args: object) -> int:
3316
3372
  args, start, end, qualified=bool(getattr(args, "project", None)),
3317
3373
  inclusive_end=True,
3318
3374
  )
3375
+ except CodexHistoryUnavailable:
3376
+ raise
3319
3377
  except (QualifiedMetadataUnavailable, RuntimeError):
3320
3378
  return _emit_source_result(
3321
3379
  args, SourceResult("codex", "unavailable", None, (QUALIFIED_METADATA_WARNING,)),
@@ -3372,6 +3430,8 @@ def cmd_source_cache_report(args: object) -> int:
3372
3430
  )
3373
3431
  try:
3374
3432
  entries = _source_entries(entry_args, start, end, qualified=False)
3433
+ except CodexHistoryUnavailable:
3434
+ raise
3375
3435
  except RuntimeError:
3376
3436
  return _emit_source_result(
3377
3437
  args, SourceResult("codex", "unavailable", None, (QUALIFIED_METADATA_WARNING,)),
@@ -3432,6 +3492,10 @@ def cmd_source_report(args: object) -> int:
3432
3492
  c.reconcile_codex_quota_projection(now=as_of)
3433
3493
  force_direct_accounting = bool(getattr(sync_stats, "lock_contended", False))
3434
3494
  already_synced = not force_direct_accounting
3495
+ if already_synced:
3496
+ cache_mod._warn_compressed_unreadable_once(sync_stats)
3497
+ except CodexHistoryUnavailable:
3498
+ raise
3435
3499
  except (OSError, sqlite3.Error, RuntimeError):
3436
3500
  return _emit_source_result(
3437
3501
  args, build_codex_report_result((), (), as_of=as_of, quota_available=False),
@@ -3458,6 +3522,8 @@ def cmd_source_report(args: object) -> int:
3458
3522
  args, start, end, qualified=False, sync=not already_synced,
3459
3523
  force_direct=force_direct_accounting,
3460
3524
  )
3525
+ except CodexHistoryUnavailable:
3526
+ raise
3461
3527
  except RuntimeError:
3462
3528
  return _emit_source_result(
3463
3529
  args, build_codex_report_result((), (), as_of=as_of, quota_available=False),