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.
- package/CHANGELOG.md +25 -0
- package/bin/_cctally_account.py +32 -8
- package/bin/_cctally_cache.py +4394 -279
- package/bin/_cctally_completion.py +1287 -0
- package/bin/_cctally_config.py +37 -0
- package/bin/_cctally_core.py +178 -2
- package/bin/_cctally_dashboard.py +2 -2
- package/bin/_cctally_dashboard_conversation.py +292 -23
- package/bin/_cctally_dashboard_envelope.py +16 -3
- package/bin/_cctally_dashboard_sources.py +18 -9
- package/bin/_cctally_db.py +316 -5
- package/bin/_cctally_doctor.py +436 -27
- package/bin/_cctally_five_hour.py +82 -31
- package/bin/_cctally_forecast.py +81 -9
- package/bin/_cctally_journal.py +3884 -112
- package/bin/_cctally_journal_repair.py +101 -12
- package/bin/_cctally_parser.py +42 -0
- package/bin/_cctally_project.py +8 -2
- package/bin/_cctally_quarantine.py +652 -0
- package/bin/_cctally_quota.py +26 -4
- package/bin/_cctally_quota_model.py +360 -83
- package/bin/_cctally_record.py +677 -149
- package/bin/_cctally_rederive.py +297 -32
- package/bin/_cctally_refresh.py +84 -13
- package/bin/_cctally_source_analytics.py +23 -2
- package/bin/_cctally_statusline.py +6137 -517
- package/bin/_cctally_store.py +287 -48
- package/bin/_cctally_transcript.py +8 -7
- package/bin/_cctally_tui.py +57 -53
- package/bin/_lib_account_binding.py +152 -0
- package/bin/_lib_account_protocol.py +386 -0
- package/bin/_lib_aggregators.py +4 -0
- package/bin/_lib_cache_coverage.py +13 -1
- package/bin/_lib_cache_writer_lock.py +19 -3
- package/bin/_lib_clone_copy.py +142 -0
- package/bin/_lib_codex_conversation_query.py +133 -19
- package/bin/_lib_codex_conversation_watch.py +59 -14
- package/bin/_lib_codex_drain_admission.py +214 -0
- package/bin/_lib_codex_metadata.py +18 -1
- package/bin/_lib_codex_publication_barrier.py +277 -0
- package/bin/_lib_codex_rollout_container.py +502 -0
- package/bin/_lib_conversation_dispatch.py +3 -0
- package/bin/_lib_conversation_query.py +11 -3
- package/bin/_lib_detector_access_registry.py +3817 -0
- package/bin/_lib_doctor.py +452 -1
- package/bin/_lib_five_hour.py +126 -0
- package/bin/_lib_ingest_frontier.py +414 -20
- package/bin/_lib_journal.py +141 -10
- package/bin/_lib_jsonl.py +5 -4
- package/bin/_lib_legacy_spool.py +126 -0
- package/bin/_lib_legacy_writer_evidence.py +136 -0
- package/bin/_lib_merge_gate.py +29 -302
- package/bin/_lib_meter_rate_change.py +10 -0
- package/bin/_lib_outcome_quarantine.py +690 -0
- package/bin/_lib_pricing.py +7 -2
- package/bin/_lib_quarantine.py +154 -0
- package/bin/_lib_quota_calibration.py +53 -8
- package/bin/_lib_quota_copy.py +2 -1
- package/bin/_lib_quota_model.py +288 -46
- package/bin/_lib_rederive.py +94 -5
- package/bin/_lib_segment_summary.py +35 -2
- package/bin/_lib_statusline_candidates.py +292 -23
- package/bin/cctally +23 -1
- package/package.json +14 -1
package/bin/_cctally_refresh.py
CHANGED
|
@@ -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
|
|
922
|
-
"""
|
|
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(
|
|
925
|
-
|
|
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),
|