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.
@@ -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 _build_budget_status_inputs(*args, **kwargs):
333
- return sys.modules["cctally"]._build_budget_status_inputs(*args, **kwargs)
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(load_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 = _resolve_current_budget_window(conn, now_utc)
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 WHERE week_start_at = ?",
779
- (week_key,),
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. `alerted_at IS NULL` guard is write-once
811
- # defense-in-depth.
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(saved: dict[str, Any]) -> None:
1062
- """Projected-pace detect-and-arm (axis ``projected``, #121).
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`` and/or ``budget_usd``. Its OWN detect-and-arm
1066
- NOT folded into ``maybe_record_milestone`` (Section 1 / Codex P0-3)called
1067
- from ``cmd_record_usage`` in its own ``try`` after the weekly/5h/budget
1068
- blocks.
1222
+ band) for ``weekly_pct``, ``budget_usd`` (any Claude period #135) and/or
1223
+ ``codex_budget_usd`` (#135). Its OWN detect-and-armNOT 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``. Both
1073
- toggles default OFF (no surprise notifications on upgrade). When NEITHER is
1074
- on, returns after only a cheap config read — no projection math, no cost
1075
- work.
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
- The ``budget_usd`` leg reuses the SAME ``_build_budget_status_inputs`` +
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. The cache is already warm from the actual-budget axis's spend
1094
- SUM this same tick, so this is not a second aggregation pass; the pre-probe
1095
- additionally skips it entirely when all budget levels are latched.
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
- if not (weekly_on or budget_on):
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, metric="weekly_pct",
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 (reuses the tick's spend via the shared path) ─────
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
- window = _resolve_current_budget_window(conn, now_utc)
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, metric="budget_usd",
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
- # Avoids a redundant JSONL ingest pass here.
1172
- inputs = _build_budget_status_inputs(
1173
- conn, target_usd=target, now_utc=now_utc,
1174
- alert_thresholds=thresholds, skip_sync=True,
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 metric = ? AND threshold = ? "
1208
- " AND alerted_at IS NULL",
1209
- (now_utc_iso(), p["week_start_at"], p["metric"],
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: projected-pace alert firing (axis `projected`, #121). Runs in its
2873
- # OWN detect-and-arm AFTER the weekly/5h/budget blocks; gated up front on
2874
- # (alerts.enabled && alerts.projected_enabled) ||
2875
- # (_budget_alerts_active && budget.projected_enabled) both toggles
2876
- # default OFF, so non-projected users pay only a cheap config read.
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:
@@ -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. ps's `-o command=` emits the full cmdline
1002
- # with no header on both macOS BSD ps and Linux util-linux ps.
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):
@@ -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._stop = stop_event
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._stop.is_set():
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._stop.wait(c.UPDATE_DASHBOARD_CHECK_POLL_S)
1941
+ self._stop_event.wait(c.UPDATE_DASHBOARD_CHECK_POLL_S)
1942
1942
 
1943
1943
 
1944
1944
  def cmd_update(args) -> int: