cctally 1.27.0 → 1.28.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 +27 -0
- package/bin/_cctally_alerts.py +26 -1
- package/bin/_cctally_cache.py +278 -6
- package/bin/_cctally_config.py +153 -11
- package/bin/_cctally_core.py +230 -41
- package/bin/_cctally_dashboard.py +399 -37
- package/bin/_cctally_db.py +594 -163
- package/bin/_cctally_doctor.py +11 -0
- package/bin/_cctally_forecast.py +700 -57
- package/bin/_cctally_milestones.py +273 -28
- package/bin/_cctally_parser.py +44 -4
- package/bin/_cctally_record.py +328 -50
- package/bin/_cctally_setup.py +7 -3
- package/bin/_cctally_statusline.py +8 -0
- package/bin/_cctally_update.py +3 -3
- package/bin/_cctally_weekrefs.py +30 -6
- package/bin/_lib_alert_axes.py +8 -1
- package/bin/_lib_alerts_payload.py +95 -3
- package/bin/_lib_budget.py +48 -0
- package/bin/_lib_conversation.py +162 -0
- package/bin/_lib_conversation_query.py +524 -0
- package/bin/_lib_doctor.py +60 -1
- package/bin/_lib_transcript_access.py +80 -0
- package/bin/cctally +40 -1
- package/dashboard/static/assets/{index-D34qf0LE.css → index-Bj5ckRUE.css} +1 -1
- package/dashboard/static/assets/index-Dw4G5FD9.js +18 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +4 -1
- package/dashboard/static/assets/index-C2F1_Mxt.js +0 -18
package/bin/_cctally_record.py
CHANGED
|
@@ -269,6 +269,30 @@ def _build_alert_payload_project_budget(*args, **kwargs):
|
|
|
269
269
|
return sys.modules["cctally"]._build_alert_payload_project_budget(*args, **kwargs)
|
|
270
270
|
|
|
271
271
|
|
|
272
|
+
def _build_alert_payload_codex_budget(*args, **kwargs):
|
|
273
|
+
return sys.modules["cctally"]._build_alert_payload_codex_budget(*args, **kwargs)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def insert_codex_budget_milestone(*args, **kwargs):
|
|
277
|
+
return sys.modules["cctally"].insert_codex_budget_milestone(*args, **kwargs)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _codex_budget_crossings(*args, **kwargs):
|
|
281
|
+
return sys.modules["cctally"]._codex_budget_crossings(*args, **kwargs)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _resolve_codex_budget_period_window(*args, **kwargs):
|
|
285
|
+
return sys.modules["cctally"]._resolve_codex_budget_period_window(*args, **kwargs)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _sum_codex_cost_for_range(*args, **kwargs):
|
|
289
|
+
return sys.modules["cctally"]._sum_codex_cost_for_range(*args, **kwargs)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def resolve_display_tz(*args, **kwargs):
|
|
293
|
+
return sys.modules["cctally"].resolve_display_tz(*args, **kwargs)
|
|
294
|
+
|
|
295
|
+
|
|
272
296
|
def _sum_cost_by_project(*args, **kwargs):
|
|
273
297
|
return sys.modules["cctally"]._sum_cost_by_project(*args, **kwargs)
|
|
274
298
|
|
|
@@ -297,6 +321,10 @@ def _resolve_current_budget_window(*args, **kwargs):
|
|
|
297
321
|
return sys.modules["cctally"]._resolve_current_budget_window(*args, **kwargs)
|
|
298
322
|
|
|
299
323
|
|
|
324
|
+
def _resolve_claude_budget_window(*args, **kwargs):
|
|
325
|
+
return sys.modules["cctally"]._resolve_claude_budget_window(*args, **kwargs)
|
|
326
|
+
|
|
327
|
+
|
|
300
328
|
def _sum_cost_for_range(*args, **kwargs):
|
|
301
329
|
return sys.modules["cctally"]._sum_cost_for_range(*args, **kwargs)
|
|
302
330
|
|
|
@@ -329,8 +357,8 @@ def _assess_forecast_confidence(*args, **kwargs):
|
|
|
329
357
|
return sys.modules["cctally"]._assess_forecast_confidence(*args, **kwargs)
|
|
330
358
|
|
|
331
359
|
|
|
332
|
-
def
|
|
333
|
-
return sys.modules["cctally"].
|
|
360
|
+
def _build_vendor_budget_inputs(*args, **kwargs):
|
|
361
|
+
return sys.modules["cctally"]._build_vendor_budget_inputs(*args, **kwargs)
|
|
334
362
|
|
|
335
363
|
|
|
336
364
|
def compute_budget_status(*args, **kwargs):
|
|
@@ -744,9 +772,11 @@ def maybe_record_budget_milestone(saved: dict[str, Any]) -> None:
|
|
|
744
772
|
# overhead for non-budget users. `load_config()` is safe outside any
|
|
745
773
|
# writer lock — atomic-rename guarantees whole-byte reads. A malformed
|
|
746
774
|
# budget block is a quiet warn-once no-op (mirrors weekly/5h), NOT an
|
|
747
|
-
# unthrottled per-tick stderr via the caller's wrapper.
|
|
775
|
+
# unthrottled per-tick stderr via the caller's wrapper. One config read
|
|
776
|
+
# services both the gate and the calendar-window tz resolution.
|
|
777
|
+
config = load_config()
|
|
748
778
|
try:
|
|
749
|
-
budget_cfg = _get_budget_config(
|
|
779
|
+
budget_cfg = _get_budget_config(config)
|
|
750
780
|
except _BudgetConfigError as exc:
|
|
751
781
|
_warn_budget_bad_config_once(exc)
|
|
752
782
|
return
|
|
@@ -756,12 +786,21 @@ def maybe_record_budget_milestone(saved: dict[str, Any]) -> None:
|
|
|
756
786
|
thresholds = budget_cfg["alert_thresholds"]
|
|
757
787
|
if not thresholds:
|
|
758
788
|
return
|
|
759
|
-
|
|
789
|
+
# Period generalization (spec §6): subscription-week resolves the snapshot-
|
|
790
|
+
# anchored window; a calendar period (calendar-week / calendar-month)
|
|
791
|
+
# resolves the window purely from `now` + the period and stores the
|
|
792
|
+
# period-start instant in the SAME `week_start_at` key column (back-compat
|
|
793
|
+
# misnomer). config/tz are resolved once for the calendar branch.
|
|
794
|
+
period = budget_cfg.get("period", "subscription-week")
|
|
795
|
+
|
|
796
|
+
tz = resolve_display_tz(argparse.Namespace(tz=None), config)
|
|
760
797
|
now_utc = _command_as_of()
|
|
761
798
|
pending_alerts: list[dict[str, Any]] = []
|
|
762
799
|
conn = open_db()
|
|
763
800
|
try:
|
|
764
|
-
window =
|
|
801
|
+
window = _resolve_claude_budget_window(
|
|
802
|
+
conn, now_utc, period=period, config=config, tz=tz
|
|
803
|
+
)
|
|
765
804
|
if window is None:
|
|
766
805
|
return # no resolvable week window yet (spec §6 worst case)
|
|
767
806
|
week_start_at, _week_end_at = window
|
|
@@ -773,10 +812,15 @@ def maybe_record_budget_milestone(saved: dict[str, Any]) -> None:
|
|
|
773
812
|
# — so a partial prior run that recorded some-but-not-all thresholds
|
|
774
813
|
# still gets the remaining ones a SUM + crossing-check. The skip never
|
|
775
814
|
# owes a crossing: an un-recorded threshold always forces the SUM.
|
|
815
|
+
# The `period IS NULL` arm (#137) makes a pre-011 NULL-period row for
|
|
816
|
+
# this window count as already-recorded, so an upgrading user never
|
|
817
|
+
# re-fires a spurious alert against a historical crossing; a row stored
|
|
818
|
+
# under the SAME concrete `period` also counts (fire-once).
|
|
776
819
|
present = {
|
|
777
820
|
int(r[0]) for r in conn.execute(
|
|
778
|
-
"SELECT threshold FROM budget_milestones
|
|
779
|
-
(
|
|
821
|
+
"SELECT threshold FROM budget_milestones "
|
|
822
|
+
"WHERE week_start_at = ? AND (period = ? OR period IS NULL)",
|
|
823
|
+
(week_key, period),
|
|
780
824
|
)
|
|
781
825
|
}
|
|
782
826
|
pending = [t for t in sorted(thresholds) if t not in present]
|
|
@@ -794,6 +838,7 @@ def maybe_record_budget_milestone(saved: dict[str, Any]) -> None:
|
|
|
794
838
|
inserted = insert_budget_milestone(
|
|
795
839
|
conn,
|
|
796
840
|
week_start_at=week_key,
|
|
841
|
+
period=period,
|
|
797
842
|
threshold=t,
|
|
798
843
|
budget_usd=target,
|
|
799
844
|
spent_usd=spent,
|
|
@@ -807,13 +852,14 @@ def maybe_record_budget_milestone(saved: dict[str, Any]) -> None:
|
|
|
807
852
|
# set-then-dispatch: alerted_at lands on the row BEFORE
|
|
808
853
|
# the osascript Popen, sharing this transaction with the
|
|
809
854
|
# INSERT (commit=False) so a crash between them is
|
|
810
|
-
# impossible.
|
|
811
|
-
#
|
|
855
|
+
# impossible. The UPDATE keys on the CONCRETE `period` (#137)
|
|
856
|
+
# — never a NULL-period sibling. `alerted_at IS NULL` guard
|
|
857
|
+
# is write-once defense-in-depth.
|
|
812
858
|
conn.execute(
|
|
813
859
|
"UPDATE budget_milestones SET alerted_at = ? "
|
|
814
|
-
"WHERE week_start_at = ? AND threshold = ? "
|
|
860
|
+
"WHERE week_start_at = ? AND period = ? AND threshold = ? "
|
|
815
861
|
" AND alerted_at IS NULL",
|
|
816
|
-
(crossed_at, week_key, t),
|
|
862
|
+
(crossed_at, week_key, period, t),
|
|
817
863
|
)
|
|
818
864
|
pending_alerts.append(_build_alert_payload_budget(
|
|
819
865
|
threshold=t,
|
|
@@ -822,6 +868,7 @@ def maybe_record_budget_milestone(saved: dict[str, Any]) -> None:
|
|
|
822
868
|
budget_usd=target,
|
|
823
869
|
spent_usd=spent,
|
|
824
870
|
consumption_pct=consumption_pct,
|
|
871
|
+
period=period,
|
|
825
872
|
))
|
|
826
873
|
# Single commit: every INSERT + its alerted_at marker durable together.
|
|
827
874
|
conn.commit()
|
|
@@ -1005,6 +1052,114 @@ def maybe_record_project_budget_milestone(saved: dict[str, Any]) -> None:
|
|
|
1005
1052
|
eprint(f"[project-budget-alerts] dispatch failed: {dispatch_exc}")
|
|
1006
1053
|
|
|
1007
1054
|
|
|
1055
|
+
def maybe_record_codex_budget_milestone(saved: dict[str, Any]) -> None:
|
|
1056
|
+
"""Fire Codex budget alerts on ACTUAL-Codex-spend threshold crossings (axis
|
|
1057
|
+
``codex_budget``, calendar-period-codex-budgets spec §6 — the gap the Codex
|
|
1058
|
+
spec review flagged: Codex usage never flows through ``record-usage``, so the
|
|
1059
|
+
Claude budget axes can't catch it). Called from ``cmd_record_usage``
|
|
1060
|
+
alongside the weekly-% / 5h-% / budget / project-budget milestone helpers AND
|
|
1061
|
+
opportunistically from ``cmd_budget`` (the Codex section already resolves
|
|
1062
|
+
spend + crossings). Forward-only / fire-once, so the double-trigger never
|
|
1063
|
+
double-fires. Gated, hot-path-cheap, set-then-dispatch. Errors are logged,
|
|
1064
|
+
not raised (the caller also wraps).
|
|
1065
|
+
|
|
1066
|
+
Unlike the Claude budget axis, Codex has NO subscription week: the period
|
|
1067
|
+
window is resolved purely from ``now`` + the configured calendar period
|
|
1068
|
+
(calendar-week / calendar-month) via the pure ``calendar_*_window``
|
|
1069
|
+
functions — it NEVER touches ``weekly_usage_snapshots``. The crossing +
|
|
1070
|
+
set-then-dispatch arithmetic is factored into the shared
|
|
1071
|
+
``_codex_budget_crossings`` helper so this firing path and the
|
|
1072
|
+
``cctally budget`` opportunistic path stay byte-identical (plan §3.6).
|
|
1073
|
+
|
|
1074
|
+
``saved`` is accepted for call-site symmetry with the sibling helpers but is
|
|
1075
|
+
unused: Codex spend is resolved from the cache DB (``_sum_codex_cost_for_range``)
|
|
1076
|
+
independent of the just-recorded 7d-% snapshot.
|
|
1077
|
+
"""
|
|
1078
|
+
# Gate FIRST (hot-path discipline): no Codex budget OR alerts off → zero
|
|
1079
|
+
# overhead for non-Codex-budget users. `load_config()` is safe outside any
|
|
1080
|
+
# writer lock (atomic-rename). A malformed budget block is a quiet warn-once
|
|
1081
|
+
# no-op (mirrors maybe_record_budget_milestone). One config read services
|
|
1082
|
+
# both the gate and the calendar-window tz resolution.
|
|
1083
|
+
config = load_config()
|
|
1084
|
+
try:
|
|
1085
|
+
budget_cfg = _get_budget_config(config)
|
|
1086
|
+
except _BudgetConfigError as exc:
|
|
1087
|
+
_warn_budget_bad_config_once(exc)
|
|
1088
|
+
return
|
|
1089
|
+
codex_cfg = budget_cfg.get("codex")
|
|
1090
|
+
if not codex_cfg or not codex_cfg.get("alerts_enabled"):
|
|
1091
|
+
return
|
|
1092
|
+
target = codex_cfg.get("amount_usd")
|
|
1093
|
+
thresholds = codex_cfg.get("alert_thresholds") or []
|
|
1094
|
+
if target is None or not thresholds:
|
|
1095
|
+
return
|
|
1096
|
+
period = codex_cfg["period"]
|
|
1097
|
+
|
|
1098
|
+
tz = resolve_display_tz(argparse.Namespace(tz=None), config)
|
|
1099
|
+
now_utc = _command_as_of()
|
|
1100
|
+
pending_alerts: list[dict[str, Any]] = []
|
|
1101
|
+
conn = open_db()
|
|
1102
|
+
try:
|
|
1103
|
+
start_at, _end_at = _resolve_codex_budget_period_window(
|
|
1104
|
+
period, now_utc, config, tz
|
|
1105
|
+
)
|
|
1106
|
+
period_key = start_at.isoformat(timespec="seconds")
|
|
1107
|
+
|
|
1108
|
+
# Pre-probe (hot-path discipline + [Dedup mustn't gate side effects]):
|
|
1109
|
+
# which configured thresholds are STILL un-recorded for this period? The
|
|
1110
|
+
# Codex cost SUM is skipped ONLY when every threshold already has a row.
|
|
1111
|
+
# The `period IS NULL` arm (#137) makes a pre-011 NULL-period row for
|
|
1112
|
+
# this period count as already-recorded (no spurious upgrade re-fire); a
|
|
1113
|
+
# row under the SAME concrete `period` also counts (fire-once).
|
|
1114
|
+
present = {
|
|
1115
|
+
int(r[0]) for r in conn.execute(
|
|
1116
|
+
"SELECT threshold FROM codex_budget_milestones "
|
|
1117
|
+
"WHERE period_start_at = ? AND (period = ? OR period IS NULL)",
|
|
1118
|
+
(period_key, period),
|
|
1119
|
+
)
|
|
1120
|
+
}
|
|
1121
|
+
pending = [t for t in sorted(thresholds) if t not in present]
|
|
1122
|
+
if not pending:
|
|
1123
|
+
return # nothing left to cross this period → skip the cost SUM
|
|
1124
|
+
|
|
1125
|
+
spent = _sum_codex_cost_for_range(start_at, now_utc)
|
|
1126
|
+
# Shared INSERT-and-arm core (set-then-dispatch, fire-once via rowcount);
|
|
1127
|
+
# commit=False inside, so this conn owns the single durable commit below.
|
|
1128
|
+
for t, crossed_at, sp, tg, pct in _codex_budget_crossings(
|
|
1129
|
+
conn,
|
|
1130
|
+
period_key=period_key,
|
|
1131
|
+
period=period,
|
|
1132
|
+
thresholds=pending,
|
|
1133
|
+
target=target,
|
|
1134
|
+
spent=spent,
|
|
1135
|
+
now_utc=now_utc,
|
|
1136
|
+
):
|
|
1137
|
+
pending_alerts.append(_build_alert_payload_codex_budget(
|
|
1138
|
+
threshold=t,
|
|
1139
|
+
crossed_at_utc=crossed_at,
|
|
1140
|
+
period_start_at=period_key,
|
|
1141
|
+
period=period,
|
|
1142
|
+
budget_usd=tg,
|
|
1143
|
+
spent_usd=sp,
|
|
1144
|
+
consumption_pct=pct,
|
|
1145
|
+
))
|
|
1146
|
+
# Single commit: every INSERT + its alerted_at marker durable together.
|
|
1147
|
+
conn.commit()
|
|
1148
|
+
except Exception as exc:
|
|
1149
|
+
eprint(f"[codex-budget-milestone] error recording codex budget milestone: {exc}")
|
|
1150
|
+
finally:
|
|
1151
|
+
conn.close()
|
|
1152
|
+
|
|
1153
|
+
# Dispatch AFTER commit; a dispatch failure NEVER rolls back the milestone
|
|
1154
|
+
# (set-then-dispatch invariant — one queue attempt per crossing, deduped on
|
|
1155
|
+
# the alerted_at column).
|
|
1156
|
+
for payload in pending_alerts:
|
|
1157
|
+
try:
|
|
1158
|
+
_dispatch_alert_notification(payload, mode="real")
|
|
1159
|
+
except Exception as dispatch_exc:
|
|
1160
|
+
eprint(f"[codex-budget-alerts] dispatch failed: {dispatch_exc}")
|
|
1161
|
+
|
|
1162
|
+
|
|
1008
1163
|
def _weekly_pct_week_avg_projection(conn, now_utc):
|
|
1009
1164
|
"""Compute the week-AVERAGE weekly-% projection for the current
|
|
1010
1165
|
subscription week, snapshot-only (CHEAP — no cost SUM, no ``sync_cache``).
|
|
@@ -1058,27 +1213,39 @@ def _weekly_pct_week_avg_projection(conn, now_utc):
|
|
|
1058
1213
|
return (projected_pct, confidence == "low")
|
|
1059
1214
|
|
|
1060
1215
|
|
|
1061
|
-
def maybe_record_projected_alert(
|
|
1062
|
-
|
|
1216
|
+
def maybe_record_projected_alert(
|
|
1217
|
+
saved: dict[str, Any], *, only_metrics=None
|
|
1218
|
+
) -> None:
|
|
1219
|
+
"""Projected-pace detect-and-arm (axis ``projected``, #121 / #135).
|
|
1063
1220
|
|
|
1064
1221
|
Fires on the WEEK-AVERAGE projection (never the displayed high-end verdict
|
|
1065
|
-
band) for ``weekly_pct
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
blocks.
|
|
1222
|
+
band) for ``weekly_pct``, ``budget_usd`` (any Claude period — #135) and/or
|
|
1223
|
+
``codex_budget_usd`` (#135). Its OWN detect-and-arm — NOT folded into
|
|
1224
|
+
``maybe_record_milestone`` (Section 1 / Codex P0-3) — called from
|
|
1225
|
+
``cmd_record_usage`` in its own ``try`` after the weekly/5h/budget blocks.
|
|
1069
1226
|
|
|
1070
1227
|
Master gates (Codex P1-2): ``weekly_pct`` fires only under
|
|
1071
1228
|
``alerts.enabled && alerts.projected_enabled``; ``budget_usd`` only under
|
|
1072
|
-
``_budget_alerts_active(budget_cfg) && budget.projected_enabled
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1229
|
+
``_budget_alerts_active(budget_cfg) && budget.projected_enabled`` (#135:
|
|
1230
|
+
ALL Claude periods, not just subscription-week); ``codex_budget_usd`` only
|
|
1231
|
+
under ``codex.alerts_enabled && codex.projected_enabled`` with a set
|
|
1232
|
+
``amount_usd`` + ``alert_thresholds`` (mirrors
|
|
1233
|
+
``maybe_record_codex_budget_milestone``'s gate — there is no
|
|
1234
|
+
``_codex_budget_alerts_active`` helper). All toggles default OFF (no
|
|
1235
|
+
surprise notifications on upgrade). When NONE is on, returns after only a
|
|
1236
|
+
cheap config read — no projection math, no cost work.
|
|
1237
|
+
|
|
1238
|
+
``only_metrics`` (#135): when a set of metric names is passed (the
|
|
1239
|
+
opportunistic ``cctally budget`` fire passes ``{"codex_budget_usd"}``), only
|
|
1240
|
+
those legs run — so that interactive fire never pops a ``weekly_pct`` /
|
|
1241
|
+
Claude-``budget_usd`` notification. ``None`` (the record path) = every
|
|
1242
|
+
enabled leg.
|
|
1076
1243
|
|
|
1077
1244
|
Pre-probe (Codex P1-1): a metric whose levels are ALL already latched is
|
|
1078
1245
|
skipped BEFORE any projection / cost work.
|
|
1079
1246
|
|
|
1080
1247
|
Snap-up (Codex P2-1): a level fires when ``projected + 1e-9 >= threshold``.
|
|
1081
|
-
Latch / fire-once: ``UNIQUE(week_start_at, metric, threshold)`` + the
|
|
1248
|
+
Latch / fire-once: ``UNIQUE(week_start_at, period, metric, threshold)`` + the
|
|
1082
1249
|
rowcount==1 predicate — a later recovery neither un-fires nor re-fires.
|
|
1083
1250
|
Mid-week reset re-anchors ``week_start_at`` (budget pattern; no
|
|
1084
1251
|
``reset_event_id``).
|
|
@@ -1087,12 +1254,17 @@ def maybe_record_projected_alert(saved: dict[str, Any]) -> None:
|
|
|
1087
1254
|
txn, commit, THEN best-effort dispatch. A dispatch failure never rolls back
|
|
1088
1255
|
the milestone.
|
|
1089
1256
|
|
|
1090
|
-
|
|
1257
|
+
Both budget legs reuse the SAME ``_build_vendor_budget_inputs`` +
|
|
1091
1258
|
``compute_budget_status`` path that produces ``budget --json``'s
|
|
1092
1259
|
``week_avg_projection_usd`` (the reconcile-bound field) — value-exact by
|
|
1093
|
-
construction
|
|
1094
|
-
|
|
1095
|
-
|
|
1260
|
+
construction, keyed on the calendar/subscription period-start instant in the
|
|
1261
|
+
back-compat ``week_start_at`` column. The Claude leg passes ``skip_sync=True``
|
|
1262
|
+
(the cache is warmed by the actual-budget axis's spend SUM this same tick);
|
|
1263
|
+
the Codex leg passes ``skip_sync=False`` (R5: Codex has no other record-path
|
|
1264
|
+
warmer — ``maybe_record_codex_budget_milestone`` short-circuits before its SUM
|
|
1265
|
+
when all actual levels are latched, so a ``skip_sync=True`` Codex leg could
|
|
1266
|
+
read a cold cache and under-count; the delta-sync is a near-no-op when warm).
|
|
1267
|
+
The pre-probe skips each leg entirely when all its levels are already latched.
|
|
1096
1268
|
"""
|
|
1097
1269
|
# The `projected_enabled` toggles are validated keys on the alerts/budget
|
|
1098
1270
|
# blocks (bool-validated; default OFF), so read them straight off the
|
|
@@ -1114,12 +1286,44 @@ def maybe_record_projected_alert(saved: dict[str, Any]) -> None:
|
|
|
1114
1286
|
weekly_on = bool(alerts_cfg.get("enabled")) and bool(
|
|
1115
1287
|
alerts_cfg.get("projected_enabled")
|
|
1116
1288
|
)
|
|
1289
|
+
# #135: the Claude `budget_usd` leg now fires for ANY period (calendar-week /
|
|
1290
|
+
# calendar-month / subscription-week). `_build_vendor_budget_inputs` resolves
|
|
1291
|
+
# the correct window per period, and the milestone keys on that period-start
|
|
1292
|
+
# instant (in the back-compat `week_start_at` column) — the same key the
|
|
1293
|
+
# actual-budget axis uses — so there is no window/key mismatch any more.
|
|
1117
1294
|
budget_on = _budget_alerts_active(budget_cfg) and bool(
|
|
1118
1295
|
budget_cfg.get("projected_enabled")
|
|
1119
1296
|
)
|
|
1120
|
-
|
|
1297
|
+
# #135: the Codex `codex_budget_usd` leg. No `_codex_budget_alerts_active`
|
|
1298
|
+
# helper exists, so inline the gate mirroring
|
|
1299
|
+
# `maybe_record_codex_budget_milestone`: a Codex budget block with alerts +
|
|
1300
|
+
# projected on and a set amount/thresholds. (Projected requires
|
|
1301
|
+
# `alerts_enabled` too — same as the Claude leg, where `_budget_alerts_active`
|
|
1302
|
+
# requires it — documented in budget.md, not UI-enforced.)
|
|
1303
|
+
codex_cfg = budget_cfg.get("codex") or {}
|
|
1304
|
+
codex_on = (
|
|
1305
|
+
bool(codex_cfg)
|
|
1306
|
+
and bool(codex_cfg.get("alerts_enabled"))
|
|
1307
|
+
and bool(codex_cfg.get("projected_enabled"))
|
|
1308
|
+
and codex_cfg.get("amount_usd") is not None
|
|
1309
|
+
and bool(codex_cfg.get("alert_thresholds"))
|
|
1310
|
+
)
|
|
1311
|
+
# only_metrics scopes the opportunistic `cctally budget` fire to the Codex
|
|
1312
|
+
# leg so it never pops a weekly_pct / Claude budget_usd notification.
|
|
1313
|
+
if only_metrics is not None:
|
|
1314
|
+
weekly_on = weekly_on and "weekly_pct" in only_metrics
|
|
1315
|
+
budget_on = budget_on and "budget_usd" in only_metrics
|
|
1316
|
+
codex_on = codex_on and "codex_budget_usd" in only_metrics
|
|
1317
|
+
if not (weekly_on or budget_on or codex_on):
|
|
1121
1318
|
return # cheap config-only path — non-projected users pay nothing
|
|
1122
1319
|
|
|
1320
|
+
# Both budget legs resolve their window via _build_vendor_budget_inputs in
|
|
1321
|
+
# CONFIG tz (Namespace(tz=None)) — like maybe_record_codex_budget_milestone
|
|
1322
|
+
# — so a `cctally budget --tz X` opportunistic fire near a period boundary
|
|
1323
|
+
# resolves the SAME period_start_at dedup key as the record path and never
|
|
1324
|
+
# forks / double-fires.
|
|
1325
|
+
config_tz = resolve_display_tz(argparse.Namespace(tz=None), cfg)
|
|
1326
|
+
|
|
1123
1327
|
now_utc = _command_as_of()
|
|
1124
1328
|
pending: list[dict[str, Any]] = []
|
|
1125
1329
|
conn = open_db()
|
|
@@ -1134,9 +1338,10 @@ def maybe_record_projected_alert(saved: dict[str, Any]) -> None:
|
|
|
1134
1338
|
)
|
|
1135
1339
|
week_key = ws_at.isoformat(timespec="seconds")
|
|
1136
1340
|
levels = (90, 100)
|
|
1341
|
+
# weekly_pct is the Anthropic subscription week (#137).
|
|
1137
1342
|
if not _projected_levels_already_latched(
|
|
1138
|
-
conn, week_start_at=week_key,
|
|
1139
|
-
levels=levels,
|
|
1343
|
+
conn, week_start_at=week_key, period="subscription-week",
|
|
1344
|
+
metric="weekly_pct", levels=levels,
|
|
1140
1345
|
):
|
|
1141
1346
|
proj = _weekly_pct_week_avg_projection(conn, now_utc)
|
|
1142
1347
|
if proj is not None and not proj[1]:
|
|
@@ -1145,33 +1350,40 @@ def maybe_record_projected_alert(saved: dict[str, Any]) -> None:
|
|
|
1145
1350
|
if value + 1e-9 >= t:
|
|
1146
1351
|
pending.append(dict(
|
|
1147
1352
|
week_start_at=week_key,
|
|
1353
|
+
period="subscription-week",
|
|
1148
1354
|
metric="weekly_pct",
|
|
1149
1355
|
threshold=t,
|
|
1150
1356
|
projected_value=value,
|
|
1151
1357
|
denominator=100.0,
|
|
1152
1358
|
))
|
|
1153
1359
|
|
|
1154
|
-
# ── budget_usd leg (
|
|
1360
|
+
# ── budget_usd leg (any Claude period — #135; shared factory) ────────
|
|
1155
1361
|
if budget_on:
|
|
1156
1362
|
target = budget_cfg["weekly_usd"]
|
|
1157
1363
|
thresholds = tuple(
|
|
1158
1364
|
sorted(set(int(t) for t in budget_cfg["alert_thresholds"]))
|
|
1159
1365
|
)
|
|
1160
|
-
|
|
1366
|
+
claude_period = budget_cfg.get("period", "subscription-week")
|
|
1367
|
+
# Resolve the window key CHEAPLY first (SUM-free, same resolver the
|
|
1368
|
+
# actual-budget axis uses) so the pre-probe can short-circuit BEFORE
|
|
1369
|
+
# _build_vendor_budget_inputs runs any cost SUM / cache sync — the
|
|
1370
|
+
# pre-probe-runs-first contract (spec §3.4; mirrors the actual axis).
|
|
1371
|
+
window = _resolve_claude_budget_window(
|
|
1372
|
+
conn, now_utc, period=claude_period, config=cfg, tz=config_tz
|
|
1373
|
+
)
|
|
1161
1374
|
if window is not None and thresholds:
|
|
1162
1375
|
b_ws_at, _b_we_at = window
|
|
1163
1376
|
b_week_key = b_ws_at.isoformat(timespec="seconds")
|
|
1164
1377
|
if not _projected_levels_already_latched(
|
|
1165
|
-
conn, week_start_at=b_week_key,
|
|
1166
|
-
levels=thresholds,
|
|
1378
|
+
conn, week_start_at=b_week_key, period=claude_period,
|
|
1379
|
+
metric="budget_usd", levels=thresholds,
|
|
1167
1380
|
):
|
|
1168
|
-
# skip_sync=True: the actual-budget axis
|
|
1169
|
-
# (maybe_record_budget_milestone) already ran a
|
|
1381
|
+
# skip_sync=True: the actual-budget axis already ran a
|
|
1170
1382
|
# _sum_cost_for_range this same tick, warming the cache.
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1383
|
+
inputs = _build_vendor_budget_inputs(
|
|
1384
|
+
vendor="claude", period=claude_period, target_usd=target,
|
|
1385
|
+
alert_thresholds=thresholds, now_utc=now_utc, config=cfg,
|
|
1386
|
+
tz=config_tz, skip_sync=True,
|
|
1175
1387
|
)
|
|
1176
1388
|
if inputs is not None:
|
|
1177
1389
|
status = compute_budget_status(inputs)
|
|
@@ -1181,18 +1393,66 @@ def maybe_record_projected_alert(saved: dict[str, Any]) -> None:
|
|
|
1181
1393
|
if value + 1e-9 >= (t / 100.0) * float(target):
|
|
1182
1394
|
pending.append(dict(
|
|
1183
1395
|
week_start_at=b_week_key,
|
|
1396
|
+
period=claude_period,
|
|
1184
1397
|
metric="budget_usd",
|
|
1185
1398
|
threshold=t,
|
|
1186
1399
|
projected_value=value,
|
|
1187
1400
|
denominator=float(target),
|
|
1188
1401
|
))
|
|
1189
1402
|
|
|
1403
|
+
# ── codex_budget_usd leg (#135; skip_sync=False — R5) ────────────────
|
|
1404
|
+
if codex_on:
|
|
1405
|
+
c_target = codex_cfg["amount_usd"]
|
|
1406
|
+
c_thresholds = tuple(
|
|
1407
|
+
sorted(set(int(t) for t in codex_cfg["alert_thresholds"]))
|
|
1408
|
+
)
|
|
1409
|
+
c_period = codex_cfg["period"]
|
|
1410
|
+
# Cheap, SUM-free window key first (pure calendar resolution), so the
|
|
1411
|
+
# pre-probe short-circuits BEFORE any Codex cache sync / cost SUM —
|
|
1412
|
+
# spec §3.4 (pre-probe runs FIRST).
|
|
1413
|
+
c_window = _resolve_codex_budget_period_window(
|
|
1414
|
+
c_period, now_utc, cfg, config_tz
|
|
1415
|
+
)
|
|
1416
|
+
if c_window is not None and c_thresholds:
|
|
1417
|
+
c_ws_at, _c_we_at = c_window
|
|
1418
|
+
c_week_key = c_ws_at.isoformat(timespec="seconds")
|
|
1419
|
+
if not _projected_levels_already_latched(
|
|
1420
|
+
conn, week_start_at=c_week_key, period=c_period,
|
|
1421
|
+
metric="codex_budget_usd", levels=c_thresholds,
|
|
1422
|
+
):
|
|
1423
|
+
# skip_sync=False (R5): Codex has no other record-path cache
|
|
1424
|
+
# warmer (maybe_record_codex_budget_milestone short-circuits
|
|
1425
|
+
# before its SUM when all actual levels are latched), so a
|
|
1426
|
+
# skip_sync=True leg could read a cold cache and under-count.
|
|
1427
|
+
# The pre-probe above already gated this, so a sync only runs
|
|
1428
|
+
# when a cross is genuinely owed; it's a near-no-op when warm.
|
|
1429
|
+
c_inputs = _build_vendor_budget_inputs(
|
|
1430
|
+
vendor="codex", period=c_period, target_usd=c_target,
|
|
1431
|
+
alert_thresholds=c_thresholds, now_utc=now_utc,
|
|
1432
|
+
config=cfg, tz=config_tz, skip_sync=False,
|
|
1433
|
+
)
|
|
1434
|
+
if c_inputs is not None:
|
|
1435
|
+
c_status = compute_budget_status(c_inputs)
|
|
1436
|
+
if not c_status.low_confidence:
|
|
1437
|
+
value = c_status.week_avg_projection_usd
|
|
1438
|
+
for t in c_thresholds:
|
|
1439
|
+
if value + 1e-9 >= (t / 100.0) * float(c_target):
|
|
1440
|
+
pending.append(dict(
|
|
1441
|
+
week_start_at=c_week_key,
|
|
1442
|
+
period=c_period,
|
|
1443
|
+
metric="codex_budget_usd",
|
|
1444
|
+
threshold=t,
|
|
1445
|
+
projected_value=value,
|
|
1446
|
+
denominator=float(c_target),
|
|
1447
|
+
))
|
|
1448
|
+
|
|
1190
1449
|
# ── arm (set-then-dispatch): INSERT + stamp alerted_at in one txn ────
|
|
1191
1450
|
fired: list[dict[str, Any]] = []
|
|
1192
1451
|
for p in pending:
|
|
1193
1452
|
inserted = insert_projected_milestone(
|
|
1194
1453
|
conn,
|
|
1195
1454
|
week_start_at=p["week_start_at"],
|
|
1455
|
+
period=p["period"],
|
|
1196
1456
|
metric=p["metric"],
|
|
1197
1457
|
threshold=p["threshold"],
|
|
1198
1458
|
projected_value=p["projected_value"],
|
|
@@ -1200,14 +1460,15 @@ def maybe_record_projected_alert(saved: dict[str, Any]) -> None:
|
|
|
1200
1460
|
commit=False,
|
|
1201
1461
|
)
|
|
1202
1462
|
# Only the genuine-new-crossing winner (rowcount==1) arms+dispatches;
|
|
1203
|
-
# a racing record-usage instance gets rowcount==0 and skips.
|
|
1463
|
+
# a racing record-usage instance gets rowcount==0 and skips. The
|
|
1464
|
+
# alerted_at UPDATE keys on the CONCRETE `period` (#137).
|
|
1204
1465
|
if inserted == 1:
|
|
1205
1466
|
conn.execute(
|
|
1206
1467
|
"UPDATE projected_milestones SET alerted_at = ? "
|
|
1207
|
-
"WHERE week_start_at = ? AND
|
|
1208
|
-
" AND alerted_at IS NULL",
|
|
1209
|
-
(now_utc_iso(), p["week_start_at"], p["
|
|
1210
|
-
p["threshold"]),
|
|
1468
|
+
"WHERE week_start_at = ? AND period = ? AND metric = ? "
|
|
1469
|
+
" AND threshold = ? AND alerted_at IS NULL",
|
|
1470
|
+
(now_utc_iso(), p["week_start_at"], p["period"],
|
|
1471
|
+
p["metric"], p["threshold"]),
|
|
1211
1472
|
)
|
|
1212
1473
|
fired.append(p)
|
|
1213
1474
|
# Single commit: every INSERT + its alerted_at marker durable together.
|
|
@@ -2810,6 +3071,8 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
|
|
|
2810
3071
|
(maybe_record_budget_milestone, "budget-milestone"),
|
|
2811
3072
|
(maybe_record_project_budget_milestone,
|
|
2812
3073
|
"project-budget-milestone"),
|
|
3074
|
+
(maybe_record_codex_budget_milestone,
|
|
3075
|
+
"codex-budget-milestone"),
|
|
2813
3076
|
(maybe_record_projected_alert, "projected-alert"),
|
|
2814
3077
|
):
|
|
2815
3078
|
try:
|
|
@@ -2869,11 +3132,26 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
|
|
|
2869
3132
|
except Exception as exc:
|
|
2870
3133
|
eprint(f"[project-budget-milestone] unexpected error: {exc}")
|
|
2871
3134
|
|
|
2872
|
-
# NEW:
|
|
2873
|
-
#
|
|
2874
|
-
#
|
|
2875
|
-
#
|
|
2876
|
-
#
|
|
3135
|
+
# NEW: Codex budget alert firing (axis `codex_budget`, calendar-period-codex-
|
|
3136
|
+
# budgets). Gated FIRST on a configured budget.codex + alerts_enabled — non-
|
|
3137
|
+
# Codex-budget users pay only one config read. Codex usage never flows through
|
|
3138
|
+
# record-usage, so this is one of the two firing triggers (the other is the
|
|
3139
|
+
# opportunistic fire on `cctally budget`); forward-only/fire-once means the
|
|
3140
|
+
# double-trigger never double-fires.
|
|
3141
|
+
try:
|
|
3142
|
+
maybe_record_codex_budget_milestone(saved)
|
|
3143
|
+
except Exception as exc:
|
|
3144
|
+
eprint(f"[codex-budget-milestone] unexpected error: {exc}")
|
|
3145
|
+
|
|
3146
|
+
# NEW: projected-pace alert firing (axis `projected`, #121/#135). Runs in
|
|
3147
|
+
# its OWN detect-and-arm AFTER the weekly/5h/budget/codex blocks; gated up
|
|
3148
|
+
# front on (alerts.enabled && alerts.projected_enabled) ||
|
|
3149
|
+
# (_budget_alerts_active && budget.projected_enabled — ANY Claude period,
|
|
3150
|
+
# #135) || (codex.alerts_enabled && codex.projected_enabled, #135) — all
|
|
3151
|
+
# toggles default OFF, so non-projected users pay only a cheap config read.
|
|
3152
|
+
# No only_metrics on the record path: every enabled leg runs. The Codex leg
|
|
3153
|
+
# relies on the codex-budget block above (:3129) having warmed the cache,
|
|
3154
|
+
# but is robust even if it short-circuited (skip_sync=False self-syncs, R5).
|
|
2877
3155
|
try:
|
|
2878
3156
|
maybe_record_projected_alert(saved)
|
|
2879
3157
|
except Exception as exc:
|
package/bin/_cctally_setup.py
CHANGED
|
@@ -998,11 +998,15 @@ def _legacy_stop_active_poller() -> str:
|
|
|
998
998
|
# Ownership probe: the PID file is at a predictable /tmp path that
|
|
999
999
|
# outlives the daemon on uncleanly exit, and macOS PIDs cycle in a
|
|
1000
1000
|
# narrow space — verify the live process is actually our legacy
|
|
1001
|
-
# poller before signaling.
|
|
1002
|
-
#
|
|
1001
|
+
# poller before signaling. `-o command=` emits the cmdline with no
|
|
1002
|
+
# header on both macOS BSD ps and Linux util-linux ps; `-ww` forces
|
|
1003
|
+
# UNLIMITED width so the cmdline is never truncated. Without it,
|
|
1004
|
+
# Linux util-linux ps clamps the column to ~80 chars (macOS BSD ps
|
|
1005
|
+
# does not), so a poller launched from a long path drops the
|
|
1006
|
+
# "usage-poller.py" token off the end → a false "stale-pid".
|
|
1003
1007
|
try:
|
|
1004
1008
|
probe = subprocess.run(
|
|
1005
|
-
["ps", "-p", str(pid), "-o", "command="],
|
|
1009
|
+
["ps", "-ww", "-p", str(pid), "-o", "command="],
|
|
1006
1010
|
capture_output=True, text=True, timeout=2.0,
|
|
1007
1011
|
)
|
|
1008
1012
|
except (OSError, subprocess.TimeoutExpired):
|
|
@@ -91,6 +91,14 @@ def _resolve_statusline_tz(cli_tz, cfg, warn_once):
|
|
|
91
91
|
tz_name = c._local_tz_name() or "UTC"
|
|
92
92
|
except Exception:
|
|
93
93
|
tz_name = "UTC"
|
|
94
|
+
elif tz_name and tz_name.lower() == "utc":
|
|
95
|
+
# Canonical "utc" (the value get_display_tz_pref / normalize_display_tz_value
|
|
96
|
+
# emit) -> the portable IANA key "UTC". macOS's case-insensitive
|
|
97
|
+
# filesystem resolves ZoneInfo("utc") to UTC, but Linux's case-sensitive
|
|
98
|
+
# /usr/share/zoneinfo raises ZoneInfoNotFoundError, which would emit a
|
|
99
|
+
# spurious "invalid timezone 'utc'" warning below. Mirrors
|
|
100
|
+
# resolve_display_tz, which maps canonical "utc" -> ZoneInfo("Etc/UTC").
|
|
101
|
+
tz_name = "UTC"
|
|
94
102
|
try:
|
|
95
103
|
ZoneInfo(tz_name)
|
|
96
104
|
except (ZoneInfoNotFoundError, Exception):
|
package/bin/_cctally_update.py
CHANGED
|
@@ -1890,13 +1890,13 @@ class _DashboardUpdateCheckThread(threading.Thread):
|
|
|
1890
1890
|
snapshot_ref: "_SnapshotRef | None" = None,
|
|
1891
1891
|
) -> None:
|
|
1892
1892
|
super().__init__(name="cctally-update-check")
|
|
1893
|
-
self.
|
|
1893
|
+
self._stop_event = stop_event
|
|
1894
1894
|
self._hub = hub
|
|
1895
1895
|
self._ref = snapshot_ref
|
|
1896
1896
|
|
|
1897
1897
|
def run(self) -> None:
|
|
1898
1898
|
c = _cctally()
|
|
1899
|
-
while not self.
|
|
1899
|
+
while not self._stop_event.is_set():
|
|
1900
1900
|
try:
|
|
1901
1901
|
# Self-heal runs every tick (every 30 min by default),
|
|
1902
1902
|
# NOT gated by `_is_update_check_due`'s 24h TTL. Catches
|
|
@@ -1938,7 +1938,7 @@ class _DashboardUpdateCheckThread(threading.Thread):
|
|
|
1938
1938
|
)
|
|
1939
1939
|
except Exception:
|
|
1940
1940
|
pass
|
|
1941
|
-
self.
|
|
1941
|
+
self._stop_event.wait(c.UPDATE_DASHBOARD_CHECK_POLL_S)
|
|
1942
1942
|
|
|
1943
1943
|
|
|
1944
1944
|
def cmd_update(args) -> int:
|