cctally 1.68.0 → 1.69.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 +56 -0
- package/bin/_cctally_alerts.py +54 -2
- package/bin/_cctally_cache.py +644 -107
- package/bin/_cctally_cache_report.py +41 -17
- package/bin/_cctally_codex.py +109 -4
- package/bin/_cctally_config.py +368 -2
- package/bin/_cctally_core.py +168 -5
- package/bin/_cctally_dashboard.py +679 -5
- package/bin/_cctally_dashboard_conversation.py +9 -4
- package/bin/_cctally_dashboard_envelope.py +110 -1
- package/bin/_cctally_dashboard_share.py +557 -79
- package/bin/_cctally_db.py +303 -17
- package/bin/_cctally_diff.py +49 -16
- package/bin/_cctally_doctor.py +118 -0
- package/bin/_cctally_forecast.py +12 -4
- package/bin/_cctally_parser.py +298 -92
- package/bin/_cctally_project.py +24 -8
- package/bin/_cctally_quota.py +1381 -0
- package/bin/_cctally_record.py +319 -14
- package/bin/_cctally_refresh.py +105 -3
- package/bin/_cctally_reporting.py +7 -1
- package/bin/_cctally_setup.py +343 -113
- package/bin/_cctally_statusline.py +228 -0
- package/bin/_cctally_tui.py +787 -7
- package/bin/_lib_alert_axes.py +11 -0
- package/bin/_lib_codex_hooks.py +367 -0
- package/bin/_lib_conversation_query.py +156 -67
- package/bin/_lib_doctor.py +202 -0
- package/bin/_lib_jsonl.py +529 -162
- package/bin/_lib_quota.py +566 -0
- package/bin/_lib_share.py +152 -34
- package/bin/_lib_snapshot_cache.py +26 -0
- package/bin/_lib_source_identity.py +74 -0
- package/bin/cctally +324 -10
- package/dashboard/static/assets/index-CBbErI-P.js +80 -0
- package/dashboard/static/assets/index-kDDVOLa_.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +5 -1
- package/dashboard/static/assets/index-BybNp_Di.js +0 -80
- package/dashboard/static/assets/index-DgAmLK65.css +0 -1
package/bin/_cctally_record.py
CHANGED
|
@@ -163,6 +163,7 @@ Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md
|
|
|
163
163
|
from __future__ import annotations
|
|
164
164
|
|
|
165
165
|
import argparse
|
|
166
|
+
import contextlib
|
|
166
167
|
import datetime as dt
|
|
167
168
|
import fcntl
|
|
168
169
|
import json
|
|
@@ -201,6 +202,13 @@ from _cctally_core import (
|
|
|
201
202
|
)
|
|
202
203
|
from _lib_five_hour import _canonical_5h_window_key, five_hour_milestone_range
|
|
203
204
|
from _lib_pricing import _calculate_entry_cost
|
|
205
|
+
from _lib_codex_hooks import (
|
|
206
|
+
CODEX_HOOK_THROTTLE_SECONDS,
|
|
207
|
+
acquire_due_lifecycle_locks,
|
|
208
|
+
codex_hook_roots,
|
|
209
|
+
mark_lifecycle_success,
|
|
210
|
+
release_lifecycle_locks,
|
|
211
|
+
)
|
|
204
212
|
|
|
205
213
|
|
|
206
214
|
import importlib.util as _ilu
|
|
@@ -794,8 +802,9 @@ def maybe_record_milestone(
|
|
|
794
802
|
|
|
795
803
|
|
|
796
804
|
def _record_budget_milestone_for_vendor(
|
|
797
|
-
*, vendor, target, thresholds, period, config, tz, build_payload
|
|
798
|
-
|
|
805
|
+
*, vendor, target, thresholds, period, config, tz, build_payload,
|
|
806
|
+
raise_errors: bool = False,
|
|
807
|
+
) -> int:
|
|
799
808
|
"""Shared budget-milestone firing core for both vendors (#143).
|
|
800
809
|
|
|
801
810
|
Hot-path ordering is preserved verbatim (spec §4.2 / [Pre-probe before
|
|
@@ -827,7 +836,7 @@ def _record_budget_milestone_for_vendor(
|
|
|
827
836
|
config=config, tz=tz,
|
|
828
837
|
)
|
|
829
838
|
if start_at is None:
|
|
830
|
-
return # no resolvable window yet (claude subscription-week pre-snapshot)
|
|
839
|
+
return 0 # no resolvable window yet (claude subscription-week pre-snapshot)
|
|
831
840
|
period_key = start_at.isoformat(timespec="seconds")
|
|
832
841
|
|
|
833
842
|
present = {
|
|
@@ -840,7 +849,7 @@ def _record_budget_milestone_for_vendor(
|
|
|
840
849
|
}
|
|
841
850
|
pending = [t for t in sorted(thresholds) if t not in present]
|
|
842
851
|
if not pending:
|
|
843
|
-
return # nothing left this window → skip the cost SUM
|
|
852
|
+
return 0 # nothing left this window → skip the cost SUM
|
|
844
853
|
|
|
845
854
|
spent = _budget_spend_for_vendor(
|
|
846
855
|
conn, vendor=vendor, start_at=start_at, now_utc=now_utc
|
|
@@ -870,6 +879,8 @@ def _record_budget_milestone_for_vendor(
|
|
|
870
879
|
conn.commit()
|
|
871
880
|
except Exception as exc:
|
|
872
881
|
eprint(f"[budget-milestone:{vendor}] error recording budget milestone: {exc}")
|
|
882
|
+
if raise_errors:
|
|
883
|
+
raise
|
|
873
884
|
finally:
|
|
874
885
|
conn.close()
|
|
875
886
|
|
|
@@ -881,6 +892,7 @@ def _record_budget_milestone_for_vendor(
|
|
|
881
892
|
_dispatch_alert_notification(payload, mode="real")
|
|
882
893
|
except Exception as dispatch_exc:
|
|
883
894
|
eprint(f"[budget-alerts:{vendor}] dispatch failed: {dispatch_exc}")
|
|
895
|
+
return len(pending_alerts)
|
|
884
896
|
|
|
885
897
|
|
|
886
898
|
def maybe_record_budget_milestone(saved: dict[str, Any]) -> None:
|
|
@@ -1108,7 +1120,9 @@ def maybe_record_project_budget_milestone(saved: dict[str, Any]) -> None:
|
|
|
1108
1120
|
eprint(f"[project-budget-alerts] dispatch failed: {dispatch_exc}")
|
|
1109
1121
|
|
|
1110
1122
|
|
|
1111
|
-
def maybe_record_codex_budget_milestone(
|
|
1123
|
+
def maybe_record_codex_budget_milestone(
|
|
1124
|
+
saved: dict[str, Any], *, raise_errors: bool = False,
|
|
1125
|
+
) -> int:
|
|
1112
1126
|
"""Fire Codex budget alerts on ACTUAL-Codex-spend threshold crossings (axis
|
|
1113
1127
|
``codex_budget``, calendar-period-codex-budgets spec §6 — the gap the Codex
|
|
1114
1128
|
spec review flagged: Codex usage never flows through ``record-usage``, so the
|
|
@@ -1143,16 +1157,16 @@ def maybe_record_codex_budget_milestone(saved: dict[str, Any]) -> None:
|
|
|
1143
1157
|
budget_cfg = _get_budget_config(config)
|
|
1144
1158
|
except _BudgetConfigError as exc:
|
|
1145
1159
|
_warn_budget_bad_config_once(exc)
|
|
1146
|
-
return
|
|
1160
|
+
return 0
|
|
1147
1161
|
codex_cfg = budget_cfg.get("codex")
|
|
1148
1162
|
if not codex_cfg or not codex_cfg.get("alerts_enabled"):
|
|
1149
|
-
return
|
|
1163
|
+
return 0
|
|
1150
1164
|
target = codex_cfg.get("amount_usd")
|
|
1151
1165
|
thresholds = codex_cfg.get("alert_thresholds") or []
|
|
1152
1166
|
if target is None or not thresholds:
|
|
1153
|
-
return
|
|
1167
|
+
return 0
|
|
1154
1168
|
tz = resolve_display_tz(argparse.Namespace(tz=None), config)
|
|
1155
|
-
_record_budget_milestone_for_vendor(
|
|
1169
|
+
return _record_budget_milestone_for_vendor(
|
|
1156
1170
|
vendor="codex",
|
|
1157
1171
|
target=target,
|
|
1158
1172
|
thresholds=thresholds,
|
|
@@ -1171,6 +1185,7 @@ def maybe_record_codex_budget_milestone(saved: dict[str, Any]) -> None:
|
|
|
1171
1185
|
spent_usd=kw["spent_usd"],
|
|
1172
1186
|
consumption_pct=kw["consumption_pct"],
|
|
1173
1187
|
),
|
|
1188
|
+
raise_errors=raise_errors,
|
|
1174
1189
|
)
|
|
1175
1190
|
|
|
1176
1191
|
|
|
@@ -3562,7 +3577,13 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
|
|
|
3562
3577
|
return 0
|
|
3563
3578
|
|
|
3564
3579
|
payload = {
|
|
3565
|
-
|
|
3580
|
+
# Record the true feeder. Defaults to "statusline" for the public
|
|
3581
|
+
# `record-usage` CLI (preserves prior behavior); the OAuth callers
|
|
3582
|
+
# (_hook_tick_oauth_refresh / _refresh_usage_inproc) pass "api", and
|
|
3583
|
+
# the statusline persist feeder passes "statusline". Previously this
|
|
3584
|
+
# was hard-coded "statusline" for EVERY caller, mislabeling OAuth
|
|
3585
|
+
# rows (spec §5). No migration — the column already exists.
|
|
3586
|
+
"source": getattr(args, "source", "statusline"),
|
|
3566
3587
|
"capturedAt": now_utc_iso(),
|
|
3567
3588
|
"weeklyPercent": weekly_percent,
|
|
3568
3589
|
"weekStartDate": week_start_date,
|
|
@@ -3735,6 +3756,166 @@ def _hook_tick_throttle_touch() -> None:
|
|
|
3735
3756
|
pass
|
|
3736
3757
|
|
|
3737
3758
|
|
|
3759
|
+
# =========================================================================
|
|
3760
|
+
# Statusline observation marker + OAuth backoff deadline (spec 2026-07-17)
|
|
3761
|
+
# =========================================================================
|
|
3762
|
+
#
|
|
3763
|
+
# Two independent markers with two DIFFERENT time encodings:
|
|
3764
|
+
# - The observation marker is MTIME-based (mirrors _hook_tick_throttle_*):
|
|
3765
|
+
# it answers "how long since the statusline last fed?", so its age is
|
|
3766
|
+
# the signal. It is touched even on a dedup no-op.
|
|
3767
|
+
# - The OAuth backoff marker is CONTENT-based: it stores a FUTURE absolute
|
|
3768
|
+
# epoch deadline as text. Its mtime is meaningless (~now). Encoding the
|
|
3769
|
+
# deadline as mtime would future-date the file and, if ever confused
|
|
3770
|
+
# with a throttle marker, corrupt an mtime-age reading — hence the
|
|
3771
|
+
# deliberate split (Codex P1-3).
|
|
3772
|
+
|
|
3773
|
+
|
|
3774
|
+
def _statusline_observe_age_seconds() -> float:
|
|
3775
|
+
"""Seconds since the statusline last successfully fed usage; +inf if
|
|
3776
|
+
never. Mtime-based, mirroring ``_hook_tick_throttle_age_seconds``."""
|
|
3777
|
+
try:
|
|
3778
|
+
mtime = _cctally_core.STATUSLINE_OBSERVE_MARKER_PATH.stat().st_mtime
|
|
3779
|
+
except FileNotFoundError:
|
|
3780
|
+
return float("inf")
|
|
3781
|
+
except OSError:
|
|
3782
|
+
return float("inf")
|
|
3783
|
+
return max(0.0, time.time() - mtime)
|
|
3784
|
+
|
|
3785
|
+
|
|
3786
|
+
def _statusline_observe_touch() -> None:
|
|
3787
|
+
"""Mark the statusline as alive (mtime := now, creating if missing).
|
|
3788
|
+
|
|
3789
|
+
Called after every persist attempt that fed valid 7d input through
|
|
3790
|
+
``cmd_record_usage`` successfully — INCLUDING a dedup (no-insert)
|
|
3791
|
+
return, so the throttles reflect liveness rather than snapshot age."""
|
|
3792
|
+
try:
|
|
3793
|
+
_cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
|
|
3794
|
+
_cctally_core.STATUSLINE_OBSERVE_MARKER_PATH.touch(exist_ok=True)
|
|
3795
|
+
os.utime(_cctally_core.STATUSLINE_OBSERVE_MARKER_PATH, None)
|
|
3796
|
+
except OSError:
|
|
3797
|
+
pass
|
|
3798
|
+
|
|
3799
|
+
|
|
3800
|
+
def _oauth_backoff_remaining_seconds() -> float:
|
|
3801
|
+
"""Seconds until the shared OAuth 429 backoff deadline; ``0.0`` when the
|
|
3802
|
+
marker is absent, empty, malformed, or already elapsed.
|
|
3803
|
+
|
|
3804
|
+
Reads the ABSOLUTE epoch deadline from the marker's text CONTENT (not
|
|
3805
|
+
its mtime) and returns ``max(0.0, deadline - now)``."""
|
|
3806
|
+
try:
|
|
3807
|
+
raw = _cctally_core.OAUTH_BACKOFF_MARKER_PATH.read_text()
|
|
3808
|
+
except FileNotFoundError:
|
|
3809
|
+
return 0.0
|
|
3810
|
+
except OSError:
|
|
3811
|
+
return 0.0
|
|
3812
|
+
try:
|
|
3813
|
+
deadline = float(raw.strip())
|
|
3814
|
+
except (TypeError, ValueError):
|
|
3815
|
+
return 0.0
|
|
3816
|
+
return max(0.0, deadline - time.time())
|
|
3817
|
+
|
|
3818
|
+
|
|
3819
|
+
def _oauth_backoff_set(deadline_epoch: float) -> None:
|
|
3820
|
+
"""Persist the shared OAuth 429 backoff deadline (absolute epoch).
|
|
3821
|
+
|
|
3822
|
+
Never SHORTENS an existing deadline: writes ``max(deadline_epoch,
|
|
3823
|
+
existing_deadline)`` so concurrent/repeated 429s keep the furthest-out
|
|
3824
|
+
cooldown. The write is atomic (tmp + ``os.replace``) so a reader never
|
|
3825
|
+
sees a half-written file. Best-effort — any OSError is swallowed."""
|
|
3826
|
+
try:
|
|
3827
|
+
_cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
|
|
3828
|
+
except OSError:
|
|
3829
|
+
pass
|
|
3830
|
+
# Compare against the existing ABSOLUTE deadline, not the remaining
|
|
3831
|
+
# seconds, so "never shorten" holds regardless of when we read it.
|
|
3832
|
+
existing_abs = 0.0
|
|
3833
|
+
try:
|
|
3834
|
+
existing_raw = _cctally_core.OAUTH_BACKOFF_MARKER_PATH.read_text()
|
|
3835
|
+
existing_abs = float(existing_raw.strip())
|
|
3836
|
+
except (FileNotFoundError, OSError, TypeError, ValueError):
|
|
3837
|
+
existing_abs = 0.0
|
|
3838
|
+
target = max(float(deadline_epoch), existing_abs)
|
|
3839
|
+
path = _cctally_core.OAUTH_BACKOFF_MARKER_PATH
|
|
3840
|
+
tmp = path.with_name(path.name + ".tmp")
|
|
3841
|
+
try:
|
|
3842
|
+
tmp.write_text(f"{target:.6f}")
|
|
3843
|
+
os.replace(tmp, path)
|
|
3844
|
+
except OSError:
|
|
3845
|
+
try:
|
|
3846
|
+
tmp.unlink()
|
|
3847
|
+
except OSError:
|
|
3848
|
+
pass
|
|
3849
|
+
|
|
3850
|
+
|
|
3851
|
+
def _oauth_backoff_clear() -> None:
|
|
3852
|
+
"""Remove the OAuth backoff deadline marker (ignore if absent)."""
|
|
3853
|
+
try:
|
|
3854
|
+
_cctally_core.OAUTH_BACKOFF_MARKER_PATH.unlink()
|
|
3855
|
+
except FileNotFoundError:
|
|
3856
|
+
pass
|
|
3857
|
+
except OSError:
|
|
3858
|
+
pass
|
|
3859
|
+
|
|
3860
|
+
|
|
3861
|
+
def _oauth_backoff_count() -> int:
|
|
3862
|
+
"""The consecutive-429 count (0 when absent/malformed)."""
|
|
3863
|
+
try:
|
|
3864
|
+
raw = _cctally_core.OAUTH_BACKOFF_COUNT_PATH.read_text()
|
|
3865
|
+
except (FileNotFoundError, OSError):
|
|
3866
|
+
return 0
|
|
3867
|
+
try:
|
|
3868
|
+
return max(0, int(raw.strip()))
|
|
3869
|
+
except (TypeError, ValueError):
|
|
3870
|
+
return 0
|
|
3871
|
+
|
|
3872
|
+
|
|
3873
|
+
def _oauth_backoff_register_429(*, retry_after_deadline, now) -> float:
|
|
3874
|
+
"""Record a 429: set/extend the shared backoff deadline and bump the
|
|
3875
|
+
consecutive-429 counter. Returns the effective deadline (absolute epoch).
|
|
3876
|
+
|
|
3877
|
+
Policy (spec §4):
|
|
3878
|
+
- A valid ``Retry-After`` (``retry_after_deadline`` is not None) is used
|
|
3879
|
+
verbatim.
|
|
3880
|
+
- Otherwise conservative exponential backoff:
|
|
3881
|
+
``now + min(CAP, BASE * 2**consecutive_429)``.
|
|
3882
|
+
- ``_oauth_backoff_set`` keeps the MAX, so concurrent/repeated 429s
|
|
3883
|
+
never shorten the cooldown."""
|
|
3884
|
+
count = _oauth_backoff_count()
|
|
3885
|
+
if retry_after_deadline is not None:
|
|
3886
|
+
deadline = float(retry_after_deadline)
|
|
3887
|
+
else:
|
|
3888
|
+
base = float(_cctally_core.OAUTH_BACKOFF_BASE_SECONDS)
|
|
3889
|
+
cap = float(_cctally_core.OAUTH_BACKOFF_CAP_SECONDS)
|
|
3890
|
+
# Clamp the exponent so a corrupt/huge counter can't overflow 2**n.
|
|
3891
|
+
exp = 2 ** min(count, 30)
|
|
3892
|
+
deadline = float(now) + min(cap, base * exp)
|
|
3893
|
+
_oauth_backoff_set(deadline)
|
|
3894
|
+
# Bump the counter atomically (best-effort).
|
|
3895
|
+
try:
|
|
3896
|
+
_cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
|
|
3897
|
+
path = _cctally_core.OAUTH_BACKOFF_COUNT_PATH
|
|
3898
|
+
tmp = path.with_name(path.name + ".tmp")
|
|
3899
|
+
tmp.write_text(str(count + 1))
|
|
3900
|
+
os.replace(tmp, path)
|
|
3901
|
+
except OSError:
|
|
3902
|
+
try:
|
|
3903
|
+
tmp.unlink()
|
|
3904
|
+
except (OSError, NameError, UnboundLocalError):
|
|
3905
|
+
pass
|
|
3906
|
+
return deadline
|
|
3907
|
+
|
|
3908
|
+
|
|
3909
|
+
def _oauth_backoff_reset() -> None:
|
|
3910
|
+
"""Clear the backoff deadline AND the consecutive-429 counter — called on
|
|
3911
|
+
any successful OAuth API response (spec §4)."""
|
|
3912
|
+
_oauth_backoff_clear()
|
|
3913
|
+
try:
|
|
3914
|
+
_cctally_core.OAUTH_BACKOFF_COUNT_PATH.unlink()
|
|
3915
|
+
except (FileNotFoundError, OSError):
|
|
3916
|
+
pass
|
|
3917
|
+
|
|
3918
|
+
|
|
3738
3919
|
def _hook_tick_read_stdin_event(stdin_max_bytes: int = 32 * 1024) -> dict:
|
|
3739
3920
|
"""Read CC's hook payload (JSON on stdin). Best-effort.
|
|
3740
3921
|
|
|
@@ -3783,6 +3964,116 @@ def _hook_tick_format_log_line(
|
|
|
3783
3964
|
)
|
|
3784
3965
|
|
|
3785
3966
|
|
|
3967
|
+
def _codex_lifecycle_log_line(
|
|
3968
|
+
*, source_root_key: str, event: str, sync: str, result: str,
|
|
3969
|
+
blocks: int, milestones: int, alert_eligible_roots: int,
|
|
3970
|
+
quota_alerts: int, budget_alerts: int, dur_ms: int,
|
|
3971
|
+
) -> str:
|
|
3972
|
+
"""Render one privacy-safe root-qualified Codex lifecycle outcome.
|
|
3973
|
+
|
|
3974
|
+
Native hook input can contain session paths and conversation identifiers;
|
|
3975
|
+
this durable diagnostic deliberately carries only the bounded event label,
|
|
3976
|
+
opaque source root key, aggregate reconciliation counts, and duration.
|
|
3977
|
+
"""
|
|
3978
|
+
safe_event = "".join(
|
|
3979
|
+
char for char in str(event)[:40] if char.isalnum() or char in "-_"
|
|
3980
|
+
) or "unknown"
|
|
3981
|
+
return (
|
|
3982
|
+
f"{now_utc_iso()} provider=codex source_root_key={source_root_key} "
|
|
3983
|
+
f"event={safe_event} sync={sync} blocks={int(blocks)} "
|
|
3984
|
+
f"milestones={int(milestones)} "
|
|
3985
|
+
f"alert_eligible_roots={int(alert_eligible_roots)} "
|
|
3986
|
+
f"quota_alerts={int(quota_alerts)} budget_alerts={int(budget_alerts)} "
|
|
3987
|
+
f"dur_ms={max(0, int(dur_ms))} result={result}"
|
|
3988
|
+
)
|
|
3989
|
+
|
|
3990
|
+
|
|
3991
|
+
def _codex_lifecycle_roots():
|
|
3992
|
+
"""Snapshot usable configured Codex homes in stable root-key order."""
|
|
3993
|
+
return codex_hook_roots(_cctally()._codex_home_roots())
|
|
3994
|
+
|
|
3995
|
+
|
|
3996
|
+
def _cmd_hook_tick_codex(args: argparse.Namespace, *, event: str = "unknown") -> int:
|
|
3997
|
+
"""Run one quiet, foreground Codex lifecycle tick.
|
|
3998
|
+
|
|
3999
|
+
Native Codex Stop/SubagentStop hooks may fire concurrently. Per-root
|
|
4000
|
+
lifecycle locks narrow alert eligibility while the one S1 cache sync and
|
|
4001
|
+
reporting reconciliation still observe the complete active root set.
|
|
4002
|
+
"""
|
|
4003
|
+
c = _cctally()
|
|
4004
|
+
roots = _codex_lifecycle_roots()
|
|
4005
|
+
locks = acquire_due_lifecycle_locks(
|
|
4006
|
+
_cctally_core.APP_DIR,
|
|
4007
|
+
roots,
|
|
4008
|
+
now=time.time(),
|
|
4009
|
+
throttle_seconds=CODEX_HOOK_THROTTLE_SECONDS,
|
|
4010
|
+
)
|
|
4011
|
+
if not locks:
|
|
4012
|
+
return 0
|
|
4013
|
+
all_root_keys = tuple(root.source_root_key for root in roots)
|
|
4014
|
+
due_root_keys = tuple(lock.root.source_root_key for lock in locks)
|
|
4015
|
+
started_at = time.monotonic()
|
|
4016
|
+
|
|
4017
|
+
def log_outcome(
|
|
4018
|
+
*, sync: str, result: str, projection=None, budget_alerts: int = 0,
|
|
4019
|
+
) -> None:
|
|
4020
|
+
blocks = int(getattr(projection, "blocks_upserted", 0) or 0)
|
|
4021
|
+
milestones = int(getattr(projection, "milestones_upserted", 0) or 0)
|
|
4022
|
+
quota_alerts = int(getattr(projection, "alerts_dispatched", 0) or 0)
|
|
4023
|
+
dur_ms = int((time.monotonic() - started_at) * 1000)
|
|
4024
|
+
for lock in locks:
|
|
4025
|
+
_hook_tick_log_line(_codex_lifecycle_log_line(
|
|
4026
|
+
source_root_key=lock.root.source_root_key,
|
|
4027
|
+
event=event,
|
|
4028
|
+
sync=sync,
|
|
4029
|
+
result=result,
|
|
4030
|
+
blocks=blocks,
|
|
4031
|
+
milestones=milestones,
|
|
4032
|
+
alert_eligible_roots=len(due_root_keys),
|
|
4033
|
+
quota_alerts=quota_alerts,
|
|
4034
|
+
budget_alerts=budget_alerts,
|
|
4035
|
+
dur_ms=dur_ms,
|
|
4036
|
+
))
|
|
4037
|
+
_hook_tick_log_rotate_if_needed()
|
|
4038
|
+
|
|
4039
|
+
try:
|
|
4040
|
+
# Hook stdout/stderr is contractually silent. Cache migration and
|
|
4041
|
+
# ingest diagnostics remain available to explicit CLI operations.
|
|
4042
|
+
with open(os.devnull, "w", encoding="utf-8") as quiet, \
|
|
4043
|
+
contextlib.redirect_stdout(quiet), contextlib.redirect_stderr(quiet):
|
|
4044
|
+
cache = c.open_cache_db()
|
|
4045
|
+
try:
|
|
4046
|
+
stats = c.sync_codex_cache(cache, lock_timeout=0)
|
|
4047
|
+
finally:
|
|
4048
|
+
cache.close()
|
|
4049
|
+
if stats.lock_contended:
|
|
4050
|
+
log_outcome(sync="contended", result="noop")
|
|
4051
|
+
return 0
|
|
4052
|
+
projection = c.reconcile_codex_quota_projection(
|
|
4053
|
+
source_root_keys=all_root_keys,
|
|
4054
|
+
alert_eligible_root_keys=due_root_keys,
|
|
4055
|
+
now=dt.datetime.now(dt.timezone.utc),
|
|
4056
|
+
)
|
|
4057
|
+
# Vendor-scoped spend is intentionally evaluated once per
|
|
4058
|
+
# successful due-set tick, not once per root.
|
|
4059
|
+
budget_alerts = c.maybe_record_codex_budget_milestone(
|
|
4060
|
+
{}, raise_errors=True,
|
|
4061
|
+
)
|
|
4062
|
+
mark_lifecycle_success(locks)
|
|
4063
|
+
log_outcome(
|
|
4064
|
+
sync="ok", result="success", projection=projection,
|
|
4065
|
+
budget_alerts=budget_alerts,
|
|
4066
|
+
)
|
|
4067
|
+
except Exception:
|
|
4068
|
+
# A failed sync, projection, or budget evaluation must acknowledge no
|
|
4069
|
+
# root. Hooks are best-effort and remain a successful no-op to Codex.
|
|
4070
|
+
log_outcome(sync="error", result="error")
|
|
4071
|
+
return 0
|
|
4072
|
+
finally:
|
|
4073
|
+
release_lifecycle_locks(locks)
|
|
4074
|
+
return 0
|
|
4075
|
+
|
|
4076
|
+
|
|
3786
4077
|
def cmd_hook_tick(args: argparse.Namespace) -> int:
|
|
3787
4078
|
"""Per-fire hook runtime (Section 3 of onboarding spec).
|
|
3788
4079
|
|
|
@@ -3790,11 +4081,25 @@ def cmd_hook_tick(args: argparse.Namespace) -> int:
|
|
|
3790
4081
|
sync_cache + (throttled) OAuth refresh, writes one log line, returns 0
|
|
3791
4082
|
UNCONDITIONALLY (even on internal failure — hook discipline).
|
|
3792
4083
|
|
|
3793
|
-
--
|
|
3794
|
-
|
|
4084
|
+
--foreground mode: reads stdin and runs the normal best-effort body in the
|
|
4085
|
+
current process without detaching. --explain mode is synchronous, prints
|
|
4086
|
+
to stdout, and returns an informative exit code.
|
|
3795
4087
|
"""
|
|
3796
4088
|
c = _cctally()
|
|
4089
|
+
source = getattr(args, "source", "claude")
|
|
4090
|
+
if source == "codex":
|
|
4091
|
+
# Codex's native handler always uses --foreground. Drain stdin before
|
|
4092
|
+
# any further decision so its event payload is never lost to detaching
|
|
4093
|
+
# shell semantics; the lifecycle body itself is intentionally quiet.
|
|
4094
|
+
meta = _hook_tick_read_stdin_event()
|
|
4095
|
+
# The production reader always returns a mapping, but this boundary is
|
|
4096
|
+
# deliberately best-effort: hook callers and lightweight lifecycle
|
|
4097
|
+
# probes may only drain stdin. Do not turn an absent payload into a
|
|
4098
|
+
# hook failure merely because event observability is unavailable.
|
|
4099
|
+
event = meta.get("event", "unknown") if isinstance(meta, dict) else "unknown"
|
|
4100
|
+
return _cmd_hook_tick_codex(args, event=event)
|
|
3797
4101
|
explain = bool(getattr(args, "explain", False))
|
|
4102
|
+
foreground = bool(getattr(args, "foreground", False))
|
|
3798
4103
|
no_oauth = bool(getattr(args, "no_oauth", False))
|
|
3799
4104
|
# Use an explicit `is None` check so `--throttle-seconds 0` survives the
|
|
3800
4105
|
# default-fallback (a `0 or DEFAULT` short-circuit would silently drop
|
|
@@ -3834,7 +4139,7 @@ def cmd_hook_tick(args: argparse.Namespace) -> int:
|
|
|
3834
4139
|
# mid-stack.
|
|
3835
4140
|
forked = False
|
|
3836
4141
|
pid = 0
|
|
3837
|
-
if not explain:
|
|
4142
|
+
if not explain and not foreground:
|
|
3838
4143
|
try:
|
|
3839
4144
|
pid = os.fork()
|
|
3840
4145
|
forked = True
|
|
@@ -3855,7 +4160,7 @@ def cmd_hook_tick(args: argparse.Namespace) -> int:
|
|
|
3855
4160
|
# In the inline-fallback path the parent process re-routes its own stdout/
|
|
3856
4161
|
# stderr to the log file for the rest of its short life. Function returns
|
|
3857
4162
|
# immediately after Step 7, so the leak is bounded.
|
|
3858
|
-
if not explain:
|
|
4163
|
+
if not explain and not foreground:
|
|
3859
4164
|
try:
|
|
3860
4165
|
_cctally_core.HOOK_TICK_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
3861
4166
|
log_fd = os.open(
|
package/bin/_cctally_refresh.py
CHANGED
|
@@ -39,6 +39,7 @@ from __future__ import annotations
|
|
|
39
39
|
import argparse
|
|
40
40
|
import dataclasses
|
|
41
41
|
import datetime as dt
|
|
42
|
+
import email.utils
|
|
42
43
|
import json
|
|
43
44
|
import os
|
|
44
45
|
import pathlib
|
|
@@ -46,6 +47,7 @@ import re
|
|
|
46
47
|
import socket
|
|
47
48
|
import subprocess
|
|
48
49
|
import sys
|
|
50
|
+
import time
|
|
49
51
|
import urllib.error
|
|
50
52
|
import urllib.request
|
|
51
53
|
|
|
@@ -64,6 +66,7 @@ def _cctally():
|
|
|
64
66
|
# `_newest_snapshot_age_seconds`, `_normalize_percent`,
|
|
65
67
|
# `_forecast_color_enabled`, `_discover_cc_version`, `cmd_record_usage`)
|
|
66
68
|
# stay on the _cctally() accessor.
|
|
69
|
+
import _cctally_core
|
|
67
70
|
from _cctally_core import (
|
|
68
71
|
eprint,
|
|
69
72
|
now_utc_iso,
|
|
@@ -87,8 +90,16 @@ class RefreshUsageRateLimitError(RefreshUsageNetworkError):
|
|
|
87
90
|
callers that already except RefreshUsageNetworkError continue to
|
|
88
91
|
catch this; specific handlers can except RefreshUsageRateLimitError
|
|
89
92
|
first to branch on the rate-limit case.
|
|
93
|
+
|
|
94
|
+
``retry_after_deadline`` carries the absolute epoch parsed from the
|
|
95
|
+
response's ``Retry-After`` header (spec §4), or ``None`` when the header
|
|
96
|
+
is absent/malformed — in which case callers apply exponential backoff.
|
|
90
97
|
"""
|
|
91
98
|
|
|
99
|
+
def __init__(self, message="", retry_after_deadline=None):
|
|
100
|
+
super().__init__(message)
|
|
101
|
+
self.retry_after_deadline = retry_after_deadline
|
|
102
|
+
|
|
92
103
|
|
|
93
104
|
class RefreshUsageMalformedError(Exception):
|
|
94
105
|
"""Raised when the OAuth usage API response is unparseable or missing
|
|
@@ -227,6 +238,45 @@ def _resolve_oauth_usage_user_agent(
|
|
|
227
238
|
# Core OAuth fetch
|
|
228
239
|
# =========================================================================
|
|
229
240
|
|
|
241
|
+
def _parse_retry_after(header_value, now: float) -> "float | None":
|
|
242
|
+
"""Parse an HTTP ``Retry-After`` header into an absolute epoch deadline.
|
|
243
|
+
|
|
244
|
+
Supports both RFC 7231 forms:
|
|
245
|
+
- delta-seconds: a non-negative integer number of seconds from ``now``.
|
|
246
|
+
- HTTP-date: an absolute timestamp (IMF-fixdate / RFC 850 / asctime),
|
|
247
|
+
parsed via ``email.utils.parsedate_to_datetime`` (stdlib).
|
|
248
|
+
|
|
249
|
+
Returns the absolute epoch, or ``None`` when the header is absent, empty,
|
|
250
|
+
or unparseable (the caller then falls back to exponential backoff)."""
|
|
251
|
+
if header_value is None:
|
|
252
|
+
return None
|
|
253
|
+
s = str(header_value).strip()
|
|
254
|
+
if not s:
|
|
255
|
+
return None
|
|
256
|
+
# delta-seconds (a bare non-negative integer).
|
|
257
|
+
try:
|
|
258
|
+
delta = int(s)
|
|
259
|
+
except ValueError:
|
|
260
|
+
delta = None
|
|
261
|
+
if delta is not None:
|
|
262
|
+
if delta < 0:
|
|
263
|
+
return None
|
|
264
|
+
return float(now) + float(delta)
|
|
265
|
+
# HTTP-date form.
|
|
266
|
+
try:
|
|
267
|
+
parsed = email.utils.parsedate_to_datetime(s)
|
|
268
|
+
except (TypeError, ValueError):
|
|
269
|
+
return None
|
|
270
|
+
if parsed is None:
|
|
271
|
+
return None
|
|
272
|
+
if parsed.tzinfo is None:
|
|
273
|
+
parsed = parsed.replace(tzinfo=dt.timezone.utc)
|
|
274
|
+
try:
|
|
275
|
+
return parsed.timestamp()
|
|
276
|
+
except (OverflowError, OSError, ValueError):
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
|
|
230
280
|
def _fetch_oauth_usage(token: str, timeout_seconds: float) -> dict:
|
|
231
281
|
"""GET the OAuth usage API and return the parsed JSON object.
|
|
232
282
|
|
|
@@ -259,7 +309,18 @@ def _fetch_oauth_usage(token: str, timeout_seconds: float) -> dict:
|
|
|
259
309
|
pass
|
|
260
310
|
msg = f"HTTP {e.code} {e.reason}" + (f": {snippet}" if snippet else "")
|
|
261
311
|
if e.code == 429:
|
|
262
|
-
|
|
312
|
+
# Parse Retry-After (delta-seconds OR HTTP-date) into an absolute
|
|
313
|
+
# deadline the backoff state honors (spec §4). Best-effort — a
|
|
314
|
+
# missing/garbage header degrades to exponential backoff downstream.
|
|
315
|
+
retry_after_deadline = None
|
|
316
|
+
try:
|
|
317
|
+
hdr = e.headers.get("Retry-After") if e.headers else None
|
|
318
|
+
retry_after_deadline = _parse_retry_after(hdr, time.time())
|
|
319
|
+
except Exception:
|
|
320
|
+
retry_after_deadline = None
|
|
321
|
+
raise RefreshUsageRateLimitError(
|
|
322
|
+
msg, retry_after_deadline=retry_after_deadline
|
|
323
|
+
) from e
|
|
263
324
|
raise RefreshUsageNetworkError(msg) from e
|
|
264
325
|
except urllib.error.URLError as e:
|
|
265
326
|
raise RefreshUsageNetworkError(f"URLError: {e.reason}") from e
|
|
@@ -545,6 +606,13 @@ def _refresh_usage_inproc(timeout_seconds: float = 5.0) -> _RefreshUsageResult:
|
|
|
545
606
|
try:
|
|
546
607
|
api = c._fetch_oauth_usage(token=token, timeout_seconds=timeout_seconds)
|
|
547
608
|
except RefreshUsageRateLimitError as exc:
|
|
609
|
+
# Force-refresh bypasses the backfill/backoff GATE (user asked for
|
|
610
|
+
# it), but a resulting 429 MUST advance the shared backoff deadline
|
|
611
|
+
# so automatic polling still honors it (spec §4).
|
|
612
|
+
c._oauth_backoff_register_429(
|
|
613
|
+
retry_after_deadline=getattr(exc, "retry_after_deadline", None),
|
|
614
|
+
now=time.time(),
|
|
615
|
+
)
|
|
548
616
|
return _RefreshUsageResult(status="rate_limited", fallback=True,
|
|
549
617
|
reason=str(exc))
|
|
550
618
|
except RefreshUsageNetworkError as exc:
|
|
@@ -556,6 +624,9 @@ def _refresh_usage_inproc(timeout_seconds: float = 5.0) -> _RefreshUsageResult:
|
|
|
556
624
|
status="fetch_failed",
|
|
557
625
|
reason=f"invalid oauth_usage config: {exc}",
|
|
558
626
|
)
|
|
627
|
+
# Successful API response — clear any pending 429 backoff + counter, so a
|
|
628
|
+
# user-confirmed recovery lets automatic polling resume (spec §4).
|
|
629
|
+
c._oauth_backoff_reset()
|
|
559
630
|
|
|
560
631
|
seven = api.get("seven_day") or {}
|
|
561
632
|
try:
|
|
@@ -606,6 +677,9 @@ def _refresh_usage_inproc(timeout_seconds: float = 5.0) -> _RefreshUsageResult:
|
|
|
606
677
|
five_hour_resets_at=(
|
|
607
678
|
str(five_resets_epoch) if five_resets_epoch is not None else None
|
|
608
679
|
),
|
|
680
|
+
# This is the OAuth feeder — label the row "api" (spec §5). Without
|
|
681
|
+
# this, cmd_record_usage would fall back to its "statusline" default.
|
|
682
|
+
source="api",
|
|
609
683
|
)
|
|
610
684
|
try:
|
|
611
685
|
rc = c.cmd_record_usage(record_args)
|
|
@@ -789,7 +863,8 @@ def _hook_tick_oauth_refresh(
|
|
|
789
863
|
Returns (status_str, payload_or_none) where status_str is one of:
|
|
790
864
|
"ok(7d=N,5h=M)" | "ok(7d=N)"
|
|
791
865
|
"skipped-no-token" | "skipped(fresh:Ns)"
|
|
792
|
-
"
|
|
866
|
+
"skipped(statusline-fresh:Ns)" | "skipped(backoff:Ns)"
|
|
867
|
+
"err(rate-limit)" | "err(network)" | "err(parse)" | "err(record-usage=K)"
|
|
793
868
|
payload_or_none is the raw OAuth-API response dict (`seven_day` /
|
|
794
869
|
`five_hour`) on success, or None on any non-ok branch.
|
|
795
870
|
"""
|
|
@@ -802,17 +877,42 @@ def _hook_tick_oauth_refresh(
|
|
|
802
877
|
throttle_seconds = float(_get_oauth_usage_config(c.load_config())["throttle_seconds"])
|
|
803
878
|
except OauthUsageConfigError:
|
|
804
879
|
throttle_seconds = float(c.HOOK_TICK_DEFAULT_THROTTLE_SECONDS)
|
|
880
|
+
# Backfill gate (spec §4). The automatic OAuth poll is now a BACKFILL
|
|
881
|
+
# behind the statusline (the primary writer). It fetches only when the
|
|
882
|
+
# statusline has NOT fed recently AND no 429 cooldown is pending — so a
|
|
883
|
+
# live statusline keeps usage fresh with zero API calls, and a 429 streak
|
|
884
|
+
# is suppressed by the shared deadline instead of re-polled every tick.
|
|
885
|
+
# Gating on the observation marker (not snapshot age) is what makes the
|
|
886
|
+
# demotion correct: cmd_record_usage dedups unchanged percentages without
|
|
887
|
+
# refreshing captured_at, so snapshot age keeps growing while the
|
|
888
|
+
# statusline is alive. Force-refresh (_refresh_usage_inproc) never routes
|
|
889
|
+
# through here, so it is exempt (user-initiated).
|
|
890
|
+
obs_age = c._statusline_observe_age_seconds()
|
|
891
|
+
if obs_age < float(_cctally_core.OAUTH_BACKFILL_STALE_SECONDS):
|
|
892
|
+
return f"skipped(statusline-fresh:{int(obs_age)}s)", None
|
|
893
|
+
backoff_remaining = c._oauth_backoff_remaining_seconds()
|
|
894
|
+
if backoff_remaining > 0:
|
|
895
|
+
return f"skipped(backoff:{int(backoff_remaining)}s)", None
|
|
805
896
|
age_s = c._newest_snapshot_age_seconds()
|
|
806
897
|
if age_s is not None and age_s < throttle_seconds:
|
|
807
898
|
return f"skipped(fresh:{int(age_s)}s)", None
|
|
808
899
|
try:
|
|
809
900
|
api = c._fetch_oauth_usage(token=token, timeout_seconds=timeout_seconds)
|
|
810
|
-
except RefreshUsageRateLimitError:
|
|
901
|
+
except RefreshUsageRateLimitError as exc:
|
|
902
|
+
# Arm the shared backoff deadline so the next tick is suppressed
|
|
903
|
+
# rather than immediately re-polling (spec §4). Honors Retry-After
|
|
904
|
+
# when present, else conservative exponential backoff.
|
|
905
|
+
c._oauth_backoff_register_429(
|
|
906
|
+
retry_after_deadline=getattr(exc, "retry_after_deadline", None),
|
|
907
|
+
now=time.time(),
|
|
908
|
+
)
|
|
811
909
|
return "err(rate-limit)", None
|
|
812
910
|
except RefreshUsageNetworkError:
|
|
813
911
|
return "err(network)", None
|
|
814
912
|
except RefreshUsageMalformedError:
|
|
815
913
|
return "err(parse)", None
|
|
914
|
+
# Successful API response — clear any pending 429 backoff + counter.
|
|
915
|
+
c._oauth_backoff_reset()
|
|
816
916
|
seven = api["seven_day"]
|
|
817
917
|
try:
|
|
818
918
|
seven_pct = c._normalize_percent(float(seven["utilization"]))
|
|
@@ -839,6 +939,8 @@ def _hook_tick_oauth_refresh(
|
|
|
839
939
|
resets_at=str(seven_resets_epoch),
|
|
840
940
|
five_hour_percent=five_pct,
|
|
841
941
|
five_hour_resets_at=str(five_resets_epoch) if five_resets_epoch is not None else None,
|
|
942
|
+
# OAuth feeder — label the row "api" (spec §5).
|
|
943
|
+
source="api",
|
|
842
944
|
)
|
|
843
945
|
try:
|
|
844
946
|
rc = c.cmd_record_usage(record_args)
|
|
@@ -602,6 +602,7 @@ def cmd_session(args: argparse.Namespace) -> int:
|
|
|
602
602
|
|
|
603
603
|
def cmd_range_cost(args: argparse.Namespace) -> int:
|
|
604
604
|
c = _cctally()
|
|
605
|
+
c._share_validate_args(args)
|
|
605
606
|
# Session A (spec §7.2 / §7.6 row note): range-cost has no --tz of
|
|
606
607
|
# its own — start/end carry their own zone via ISO 8601. Calling the
|
|
607
608
|
# bridge keeps the alias-surface contract uniform across the 10
|
|
@@ -701,7 +702,12 @@ def cmd_range_cost(args: argparse.Namespace) -> int:
|
|
|
701
702
|
),
|
|
702
703
|
"modelBreakdowns": breakdowns,
|
|
703
704
|
}
|
|
704
|
-
|
|
705
|
+
payload = c.stamp_schema_version(output)
|
|
706
|
+
sink = getattr(args, "_source_result_sink", None)
|
|
707
|
+
if sink is not None:
|
|
708
|
+
sink(payload)
|
|
709
|
+
else:
|
|
710
|
+
print(json.dumps(payload, indent=2))
|
|
705
711
|
return 0
|
|
706
712
|
|
|
707
713
|
if args.breakdown:
|