cctally 1.114.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 (64) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/bin/_cctally_account.py +32 -8
  3. package/bin/_cctally_cache.py +4394 -279
  4. package/bin/_cctally_completion.py +1287 -0
  5. package/bin/_cctally_config.py +37 -0
  6. package/bin/_cctally_core.py +178 -2
  7. package/bin/_cctally_dashboard.py +2 -2
  8. package/bin/_cctally_dashboard_conversation.py +292 -23
  9. package/bin/_cctally_dashboard_envelope.py +16 -3
  10. package/bin/_cctally_dashboard_sources.py +18 -9
  11. package/bin/_cctally_db.py +316 -5
  12. package/bin/_cctally_doctor.py +436 -27
  13. package/bin/_cctally_five_hour.py +82 -31
  14. package/bin/_cctally_forecast.py +81 -9
  15. package/bin/_cctally_journal.py +3884 -112
  16. package/bin/_cctally_journal_repair.py +101 -12
  17. package/bin/_cctally_parser.py +42 -0
  18. package/bin/_cctally_project.py +8 -2
  19. package/bin/_cctally_quarantine.py +652 -0
  20. package/bin/_cctally_quota.py +26 -4
  21. package/bin/_cctally_quota_model.py +360 -83
  22. package/bin/_cctally_record.py +677 -149
  23. package/bin/_cctally_rederive.py +297 -32
  24. package/bin/_cctally_refresh.py +84 -13
  25. package/bin/_cctally_source_analytics.py +23 -2
  26. package/bin/_cctally_statusline.py +6137 -517
  27. package/bin/_cctally_store.py +287 -48
  28. package/bin/_cctally_transcript.py +8 -7
  29. package/bin/_cctally_tui.py +57 -53
  30. package/bin/_lib_account_binding.py +152 -0
  31. package/bin/_lib_account_protocol.py +386 -0
  32. package/bin/_lib_aggregators.py +4 -0
  33. package/bin/_lib_cache_coverage.py +13 -1
  34. package/bin/_lib_cache_writer_lock.py +19 -3
  35. package/bin/_lib_clone_copy.py +142 -0
  36. package/bin/_lib_codex_conversation_query.py +133 -19
  37. package/bin/_lib_codex_conversation_watch.py +59 -14
  38. package/bin/_lib_codex_drain_admission.py +214 -0
  39. package/bin/_lib_codex_metadata.py +18 -1
  40. package/bin/_lib_codex_publication_barrier.py +277 -0
  41. package/bin/_lib_codex_rollout_container.py +502 -0
  42. package/bin/_lib_conversation_dispatch.py +3 -0
  43. package/bin/_lib_conversation_query.py +11 -3
  44. package/bin/_lib_detector_access_registry.py +3817 -0
  45. package/bin/_lib_doctor.py +452 -1
  46. package/bin/_lib_five_hour.py +126 -0
  47. package/bin/_lib_ingest_frontier.py +414 -20
  48. package/bin/_lib_journal.py +141 -10
  49. package/bin/_lib_jsonl.py +5 -4
  50. package/bin/_lib_legacy_spool.py +126 -0
  51. package/bin/_lib_legacy_writer_evidence.py +136 -0
  52. package/bin/_lib_merge_gate.py +29 -302
  53. package/bin/_lib_meter_rate_change.py +10 -0
  54. package/bin/_lib_outcome_quarantine.py +690 -0
  55. package/bin/_lib_pricing.py +7 -2
  56. package/bin/_lib_quarantine.py +154 -0
  57. package/bin/_lib_quota_calibration.py +53 -8
  58. package/bin/_lib_quota_copy.py +2 -1
  59. package/bin/_lib_quota_model.py +288 -46
  60. package/bin/_lib_rederive.py +94 -5
  61. package/bin/_lib_segment_summary.py +35 -2
  62. package/bin/_lib_statusline_candidates.py +292 -23
  63. package/bin/cctally +23 -1
  64. package/package.json +14 -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,
@@ -1247,6 +1247,7 @@ def load_qualified_codex_entries(
1247
1247
  cache_conn: sqlite3.Connection | None = None,
1248
1248
  source_identities: Iterable[tuple[str, str]] | None = None,
1249
1249
  inventory: "CodexThreadMetadataInventory | None" = None,
1250
+ _on_sync: "Callable[[object], object] | None" = None,
1250
1251
  ) -> tuple[QualifiedCodexEntry, ...]:
1251
1252
  """Load exactly one bounded, root-qualified Codex accounting read.
1252
1253
 
@@ -1293,6 +1294,8 @@ def load_qualified_codex_entries(
1293
1294
  )
1294
1295
  if stats.lock_contended:
1295
1296
  raise QualifiedMetadataUnavailable("Codex qualified project metadata is unavailable")
1297
+ if _on_sync is not None:
1298
+ _on_sync(stats)
1296
1299
  conn.row_factory = sqlite3.Row
1297
1300
  rows = _load_qualified_codex_rows(conn, start, end, identities)
1298
1301
  # §4.3: one inventory read per build. The caller supplies the build's
@@ -1948,6 +1951,8 @@ def _source_entries(
1948
1951
  if qualified:
1949
1952
  values = load_qualified_codex_entries(
1950
1953
  start, query_end, speed=speed, sync=sync, group=group,
1954
+ _on_sync=_cctally()._load_sibling(
1955
+ "_cctally_cache")._warn_compressed_unreadable_once,
1951
1956
  )
1952
1957
  return assign_collision_safe_project_labels(values)
1953
1958
  return load_codex_accounting_entries(
@@ -3273,6 +3278,8 @@ def cmd_source_diff(args: object) -> int:
3273
3278
  except ValueError as exc:
3274
3279
  print(f"diff: {exc}", file=sys.stderr)
3275
3280
  return 2
3281
+ except CodexHistoryUnavailable:
3282
+ raise
3276
3283
  except RuntimeError as exc:
3277
3284
  print(str(exc), file=sys.stderr)
3278
3285
  return 1
@@ -3306,12 +3313,16 @@ def cmd_source_diff(args: object) -> int:
3306
3313
  except QualifiedMetadataUnavailable:
3307
3314
  try:
3308
3315
  entries = _source_entries(entry_args, start, end, qualified=False)
3316
+ except CodexHistoryUnavailable:
3317
+ raise
3309
3318
  except RuntimeError:
3310
3319
  return _emit_source_result(
3311
3320
  args, SourceResult("codex", "unavailable", None, (QUALIFIED_METADATA_WARNING,)),
3312
3321
  diff=True, claude=claude,
3313
3322
  )
3314
3323
  metadata_available = False
3324
+ except CodexHistoryUnavailable:
3325
+ raise
3315
3326
  except RuntimeError:
3316
3327
  return _emit_source_result(
3317
3328
  args, SourceResult("codex", "unavailable", None, (QUALIFIED_METADATA_WARNING,)),
@@ -3361,6 +3372,8 @@ def cmd_source_range_cost(args: object) -> int:
3361
3372
  args, start, end, qualified=bool(getattr(args, "project", None)),
3362
3373
  inclusive_end=True,
3363
3374
  )
3375
+ except CodexHistoryUnavailable:
3376
+ raise
3364
3377
  except (QualifiedMetadataUnavailable, RuntimeError):
3365
3378
  return _emit_source_result(
3366
3379
  args, SourceResult("codex", "unavailable", None, (QUALIFIED_METADATA_WARNING,)),
@@ -3417,6 +3430,8 @@ def cmd_source_cache_report(args: object) -> int:
3417
3430
  )
3418
3431
  try:
3419
3432
  entries = _source_entries(entry_args, start, end, qualified=False)
3433
+ except CodexHistoryUnavailable:
3434
+ raise
3420
3435
  except RuntimeError:
3421
3436
  return _emit_source_result(
3422
3437
  args, SourceResult("codex", "unavailable", None, (QUALIFIED_METADATA_WARNING,)),
@@ -3477,6 +3492,10 @@ def cmd_source_report(args: object) -> int:
3477
3492
  c.reconcile_codex_quota_projection(now=as_of)
3478
3493
  force_direct_accounting = bool(getattr(sync_stats, "lock_contended", False))
3479
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
3480
3499
  except (OSError, sqlite3.Error, RuntimeError):
3481
3500
  return _emit_source_result(
3482
3501
  args, build_codex_report_result((), (), as_of=as_of, quota_available=False),
@@ -3503,6 +3522,8 @@ def cmd_source_report(args: object) -> int:
3503
3522
  args, start, end, qualified=False, sync=not already_synced,
3504
3523
  force_direct=force_direct_accounting,
3505
3524
  )
3525
+ except CodexHistoryUnavailable:
3526
+ raise
3506
3527
  except RuntimeError:
3507
3528
  return _emit_source_result(
3508
3529
  args, build_codex_report_result((), (), as_of=as_of, quota_available=False),