cctally 1.6.3 → 1.7.1

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.
@@ -0,0 +1,2120 @@
1
+ """Record-usage / hook-tick hot-path subsystem for cctally.
2
+
3
+ Eager I/O sibling: bin/cctally loads this at startup. Holds the
4
+ runtime path that every Claude Code statusline tick and every CC
5
+ hook fires:
6
+
7
+ - ``cmd_record_usage`` — the statusline-driven entry point. Parses
8
+ ``--percent`` / ``--resets-at`` / ``--five-hour-*``, applies
9
+ ULP-noise sanitization at the ingress (``_normalize_percent``),
10
+ resolves the canonical 5h window key (Tier 1 blocks-table + Tier
11
+ 2 snapshots fallback), runs the mid-week reset-event detector,
12
+ applies the per-window 7d/5h monotonicity clamps, dedup-skips
13
+ no-op ticks (with a self-heal probe that re-fires the milestone
14
+ + 5h-block helpers when a prior process was killed between
15
+ ``insert_usage_snapshot`` and the helpers), inserts the snapshot
16
+ row, queues the milestone + 5h-block updates, and writes the
17
+ ``hwm-7d`` / ``hwm-5h`` files.
18
+ - ``cmd_hook_tick`` — the CC hook entry point. Reads CC's JSON
19
+ payload from stdin BEFORE fork (POSIX §2.9.3 makes ``cmd &``
20
+ blank stdin), forks to background so CC unblocks immediately,
21
+ detaches stdio to ``hook-tick.log``, runs ``sync_cache`` + a
22
+ throttled OAuth refresh under ``hook-tick.last-fetch.lock``, and
23
+ writes one log line. Normal mode returns 0 unconditionally
24
+ (hook discipline); ``--explain`` returns a decision-tree exit code.
25
+ - ``maybe_record_milestone`` — percent-crossing detector. Runs
26
+ ``cmd_sync_week`` to refresh cost-on-disk, computes cumulative +
27
+ marginal cost via ``_compute_cost_for_weekref`` for reset-affected
28
+ weeks or ``get_latest_cost_for_week`` otherwise, inserts a
29
+ ``percent_milestones`` row per crossed threshold inside a single
30
+ transaction, and queues ``_dispatch_alert_notification`` jobs
31
+ for thresholds configured in ``alerts.weekly_thresholds`` (set-
32
+ then-dispatch invariant, spec §3.2).
33
+ - ``maybe_update_five_hour_block`` — 5h block upsert + rollup-children
34
+ replace-all + 5h-% milestone detection. Resolves block_start_at
35
+ from prior row (or computes from ``five_hour_resets_at - 5h`` on
36
+ first observation), recomputes totals via ``_compute_block_totals``,
37
+ upserts the parent row with ON CONFLICT DO UPDATE, replaces
38
+ per-(block, model) and per-(block, project) children, fires the
39
+ 5h-% alert dispatch, and runs the cross-reset cross-flag JOIN
40
+ sweep — all inside one BEGIN.
41
+ - ``_compute_block_totals`` — sums tokens + cost over
42
+ [block_start_at, range_end] from ``session_entries``, with
43
+ per-model and per-project breakdowns. Routes through
44
+ ``get_claude_session_entries`` (cache-first / lock-contention
45
+ fallback / direct-JSONL fallback) so the rollup children inherit
46
+ the cache subsystem's correctness envelope.
47
+ - ``insert_usage_snapshot`` / ``_saved_dict_from_usage_row`` —
48
+ ``weekly_usage_snapshots`` INSERT and its inverse (rebuild the
49
+ ``saved`` dict from an existing row for the dedup self-heal
50
+ path).
51
+ - ``DerivedWeekWindow`` + ``_derive_week_from_payload`` +
52
+ ``_coerce_payload_captured_at`` — payload-to-week-bucket
53
+ resolution shared by ``insert_usage_snapshot``. Anchors the
54
+ bucket-key date on the canonical UTC ISO (regression: Israel host
55
+ briefly running with TZ=America/Los_Angeles spawned ghost
56
+ ``week_start_date`` rows; see ``tests/test_derive_week_utc_anchor.py``).
57
+ - ``_normalize_percent`` — single chokepoint that flushes IEEE 754
58
+ ULP noise out of ingress percent floats. Applied at every
59
+ cmd_record_usage ingress site (CLI args, hook-tick OAuth refresh,
60
+ refresh-usage OAuth fetch). 10dp round is well below any
61
+ meaningful consumer precision but above IEEE 754 ULP scale near
62
+ 100.
63
+ - ``_hook_tick_*`` helpers — log/throttle file primitives,
64
+ stdin-read, session-id short, log-line formatter.
65
+ - ``_safe_float`` / ``_validate_date_optional`` — payload-validation
66
+ helpers consumed only by ``insert_usage_snapshot``.
67
+ - ``_logged_window_key_coerce_failure`` — one-shot module-level
68
+ guard so a misbehaving caller passing a non-int ``fiveHourWindowKey``
69
+ doesn't spam stderr on every insert.
70
+
71
+ What stays in bin/cctally:
72
+ - Path constants ``APP_DIR``, ``HOOK_TICK_LOG_DIR``,
73
+ ``HOOK_TICK_LOG_PATH``, ``HOOK_TICK_LOG_ROTATED_PATH``,
74
+ ``HOOK_TICK_LOG_ROTATE_BYTES``, ``HOOK_TICK_THROTTLE_PATH``,
75
+ ``HOOK_TICK_THROTTLE_LOCK_PATH``,
76
+ ``HOOK_TICK_DEFAULT_THROTTLE_SECONDS`` — referenced from the
77
+ moved bodies via the ``c = _cctally()`` call-time accessor pattern
78
+ (spec §5.5, same as ``bin/_cctally_cache.py``). The accessor
79
+ resolves ``sys.modules['cctally'].X`` on every call, so the
80
+ conftest ``redirect_paths`` ``setitem(ns, "APP_DIR", tmp)``
81
+ propagates transparently — no sibling-side patches needed.
82
+ - Alerts-config surface (``_AlertsConfigError``, ``_get_alerts_config``,
83
+ ``_warn_alerts_bad_config_once``, ``_ALERTS_BAD_CONFIG_WARNED``)
84
+ — stays in bin/cctally per task brief; consumed by dashboard
85
+ and other surfaces beyond record/hook-tick. Routed through
86
+ module-level shims here so the moved bodies keep bare-name
87
+ call shape.
88
+ - ``_dispatch_alert_notification`` — already lives in
89
+ ``bin/_cctally_alerts.py`` (Phase B). Accessed via shim that
90
+ resolves through ``sys.modules['cctally']._dispatch_alert_notification``
91
+ so the eager re-export in bin/cctally propagates the same
92
+ function object both sides see.
93
+ - ``cmd_sync_week`` (Phase B sibling), ``cmd_refresh_usage`` /
94
+ ``_hook_tick_oauth_refresh`` / ``_hook_tick_make_mock_refresh``
95
+ / ``_get_oauth_usage_config`` / ``OauthUsageConfigError``
96
+ (Phase C ``_cctally_refresh.py``) — consumed from this sibling
97
+ via the same bare-name shim or ``c.X`` pattern.
98
+ - ``open_db``, ``open_cache_db``, ``sync_cache``, ``parse_iso_datetime``,
99
+ ``now_utc_iso``, ``load_config``, ``get_week_start_name``,
100
+ ``compute_week_bounds``, ``parse_date_str``,
101
+ ``_canonicalize_optional_iso``, ``_canonical_5h_window_key``,
102
+ ``_floor_to_hour``, ``_get_canonical_boundary_for_date``,
103
+ ``_apply_reset_events_to_weekrefs``, ``_week_ref_has_reset_event``,
104
+ ``_compute_cost_for_weekref``, ``get_latest_cost_for_week``,
105
+ ``get_max_milestone_for_week``, ``get_milestone_cost_for_week``,
106
+ ``insert_percent_milestone``, ``make_week_ref``,
107
+ ``_calculate_entry_cost``, ``_resolve_primary_model_for_block``,
108
+ ``_resolve_display_tz_obj``, ``_build_alert_payload_weekly``,
109
+ ``_build_alert_payload_five_hour``, ``eprint``,
110
+ ``get_claude_session_entries``, ``_FIVE_HOUR_JITTER_FLOOR_SECONDS``,
111
+ ``_RESET_PCT_DROP_THRESHOLD`` — boundary helpers, already-extracted
112
+ subsystems, or constants that belong in bin/cctally. All accessed
113
+ via the same shim/``c.X`` pattern.
114
+
115
+ §5.6 audit on this extraction's monkeypatch surface:
116
+ - ``cmd_record_usage`` — patched via ``monkeypatch.setitem(ns, …)``
117
+ by 5 test files (``test_hook_tick_rate_limit.py``,
118
+ ``test_refresh_usage_inproc.py``, ``test_refresh_usage_cmd.py``,
119
+ callers via ``ns["cmd_record_usage"](...)``). Re-export in
120
+ bin/cctally propagates patches; the moved body never reaches
121
+ for itself.
122
+ - ``_hook_tick_oauth_refresh`` — patched via
123
+ ``monkeypatch.setitem(ns, …)`` by ``test_hook_tick_rate_limit.py``.
124
+ Moved ``cmd_hook_tick`` uses a module-level shim that resolves
125
+ via ``sys.modules['cctally']`` at call time; the
126
+ ``globals()["_hook_tick_oauth_refresh"] = …`` mock-injection
127
+ branch is rewritten to mutate ``sys.modules['cctally']`` so
128
+ ``--mock-oauth-response`` still propagates.
129
+ - ``_normalize_percent`` — test reads via
130
+ ``ns["_normalize_percent"]`` (``test_record_usage_precision.py``).
131
+ Re-export in bin/cctally propagates the same function object.
132
+ - ``_derive_week_from_payload`` — test reads via
133
+ ``ns["_derive_week_from_payload"]``
134
+ (``test_derive_week_utc_anchor.py``). Re-export in bin/cctally
135
+ propagates.
136
+
137
+ Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md
138
+ """
139
+ from __future__ import annotations
140
+
141
+ import argparse
142
+ import datetime as dt
143
+ import fcntl
144
+ import json
145
+ import math
146
+ import os
147
+ import sqlite3
148
+ import sys
149
+ import time
150
+ from dataclasses import dataclass
151
+ from typing import Any
152
+
153
+
154
+ def _cctally():
155
+ """Resolve the current ``cctally`` module at call-time (spec §5.5)."""
156
+ return sys.modules["cctally"]
157
+
158
+
159
+ # Module-level back-ref shims. Each shim resolves
160
+ # ``sys.modules['cctally'].X`` at CALL TIME (not bind time), so
161
+ # monkeypatches on cctally's namespace propagate into the moved code
162
+ # unchanged. Mirrors the precedent established in
163
+ # ``bin/_cctally_cache.py`` and ``bin/_cctally_db.py``.
164
+ def eprint(*args, **kwargs):
165
+ return sys.modules["cctally"].eprint(*args, **kwargs)
166
+
167
+
168
+ def now_utc_iso(*args, **kwargs):
169
+ return sys.modules["cctally"].now_utc_iso(*args, **kwargs)
170
+
171
+
172
+ def parse_iso_datetime(*args, **kwargs):
173
+ return sys.modules["cctally"].parse_iso_datetime(*args, **kwargs)
174
+
175
+
176
+ def open_db(*args, **kwargs):
177
+ return sys.modules["cctally"].open_db(*args, **kwargs)
178
+
179
+
180
+ def open_cache_db(*args, **kwargs):
181
+ return sys.modules["cctally"].open_cache_db(*args, **kwargs)
182
+
183
+
184
+ def sync_cache(*args, **kwargs):
185
+ return sys.modules["cctally"].sync_cache(*args, **kwargs)
186
+
187
+
188
+ def load_config(*args, **kwargs):
189
+ return sys.modules["cctally"].load_config(*args, **kwargs)
190
+
191
+
192
+ def get_week_start_name(*args, **kwargs):
193
+ return sys.modules["cctally"].get_week_start_name(*args, **kwargs)
194
+
195
+
196
+ def compute_week_bounds(*args, **kwargs):
197
+ return sys.modules["cctally"].compute_week_bounds(*args, **kwargs)
198
+
199
+
200
+ def parse_date_str(*args, **kwargs):
201
+ return sys.modules["cctally"].parse_date_str(*args, **kwargs)
202
+
203
+
204
+ def _canonicalize_optional_iso(*args, **kwargs):
205
+ return sys.modules["cctally"]._canonicalize_optional_iso(*args, **kwargs)
206
+
207
+
208
+ def _canonical_5h_window_key(*args, **kwargs):
209
+ return sys.modules["cctally"]._canonical_5h_window_key(*args, **kwargs)
210
+
211
+
212
+ def _floor_to_hour(*args, **kwargs):
213
+ return sys.modules["cctally"]._floor_to_hour(*args, **kwargs)
214
+
215
+
216
+ def _get_canonical_boundary_for_date(*args, **kwargs):
217
+ return sys.modules["cctally"]._get_canonical_boundary_for_date(*args, **kwargs)
218
+
219
+
220
+ def _apply_reset_events_to_weekrefs(*args, **kwargs):
221
+ return sys.modules["cctally"]._apply_reset_events_to_weekrefs(*args, **kwargs)
222
+
223
+
224
+ def _week_ref_has_reset_event(*args, **kwargs):
225
+ return sys.modules["cctally"]._week_ref_has_reset_event(*args, **kwargs)
226
+
227
+
228
+ def _compute_cost_for_weekref(*args, **kwargs):
229
+ return sys.modules["cctally"]._compute_cost_for_weekref(*args, **kwargs)
230
+
231
+
232
+ def get_latest_cost_for_week(*args, **kwargs):
233
+ return sys.modules["cctally"].get_latest_cost_for_week(*args, **kwargs)
234
+
235
+
236
+ def get_max_milestone_for_week(*args, **kwargs):
237
+ return sys.modules["cctally"].get_max_milestone_for_week(*args, **kwargs)
238
+
239
+
240
+ def get_milestone_cost_for_week(*args, **kwargs):
241
+ return sys.modules["cctally"].get_milestone_cost_for_week(*args, **kwargs)
242
+
243
+
244
+ def insert_percent_milestone(*args, **kwargs):
245
+ return sys.modules["cctally"].insert_percent_milestone(*args, **kwargs)
246
+
247
+
248
+ def make_week_ref(*args, **kwargs):
249
+ return sys.modules["cctally"].make_week_ref(*args, **kwargs)
250
+
251
+
252
+ def cmd_sync_week(*args, **kwargs):
253
+ return sys.modules["cctally"].cmd_sync_week(*args, **kwargs)
254
+
255
+
256
+ def _calculate_entry_cost(*args, **kwargs):
257
+ return sys.modules["cctally"]._calculate_entry_cost(*args, **kwargs)
258
+
259
+
260
+ def get_claude_session_entries(*args, **kwargs):
261
+ return sys.modules["cctally"].get_claude_session_entries(*args, **kwargs)
262
+
263
+
264
+ def _resolve_primary_model_for_block(*args, **kwargs):
265
+ return sys.modules["cctally"]._resolve_primary_model_for_block(*args, **kwargs)
266
+
267
+
268
+ def _resolve_display_tz_obj(*args, **kwargs):
269
+ return sys.modules["cctally"]._resolve_display_tz_obj(*args, **kwargs)
270
+
271
+
272
+ def _build_alert_payload_weekly(*args, **kwargs):
273
+ return sys.modules["cctally"]._build_alert_payload_weekly(*args, **kwargs)
274
+
275
+
276
+ def _build_alert_payload_five_hour(*args, **kwargs):
277
+ return sys.modules["cctally"]._build_alert_payload_five_hour(*args, **kwargs)
278
+
279
+
280
+ def _dispatch_alert_notification(*args, **kwargs):
281
+ return sys.modules["cctally"]._dispatch_alert_notification(*args, **kwargs)
282
+
283
+
284
+ def _get_alerts_config(*args, **kwargs):
285
+ return sys.modules["cctally"]._get_alerts_config(*args, **kwargs)
286
+
287
+
288
+ def _warn_alerts_bad_config_once(*args, **kwargs):
289
+ return sys.modules["cctally"]._warn_alerts_bad_config_once(*args, **kwargs)
290
+
291
+
292
+ def _get_oauth_usage_config(*args, **kwargs):
293
+ return sys.modules["cctally"]._get_oauth_usage_config(*args, **kwargs)
294
+
295
+
296
+ def _hook_tick_oauth_refresh(*args, **kwargs):
297
+ """Shim for ``_hook_tick_oauth_refresh``.
298
+
299
+ Resolves via ``sys.modules['cctally']`` at call time so
300
+ ``monkeypatch.setitem(ns, "_hook_tick_oauth_refresh", boom)``
301
+ propagates. The ``--mock-oauth-response`` flag below rewrites
302
+ ``sys.modules['cctally']._hook_tick_oauth_refresh`` so this
303
+ shim picks up the mock on the very next call.
304
+ """
305
+ return sys.modules["cctally"]._hook_tick_oauth_refresh(*args, **kwargs)
306
+
307
+
308
+ def _hook_tick_make_mock_refresh(*args, **kwargs):
309
+ return sys.modules["cctally"]._hook_tick_make_mock_refresh(*args, **kwargs)
310
+
311
+
312
+ # Exception classes raised by callees that stay in bin/cctally
313
+ # (``_AlertsConfigError``) or in another sibling (``OauthUsageConfigError``
314
+ # in ``bin/_cctally_refresh.py``) are caught here via
315
+ # ``except sys.modules['cctally'].SomeError`` — Python evaluates the
316
+ # ``except`` expression at except-time, so each catch resolves to the
317
+ # live class object that the raiser also reaches. See call sites in
318
+ # ``maybe_record_milestone``, ``maybe_update_five_hour_block``, and
319
+ # ``cmd_hook_tick`` for the three rewrites.
320
+
321
+
322
+ # Constants referenced by the moved bodies. Defined here (rather than
323
+ # `c.X`-routed) because they're pure literals — no monkeypatch surface
324
+ # and no dependency on cctally's module instance.
325
+ _PERCENT_NORMALIZE_DECIMALS = 10
326
+
327
+
328
+ # One-shot guard so a misbehaving caller passing a non-int
329
+ # fiveHourWindowKey doesn't spam the log on every insert. Set on first
330
+ # loud-skip in insert_usage_snapshot. Moved into this sibling alongside
331
+ # insert_usage_snapshot — the `global` statement inside that function
332
+ # now binds to THIS module's namespace, which is correct (cctally re-
333
+ # exports the function via the eager-load block, but the `global` write
334
+ # stays in the sibling's __dict__ and the per-process one-shot semantics
335
+ # are preserved across both call routes).
336
+ _logged_window_key_coerce_failure = False
337
+
338
+
339
+ # === BEGIN MOVED REGIONS ===
340
+ # Path constants (APP_DIR, HOOK_TICK_*) are accessed via the
341
+ # `c = _cctally()` call-time accessor inside each function that needs
342
+ # them — so ``monkeypatch.setitem(ns, "APP_DIR", tmp)`` in tests
343
+ # resolves on every read (no stale module-level binding).
344
+ #
345
+ # Constants pulled from cctally at call time:
346
+ # c._FIVE_HOUR_JITTER_FLOOR_SECONDS — _lib_five_hour.* re-export
347
+ # c._RESET_PCT_DROP_THRESHOLD — bin/cctally module-level constant
348
+ # c.HOOK_TICK_LOG_DIR / _PATH / _ROTATED_PATH / _ROTATE_BYTES
349
+ # c.HOOK_TICK_THROTTLE_PATH / _LOCK_PATH
350
+ # c.HOOK_TICK_DEFAULT_THROTTLE_SECONDS
351
+ # c.APP_DIR
352
+
353
+
354
+ def _normalize_percent(value: "float | int | None") -> "float | None":
355
+ """Flush IEEE 754 ULP noise out of an ingress percent value.
356
+
357
+ Single chokepoint applied at every site where a raw percent enters
358
+ cctally's runtime path (OAuth fetch, hook-tick OAuth refresh, and
359
+ the cmd_record_usage CLI ingress). Downstream consumers — HWM
360
+ files, ``weekly_usage_snapshots.{weekly,five_hour}_percent`` REAL
361
+ columns, ``five_hour_blocks.final_five_hour_percent``, milestone
362
+ crossing values, and the SSE envelope's ``used_percent`` field —
363
+ all read the cleaned value, so a single round here stops
364
+ ``5h=7.000000000000001`` style strings from reaching any log or
365
+ serialized surface.
366
+
367
+ ``None`` is the canonical absent-percent sentinel; preserve it
368
+ unchanged so the optional-5h branches stay simple.
369
+ """
370
+ if value is None:
371
+ return None
372
+ return round(float(value), _PERCENT_NORMALIZE_DECIMALS)
373
+
374
+
375
+ def maybe_record_milestone(
376
+ saved: dict[str, Any],
377
+ ) -> None:
378
+ """Check if a new integer percent threshold was crossed, and if so,
379
+ fetch cost and record the milestone. Errors are logged, not raised."""
380
+ weekly_percent = saved.get("weeklyPercent")
381
+ if weekly_percent is None or weekly_percent < 1:
382
+ return
383
+
384
+ # Snap near-integer values up before flooring: the status-line API returns
385
+ # N% as 0.N * 100, which in IEEE 754 can land one ULP below N.0 (e.g.
386
+ # 0.58 * 100 == 57.99999999999999). A bare math.floor() then returns N-1
387
+ # and the N-threshold milestone is never recorded.
388
+ current_floor = math.floor(weekly_percent + 1e-9)
389
+ if current_floor < 1:
390
+ return
391
+
392
+ week_start_date = saved["weekStartDate"]
393
+ week_end_date = saved["weekEndDate"]
394
+ week_start_at = saved.get("weekStartAt")
395
+ week_end_at = saved.get("weekEndAt")
396
+ usage_snapshot_id = saved["id"]
397
+ five_hour_percent = saved.get("fiveHourPercent")
398
+
399
+ conn = open_db()
400
+ try:
401
+ max_existing = get_max_milestone_for_week(conn, week_start_date)
402
+ if max_existing is not None and current_floor <= max_existing:
403
+ return
404
+
405
+ # Threshold crossed — sync cost before recording so the milestone
406
+ # captures up-to-date cumulative cost, not a stale snapshot.
407
+ try:
408
+ sync_ns = argparse.Namespace(
409
+ week_start=None,
410
+ week_end=None,
411
+ week_start_name=None,
412
+ mode="auto",
413
+ offline=False,
414
+ project=None,
415
+ json=False,
416
+ quiet=True,
417
+ )
418
+ cmd_sync_week(sync_ns)
419
+ except Exception as exc:
420
+ eprint(f"[milestone] cost sync failed, using latest available: {exc}")
421
+
422
+ week_start = dt.date.fromisoformat(week_start_date)
423
+ week_end = dt.date.fromisoformat(week_end_date)
424
+ week_ref = make_week_ref(
425
+ week_start_date=week_start_date,
426
+ week_end_date=week_end_date,
427
+ week_start_at=week_start_at,
428
+ week_end_at=week_end_at,
429
+ )
430
+
431
+ # For reset-affected weeks, the cached weekly_cost_snapshots row
432
+ # covers the API-derived range (which for a post-reset week
433
+ # backdates into the old window). Live-compute over the effective
434
+ # range so the milestone captures cost from the reset moment
435
+ # forward, not from the phantom backdated start.
436
+ effective_ref = week_ref
437
+ adjusted = _apply_reset_events_to_weekrefs(conn, [week_ref])
438
+ if adjusted:
439
+ effective_ref = adjusted[0]
440
+
441
+ if _week_ref_has_reset_event(conn, effective_ref):
442
+ live_cost = _compute_cost_for_weekref(effective_ref)
443
+ if live_cost is None:
444
+ eprint("[milestone] could not compute effective-range cost, skipping")
445
+ return
446
+ cumulative_cost = live_cost
447
+ cost_snapshot_id = 0 # no snapshot row to anchor against
448
+ else:
449
+ latest_cost = get_latest_cost_for_week(conn, week_ref)
450
+ if latest_cost is None:
451
+ eprint("[milestone] no cost snapshot yet for this week, skipping")
452
+ return
453
+ cumulative_cost = float(latest_cost["cost_usd"])
454
+ cost_snapshot_id = int(latest_cost["id"])
455
+
456
+ # Determine which thresholds to record
457
+ start_threshold = (max_existing + 1) if max_existing is not None else current_floor
458
+
459
+ # Hoist `_get_alerts_config(load_config())` above the per-pct loop:
460
+ # in the catch-up case (multi-percent jump on first observation) the
461
+ # loop iterates N times and the config never changes mid-loop. One
462
+ # read serves all iterations.
463
+ # `load_config()` is safe outside the writer lock — atomic-rename
464
+ # guarantees readers see whole bytes (CLAUDE.md gotcha).
465
+ # `_ALERTS_BAD_CONFIG_WARNED` (module-level, M3) rate-limits the
466
+ # warning to once per process; both axis paths share the flag since
467
+ # the underlying problem is config-wide, not axis-specific.
468
+ try:
469
+ alerts_cfg: "dict | None" = _get_alerts_config(load_config())
470
+ except sys.modules["cctally"]._AlertsConfigError as exc:
471
+ _warn_alerts_bad_config_once(exc)
472
+ alerts_cfg = None
473
+
474
+ # Collect dispatch jobs across the per-pct loop and fire AFTER the
475
+ # single commit below. Mirrors the 5h path's pending_alerts pattern
476
+ # (set-then-dispatch + atomic INSERT/UPDATE, spec §3.2). Without
477
+ # this, `insert_percent_milestone`'s prior internal commit would
478
+ # split INSERT and the alerted_at UPDATE across two transactions —
479
+ # a crash in the gap left `alerted_at` NULL forever, since the
480
+ # next call's INSERT OR IGNORE returns rowcount==0 and the
481
+ # `if inserted == 1` dispatch guard skips re-firing.
482
+ pending_alerts: list[dict[str, Any]] = []
483
+ for pct in range(start_threshold, current_floor + 1):
484
+ if pct == start_threshold and max_existing is not None:
485
+ prev_cost = get_milestone_cost_for_week(conn, week_start_date, max_existing)
486
+ marginal = (cumulative_cost - prev_cost) if prev_cost is not None else None
487
+ else:
488
+ marginal = None
489
+ inserted = insert_percent_milestone(
490
+ conn,
491
+ week_start_date=week_start_date,
492
+ week_end_date=week_end_date,
493
+ week_start_at=week_start_at,
494
+ week_end_at=week_end_at,
495
+ percent_threshold=pct,
496
+ cumulative_cost_usd=cumulative_cost,
497
+ marginal_cost_usd=marginal,
498
+ usage_snapshot_id=usage_snapshot_id,
499
+ cost_snapshot_id=cost_snapshot_id,
500
+ five_hour_percent_at_crossing=five_hour_percent,
501
+ commit=False,
502
+ )
503
+ # ── Threshold-actions dispatch (set-then-dispatch, spec §3.2) ──
504
+ # Only the genuine-new-crossing winner (rowcount==1) reaches this
505
+ # path; concurrent record-usage instances that race on the same
506
+ # (week_start_date, percent_threshold) get rowcount==0 from the
507
+ # INSERT OR IGNORE and skip dispatch entirely. The
508
+ # `alerted_at IS NULL` guard on the UPDATE is defense-in-depth:
509
+ # write-once even if two writers somehow both think they won.
510
+ if inserted == 1:
511
+ if (
512
+ alerts_cfg is not None
513
+ and alerts_cfg["enabled"]
514
+ and pct in alerts_cfg["weekly_thresholds"]
515
+ ):
516
+ crossed_at = now_utc_iso()
517
+ # set-then-dispatch: alerted_at lands on the row BEFORE
518
+ # the osascript Popen, so a dismissed-after-spawn
519
+ # notification still surfaces in the dashboard alerts
520
+ # envelope (T5). UPDATE shares the transaction with
521
+ # the preceding INSERT (commit=False above) so a
522
+ # crash between them is impossible.
523
+ conn.execute(
524
+ "UPDATE percent_milestones SET alerted_at = ? "
525
+ "WHERE week_start_date = ? AND percent_threshold = ? "
526
+ "AND alerted_at IS NULL",
527
+ (crossed_at, week_start_date, pct),
528
+ )
529
+ # Cheap re-read for payload context (cumulative_cost_usd
530
+ # reflects the value persisted on insert, immune to any
531
+ # subsequent recompute drift). SELECT inside the open
532
+ # transaction is fine; values reflect post-INSERT state.
533
+ row = conn.execute(
534
+ "SELECT cumulative_cost_usd FROM percent_milestones "
535
+ "WHERE week_start_date = ? AND percent_threshold = ?",
536
+ (week_start_date, pct),
537
+ ).fetchone()
538
+ if row is not None:
539
+ cum = float(row["cumulative_cost_usd"])
540
+ # $/1% rough trend metric: cumulative / threshold.
541
+ dpp = (cum / pct) if pct else None
542
+ payload = _build_alert_payload_weekly(
543
+ threshold=pct,
544
+ crossed_at_utc=crossed_at,
545
+ week_start_date=week_start_date,
546
+ cumulative_cost_usd=cum,
547
+ dollars_per_percent=dpp,
548
+ )
549
+ pending_alerts.append(payload)
550
+ # Single commit after the loop durably writes every milestone row
551
+ # AND its alerted_at marker together.
552
+ conn.commit()
553
+ # Dispatch deferred to AFTER commit (matches 5h path). Per-payload
554
+ # exception logged so a bad-payload alert can't suppress healthy ones.
555
+ # Production caller ignores _dispatch_alert_notification's return
556
+ # value (spec §6.4).
557
+ for payload in pending_alerts:
558
+ try:
559
+ _dispatch_alert_notification(payload, mode="real")
560
+ except Exception as dispatch_exc:
561
+ eprint(f"[alerts] dispatch failed: {dispatch_exc}")
562
+ except Exception as exc:
563
+ eprint(f"[milestone] error recording milestone: {exc}")
564
+ finally:
565
+ conn.close()
566
+
567
+
568
+ def _compute_block_totals(
569
+ block_start_at: dt.datetime,
570
+ range_end: dt.datetime,
571
+ *,
572
+ skip_sync: bool = False,
573
+ ) -> dict[str, Any]:
574
+ """Sum tokens + cost over [block_start_at, range_end] from session_entries,
575
+ plus per-model and per-project breakdowns in the same walk.
576
+
577
+ Used by the live write path (maybe_update_five_hour_block) and the
578
+ historical backfill (_backfill_five_hour_blocks /
579
+ _backfill_five_hour_block_models / _backfill_five_hour_block_projects).
580
+
581
+ Routes through get_claude_session_entries (rather than the parent
582
+ get_entries which returns UsageEntry without project_path) — same
583
+ cache-first / lock-contention / direct-JSONL fallback chain.
584
+
585
+ Returns a dict with:
586
+ input_tokens, output_tokens, cache_create_tokens, cache_read_tokens (int)
587
+ cost_usd (float)
588
+ by_model: dict[model_name -> {input_tokens, output_tokens,
589
+ cache_create_tokens, cache_read_tokens,
590
+ cost_usd, entry_count}]
591
+ by_project: dict[project_path_or_'(unknown)' -> same shape]
592
+ """
593
+ totals: dict[str, Any] = {
594
+ "input_tokens": 0,
595
+ "output_tokens": 0,
596
+ "cache_create_tokens": 0,
597
+ "cache_read_tokens": 0,
598
+ "cost_usd": 0.0,
599
+ "by_model": {},
600
+ "by_project": {},
601
+ }
602
+ for entry in get_claude_session_entries(
603
+ block_start_at, range_end, skip_sync=skip_sync,
604
+ ):
605
+ usage = {
606
+ "input_tokens": entry.input_tokens,
607
+ "output_tokens": entry.output_tokens,
608
+ "cache_creation_input_tokens": entry.cache_creation_tokens,
609
+ "cache_read_input_tokens": entry.cache_read_tokens,
610
+ }
611
+ cost = _calculate_entry_cost(
612
+ entry.model, usage, mode="auto", cost_usd=entry.cost_usd,
613
+ )
614
+
615
+ totals["input_tokens"] += entry.input_tokens
616
+ totals["output_tokens"] += entry.output_tokens
617
+ totals["cache_create_tokens"] += entry.cache_creation_tokens
618
+ totals["cache_read_tokens"] += entry.cache_read_tokens
619
+ totals["cost_usd"] += cost
620
+
621
+ # Bucket by model and by project_path. NULL project_path → sentinel
622
+ # so reconcile invariant SUM(child.cost) == parent.total holds.
623
+ # Note: the JSONL-fallback path (_direct_parse_claude_session_entries)
624
+ # always populates project_path = cwd (never NULL); '(unknown)' only
625
+ # appears on the cache-backed path during the brief session_files
626
+ # lazy-backfill window.
627
+ for key, bucket_dict in (
628
+ (entry.model, totals["by_model"]),
629
+ (entry.project_path or "(unknown)", totals["by_project"]),
630
+ ):
631
+ b = bucket_dict.setdefault(
632
+ key,
633
+ {
634
+ "input_tokens": 0,
635
+ "output_tokens": 0,
636
+ "cache_create_tokens": 0,
637
+ "cache_read_tokens": 0,
638
+ "cost_usd": 0.0,
639
+ "entry_count": 0,
640
+ },
641
+ )
642
+ b["input_tokens"] += entry.input_tokens
643
+ b["output_tokens"] += entry.output_tokens
644
+ b["cache_create_tokens"] += entry.cache_creation_tokens
645
+ b["cache_read_tokens"] += entry.cache_read_tokens
646
+ b["cost_usd"] += cost
647
+ b["entry_count"] += 1
648
+ return totals
649
+
650
+
651
+ def maybe_update_five_hour_block(saved: dict[str, Any]) -> None:
652
+ """Upsert the current 5h block in five_hour_blocks; close strictly
653
+ older open blocks; sweep naturally-expired blocks; flag blocks
654
+ spanning a recorded mid-week 7d-reset.
655
+
656
+ Errors are logged and swallowed — record-usage must not regress
657
+ because of this helper, same posture as maybe_record_milestone.
658
+ """
659
+ five_hour_percent = saved.get("fiveHourPercent")
660
+ five_hour_resets_at = saved.get("fiveHourResetsAt")
661
+ five_hour_window_key = saved.get("fiveHourWindowKey")
662
+ if (
663
+ five_hour_percent is None
664
+ or five_hour_resets_at is None
665
+ or five_hour_window_key is None
666
+ ):
667
+ return # no canonical 5h anchor — nothing to record
668
+
669
+ captured_at = saved["capturedAt"]
670
+ weekly_percent = saved.get("weeklyPercent")
671
+ snapshot_id = saved["id"]
672
+
673
+ # Note: this is the 4th open_db() invocation per record-usage call
674
+ # (after cmd_record_usage's prior-state read, insert_usage_snapshot,
675
+ # and maybe_record_milestone). Each open re-runs the inline schema
676
+ # migrations and the empty-table check that gates _backfill_five_hour_blocks.
677
+ # The backfill itself only runs once per process (the gate fires only when
678
+ # five_hour_blocks is empty), so the cost is benign — but the count is
679
+ # surprising. If any future helper grows expensive open_db() side effects,
680
+ # consolidate by passing the connection through rather than reopening.
681
+ conn = open_db()
682
+ try:
683
+ # Step 3 (per spec §3.2): read prior state including immutable
684
+ # fields we'll re-use. Re-deriving block_start_at from saved.
685
+ # fiveHourResetsAt would reintroduce the seconds-level Anthropic
686
+ # ISO jitter that five_hour_window_key was designed to collapse.
687
+ prior = conn.execute(
688
+ """
689
+ SELECT id AS prior_block_id,
690
+ block_start_at AS block_start_at
691
+ FROM five_hour_blocks
692
+ WHERE five_hour_window_key = ?
693
+ """,
694
+ (int(five_hour_window_key),),
695
+ ).fetchone()
696
+
697
+ if prior is None:
698
+ # First observation of this window. Compute block_start_at
699
+ # from the canonical resets timestamp.
700
+ try:
701
+ resets_dt = parse_iso_datetime(
702
+ five_hour_resets_at, "five_hour_resets_at",
703
+ )
704
+ except ValueError as exc:
705
+ eprint(f"[5h-block] bad resets_at, skipping: {exc}")
706
+ return
707
+ block_start_dt = resets_dt - dt.timedelta(hours=5)
708
+ block_start_at = block_start_dt.isoformat(timespec="seconds")
709
+ else:
710
+ block_start_at = prior["block_start_at"]
711
+ block_start_dt = parse_iso_datetime(
712
+ block_start_at, "five_hour_blocks.block_start_at",
713
+ )
714
+
715
+ # Step 6 (totals) — done outside the transaction so the
716
+ # cache.db read doesn't hold the stats.db write lock open.
717
+ captured_at_dt = parse_iso_datetime(captured_at, "capturedAt")
718
+ totals = _compute_block_totals(block_start_dt, captured_at_dt)
719
+
720
+ # Hoist alerts config above BEGIN (M1 + M2): single read serves
721
+ # all per-pct iterations in the catch-up case, AND keeps the
722
+ # filesystem read out of the transaction window so the stats.db
723
+ # write lock isn't held across config.json I/O.
724
+ # `load_config()` is safe outside the writer lock — atomic-rename
725
+ # guarantees readers see whole bytes (CLAUDE.md gotcha).
726
+ # `_ALERTS_BAD_CONFIG_WARNED` (module-level, M3) rate-limits the
727
+ # warning to once per process; both axis paths share the flag since
728
+ # the underlying problem is config-wide, not axis-specific.
729
+ cfg_for_alerts = load_config()
730
+ try:
731
+ alerts_cfg: "dict | None" = _get_alerts_config(cfg_for_alerts)
732
+ except sys.modules["cctally"]._AlertsConfigError as exc:
733
+ _warn_alerts_bad_config_once(exc)
734
+ alerts_cfg = None
735
+ # Resolve display.tz once (shares the cfg load above). Threaded
736
+ # into _dispatch_alert_notification so the macOS notification
737
+ # subtitle (block-start time) matches the dashboard / TUI render
738
+ # rather than falling back to host-local via tz=None.
739
+ display_tz_for_alerts = _resolve_display_tz_obj(cfg_for_alerts)
740
+
741
+ # Collect dispatch jobs while inside BEGIN (set-then-dispatch:
742
+ # alerted_at UPDATE stays inside the transaction per spec §3.2)
743
+ # but DEFER `_dispatch_alert_notification` until AFTER the outer
744
+ # commit (I1: prevents the inner Popen-time conn.commit() from
745
+ # ending the surrounding BEGIN mid-sequence and breaking the
746
+ # close-older + upsert + cross-flag atomicity envelope).
747
+ pending_alerts: list[dict[str, Any]] = []
748
+
749
+ # Steps 4-5 + 7: transaction wraps close-older + upsert so a
750
+ # mid-sequence failure doesn't leave the prior block closed
751
+ # without the current block opened/updated.
752
+ now_iso = now_utc_iso()
753
+ conn.execute("BEGIN")
754
+ try:
755
+ # Step 5: close any STRICTLY OLDER open block. `<` not `!=`
756
+ # — record-usage runs in parallel via background hook-tick &
757
+ # detach + status-line ticks; an older invocation completing
758
+ # after a newer one would close the now-current block under
759
+ # `!=`. With `<`, an older invocation only closes still-older
760
+ # blocks. window_key is a 10-min-floored monotonic epoch.
761
+ conn.execute(
762
+ """
763
+ UPDATE five_hour_blocks
764
+ SET is_closed = 1, last_updated_at_utc = ?
765
+ WHERE is_closed = 0
766
+ AND five_hour_window_key < ?
767
+ """,
768
+ (now_iso, int(five_hour_window_key)),
769
+ )
770
+
771
+ # Step 5b: natural-expiration sweep. The close-older predicate
772
+ # above only fires when a strictly-newer window arrives. A user
773
+ # who lets a block expire without a successor (idle / shut down
774
+ # past the 5h reset) would otherwise leave the row at
775
+ # is_closed = 0 forever. Idempotent (only flips 0 → 1); safe to
776
+ # re-run every tick. ISO-string compare is monotonic so it
777
+ # works directly on five_hour_resets_at.
778
+ conn.execute(
779
+ """
780
+ UPDATE five_hour_blocks
781
+ SET is_closed = 1, last_updated_at_utc = ?
782
+ WHERE is_closed = 0
783
+ AND five_hour_resets_at < ?
784
+ """,
785
+ (now_iso, now_iso),
786
+ )
787
+
788
+ # Step 7: atomic upsert. Single statement collapses the
789
+ # insert-vs-update branches and is race-safe: when two
790
+ # record-usage invocations both observe `prior is None`
791
+ # for a brand-new window (the SELECT at line 8636 happens
792
+ # before BEGIN), the loser's INSERT lands as DO UPDATE
793
+ # rather than raising IntegrityError on the
794
+ # UNIQUE(five_hour_window_key) constraint and dropping the
795
+ # tick. Immutable columns (block_start_at,
796
+ # first_observed_at_utc, five_hour_resets_at,
797
+ # seven_day_pct_at_block_start, created_at_utc) are
798
+ # deliberately omitted from DO UPDATE — first writer
799
+ # owns them.
800
+ conn.execute(
801
+ """
802
+ INSERT INTO five_hour_blocks (
803
+ five_hour_window_key,
804
+ five_hour_resets_at,
805
+ block_start_at,
806
+ first_observed_at_utc,
807
+ last_observed_at_utc,
808
+ final_five_hour_percent,
809
+ seven_day_pct_at_block_start,
810
+ seven_day_pct_at_block_end,
811
+ crossed_seven_day_reset,
812
+ total_input_tokens,
813
+ total_output_tokens,
814
+ total_cache_create_tokens,
815
+ total_cache_read_tokens,
816
+ total_cost_usd,
817
+ is_closed,
818
+ created_at_utc,
819
+ last_updated_at_utc
820
+ )
821
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, 0, ?, ?)
822
+ ON CONFLICT(five_hour_window_key) DO UPDATE SET
823
+ last_observed_at_utc = excluded.last_observed_at_utc,
824
+ final_five_hour_percent = excluded.final_five_hour_percent,
825
+ seven_day_pct_at_block_end = excluded.seven_day_pct_at_block_end,
826
+ total_input_tokens = excluded.total_input_tokens,
827
+ total_output_tokens = excluded.total_output_tokens,
828
+ total_cache_create_tokens = excluded.total_cache_create_tokens,
829
+ total_cache_read_tokens = excluded.total_cache_read_tokens,
830
+ total_cost_usd = excluded.total_cost_usd,
831
+ last_updated_at_utc = excluded.last_updated_at_utc
832
+ """,
833
+ (
834
+ int(five_hour_window_key),
835
+ str(five_hour_resets_at),
836
+ block_start_at,
837
+ captured_at,
838
+ captured_at,
839
+ float(five_hour_percent),
840
+ weekly_percent,
841
+ weekly_percent,
842
+ totals["input_tokens"],
843
+ totals["output_tokens"],
844
+ totals["cache_create_tokens"],
845
+ totals["cache_read_tokens"],
846
+ totals["cost_usd"],
847
+ now_iso,
848
+ now_iso,
849
+ ),
850
+ )
851
+
852
+ # ── Resolve current block_id once for reuse by the per-(block, model)
853
+ # / per-(block, project) child writes below AND the existing milestone
854
+ # detection (which previously did its own SELECT — drop that SELECT in
855
+ # favor of this variable).
856
+ block_id_row = conn.execute(
857
+ "SELECT id FROM five_hour_blocks WHERE five_hour_window_key = ?",
858
+ (int(five_hour_window_key),),
859
+ ).fetchone()
860
+ block_id = int(block_id_row["id"])
861
+
862
+ # ── Replace-all per-tick: per-(block, model) and per-(block, project_path)
863
+ # rollup-children. DELETE keyed on five_hour_window_key (NOT block_id) so
864
+ # orphan child rows from a prior parent rebuild are cleaned up automatically.
865
+ # Same transaction as the parent upsert; if these raise, the whole tick
866
+ # rolls back and the next tick recomputes from scratch.
867
+ conn.execute(
868
+ "DELETE FROM five_hour_block_models WHERE five_hour_window_key = ?",
869
+ (int(five_hour_window_key),),
870
+ )
871
+ if totals.get("by_model"):
872
+ conn.executemany(
873
+ """
874
+ INSERT INTO five_hour_block_models (
875
+ block_id, five_hour_window_key, model,
876
+ input_tokens, output_tokens,
877
+ cache_create_tokens, cache_read_tokens,
878
+ cost_usd, entry_count
879
+ )
880
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
881
+ """,
882
+ [
883
+ (
884
+ block_id,
885
+ int(five_hour_window_key),
886
+ model,
887
+ b["input_tokens"],
888
+ b["output_tokens"],
889
+ b["cache_create_tokens"],
890
+ b["cache_read_tokens"],
891
+ b["cost_usd"],
892
+ b["entry_count"],
893
+ )
894
+ for model, b in totals["by_model"].items()
895
+ ],
896
+ )
897
+
898
+ conn.execute(
899
+ "DELETE FROM five_hour_block_projects WHERE five_hour_window_key = ?",
900
+ (int(five_hour_window_key),),
901
+ )
902
+ if totals.get("by_project"):
903
+ conn.executemany(
904
+ """
905
+ INSERT INTO five_hour_block_projects (
906
+ block_id, five_hour_window_key, project_path,
907
+ input_tokens, output_tokens,
908
+ cache_create_tokens, cache_read_tokens,
909
+ cost_usd, entry_count
910
+ )
911
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
912
+ """,
913
+ [
914
+ (
915
+ block_id,
916
+ int(five_hour_window_key),
917
+ project_path,
918
+ b["input_tokens"],
919
+ b["output_tokens"],
920
+ b["cache_create_tokens"],
921
+ b["cache_read_tokens"],
922
+ b["cost_usd"],
923
+ b["entry_count"],
924
+ )
925
+ for project_path, b in totals["by_project"].items()
926
+ ],
927
+ )
928
+
929
+ # ── 5h-% milestone detection (mirrors maybe_record_milestone) ──
930
+ # Snap-up-by-1e-9 per the gotcha: 0.50 * 100 == 49.99...9 in
931
+ # IEEE-754, so bare math.floor would miss the 50 threshold.
932
+ current_floor = math.floor(float(five_hour_percent) + 1e-9)
933
+ if current_floor >= 1:
934
+ # Use max(percent_threshold) directly (not prior block's
935
+ # final_pct) so first-observation already-mid-stream doesn't
936
+ # synthesize crossings 1..(current_floor - 1) we never had
937
+ # authentic moment-of-detection data for. Same shape as
938
+ # maybe_record_milestone's max_existing path.
939
+ row = conn.execute(
940
+ "SELECT MAX(percent_threshold) AS m FROM five_hour_milestones "
941
+ "WHERE five_hour_window_key = ?",
942
+ (int(five_hour_window_key),),
943
+ ).fetchone()
944
+ max_existing = row["m"] if row and row["m"] is not None else None
945
+
946
+ if max_existing is None:
947
+ start_threshold = current_floor # first observation: only current floor
948
+ else:
949
+ start_threshold = int(max_existing) + 1
950
+
951
+ if start_threshold <= current_floor:
952
+ # block_id was resolved above (before the children writes) and
953
+ # is still in scope here.
954
+
955
+ # Marginal-cost lookup for the start_threshold milestone
956
+ # (only when there's a prior milestone in this block).
957
+ prior_cost: float | None = None
958
+ if max_existing is not None:
959
+ prev_row = conn.execute(
960
+ "SELECT block_cost_usd FROM five_hour_milestones "
961
+ "WHERE five_hour_window_key = ? AND percent_threshold = ?",
962
+ (int(five_hour_window_key), int(max_existing)),
963
+ ).fetchone()
964
+ if prev_row is not None:
965
+ prior_cost = float(prev_row["block_cost_usd"])
966
+
967
+ for pct in range(start_threshold, current_floor + 1):
968
+ if pct == start_threshold and prior_cost is not None:
969
+ marginal: float | None = totals["cost_usd"] - prior_cost
970
+ else:
971
+ marginal = None
972
+ cur = conn.execute(
973
+ """
974
+ INSERT OR IGNORE INTO five_hour_milestones (
975
+ block_id,
976
+ five_hour_window_key,
977
+ percent_threshold,
978
+ captured_at_utc,
979
+ usage_snapshot_id,
980
+ block_input_tokens,
981
+ block_output_tokens,
982
+ block_cache_create_tokens,
983
+ block_cache_read_tokens,
984
+ block_cost_usd,
985
+ marginal_cost_usd,
986
+ seven_day_pct_at_crossing
987
+ )
988
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
989
+ """,
990
+ (
991
+ block_id,
992
+ int(five_hour_window_key),
993
+ int(pct),
994
+ captured_at,
995
+ int(snapshot_id),
996
+ totals["input_tokens"],
997
+ totals["output_tokens"],
998
+ totals["cache_create_tokens"],
999
+ totals["cache_read_tokens"],
1000
+ totals["cost_usd"],
1001
+ marginal,
1002
+ weekly_percent,
1003
+ ),
1004
+ )
1005
+ # ── Threshold-actions dispatch (set-then-dispatch, spec §3.2) ──
1006
+ # Only the genuine-new-crossing winner (rowcount==1)
1007
+ # reaches dispatch. Concurrent record-usage instances
1008
+ # that race on the same (five_hour_window_key,
1009
+ # percent_threshold) get rowcount==0 from the
1010
+ # INSERT OR IGNORE and skip dispatch entirely.
1011
+ # `alerted_at IS NULL` on the UPDATE preserves
1012
+ # write-once even if two writers somehow both think
1013
+ # they won.
1014
+ #
1015
+ # I1: alerted_at UPDATE stays inside BEGIN (set-then-
1016
+ # dispatch invariant per spec §3.2 — the row carries
1017
+ # alerted_at BEFORE any externally-observable side
1018
+ # effect). The single outer commit at the bottom of
1019
+ # this BEGIN durably writes the milestone row AND the
1020
+ # alerted_at update together. Dispatch itself is
1021
+ # collected into pending_alerts and fired AFTER the
1022
+ # outer commit so the inner Popen-time bookkeeping
1023
+ # never ends the surrounding BEGIN mid-sequence.
1024
+ if (
1025
+ cur.rowcount == 1
1026
+ and alerts_cfg is not None
1027
+ and alerts_cfg["enabled"]
1028
+ and pct in alerts_cfg["five_hour_thresholds"]
1029
+ ):
1030
+ crossed_at = now_utc_iso()
1031
+ conn.execute(
1032
+ "UPDATE five_hour_milestones SET alerted_at = ? "
1033
+ "WHERE five_hour_window_key = ? AND percent_threshold = ? "
1034
+ "AND alerted_at IS NULL",
1035
+ (crossed_at, int(five_hour_window_key), int(pct)),
1036
+ )
1037
+ # Cheap re-reads inside BEGIN are SELECT-only and
1038
+ # safe; values reflect post-INSERT state. We
1039
+ # build the payload now (while block_id / totals
1040
+ # are in scope) and defer ONLY the Popen-side
1041
+ # _dispatch_alert_notification to after the outer
1042
+ # commit.
1043
+ cost_row = conn.execute(
1044
+ "SELECT block_cost_usd FROM five_hour_milestones "
1045
+ "WHERE five_hour_window_key = ? AND percent_threshold = ?",
1046
+ (int(five_hour_window_key), int(pct)),
1047
+ ).fetchone()
1048
+ block_row = conn.execute(
1049
+ "SELECT block_start_at FROM five_hour_blocks "
1050
+ "WHERE five_hour_window_key = ?",
1051
+ (int(five_hour_window_key),),
1052
+ ).fetchone()
1053
+ primary_model = _resolve_primary_model_for_block(
1054
+ conn, int(five_hour_window_key)
1055
+ )
1056
+ payload = _build_alert_payload_five_hour(
1057
+ threshold=int(pct),
1058
+ crossed_at_utc=crossed_at,
1059
+ five_hour_window_key=int(five_hour_window_key),
1060
+ block_start_at=(
1061
+ block_row["block_start_at"] if block_row else ""
1062
+ ),
1063
+ block_cost_usd=(
1064
+ float(cost_row["block_cost_usd"])
1065
+ if cost_row
1066
+ else 0.0
1067
+ ),
1068
+ primary_model=primary_model,
1069
+ )
1070
+ pending_alerts.append(payload)
1071
+
1072
+ # ── Reset-crossing cross-flag (opportunistic, JOIN-based) ──
1073
+ # Self-healing sweep: every tick, flag any open block whose
1074
+ # [block_start_at, last_observed_at_utc] interval crosses a
1075
+ # weekly reset, from either of two sources:
1076
+ # (a) week_reset_events — Anthropic-shifted MID-week resets
1077
+ # (prior week_end_at was still in the future at detect
1078
+ # time; see cmd_record_usage's reset-event detection).
1079
+ # (b) weekly_usage_snapshots.week_start_at — NATURAL weekly
1080
+ # boundaries. These never get a week_reset_events row
1081
+ # (mid-week detection requires the prior end to be in
1082
+ # the future), so source (a) silently misses blocks
1083
+ # that span a routine week reset. Without this clause
1084
+ # the dashboard's "Δ pp this block" delta is computed
1085
+ # against the pre-reset 7d% (~94%) versus post-reset
1086
+ # (~0%) and renders as a misleading −94pp drop.
1087
+ # Predicate (b) uses strict ``>`` on the lower bound so a
1088
+ # block that starts EXACTLY at the boundary (post-reset) is
1089
+ # not flagged. Symmetric with the historical-backfill
1090
+ # predicate (§4.2 step 5). Idempotent (only flips 0 → 1).
1091
+ #
1092
+ # Comparisons go through ``unixepoch()`` rather than a raw
1093
+ # lex BETWEEN: ``parse_iso_datetime`` returns host-local
1094
+ # tz-aware datetimes (line 9433: ``return parsed.astimezone()``),
1095
+ # so ``block_start_at`` is stored with the host's display
1096
+ # offset (e.g. ``+03:00``) while ``week_start_at`` is
1097
+ # ``+00:00`` and ``last_observed_at_utc`` is ``Z``. A lex
1098
+ # compare across mixed offsets silently mis-orders moments
1099
+ # for non-UTC hosts; ``unixepoch()`` normalizes all three
1100
+ # to seconds-since-epoch and is correct regardless of
1101
+ # offset suffix.
1102
+ #
1103
+ # Why the JOIN rather than a per-tick param: an earlier
1104
+ # design passed mid_week_reset_at only on the tick that
1105
+ # cmd_record_usage's INSERT OR IGNORE actually inserted
1106
+ # the event row. If the helper raised after the event
1107
+ # commit but before the flag UPDATE, the next tick's
1108
+ # INSERT OR IGNORE was a duplicate and the flag stayed 0
1109
+ # forever. The JOIN re-derives from durable state on
1110
+ # every tick and self-heals.
1111
+ conn.execute(
1112
+ """
1113
+ UPDATE five_hour_blocks
1114
+ SET crossed_seven_day_reset = 1
1115
+ WHERE crossed_seven_day_reset = 0
1116
+ AND (
1117
+ EXISTS (
1118
+ SELECT 1 FROM week_reset_events e
1119
+ WHERE unixepoch(e.effective_reset_at_utc)
1120
+ BETWEEN unixepoch(five_hour_blocks.block_start_at)
1121
+ AND unixepoch(five_hour_blocks.last_observed_at_utc)
1122
+ )
1123
+ OR EXISTS (
1124
+ SELECT 1 FROM weekly_usage_snapshots ws
1125
+ WHERE ws.week_start_at IS NOT NULL
1126
+ AND unixepoch(ws.week_start_at)
1127
+ > unixepoch(five_hour_blocks.block_start_at)
1128
+ AND unixepoch(ws.week_start_at)
1129
+ <= unixepoch(five_hour_blocks.last_observed_at_utc)
1130
+ )
1131
+ )
1132
+ """,
1133
+ )
1134
+
1135
+ conn.commit()
1136
+ except Exception:
1137
+ conn.rollback()
1138
+ raise
1139
+
1140
+ # I1: dispatch deferred to AFTER the outer commit. The milestone
1141
+ # row + alerted_at update + close-older + parent upsert + child
1142
+ # rebuilds + cross-flag sweep are all durably written together
1143
+ # before any externally-observable osascript Popen fires. If the
1144
+ # inner BEGIN rolled back above, `pending_alerts` is unreachable
1145
+ # (the `raise` above bubbles out via the outer try). Production
1146
+ # caller ignores _dispatch_alert_notification's return value
1147
+ # (spec §6.4); a per-payload exception is logged and the loop
1148
+ # continues so a bad-payload alert can't suppress healthy ones.
1149
+ for payload in pending_alerts:
1150
+ try:
1151
+ _dispatch_alert_notification(
1152
+ payload, mode="real", tz=display_tz_for_alerts
1153
+ )
1154
+ except Exception as dispatch_exc:
1155
+ eprint(f"[alerts] dispatch failed: {dispatch_exc}")
1156
+ except Exception as exc:
1157
+ eprint(f"[5h-block] error updating block: {exc}")
1158
+ finally:
1159
+ conn.close()
1160
+
1161
+
1162
+ def cmd_record_usage(args: argparse.Namespace) -> int:
1163
+ """Record usage data from Claude Code status line rate_limits."""
1164
+ c = _cctally()
1165
+ config = load_config()
1166
+ week_start_name = get_week_start_name(config, getattr(args, "week_start_name", None))
1167
+
1168
+ # ULP-noise sanitization is applied at the cmd_record_usage ingress
1169
+ # boundary so every downstream consumer (HWM files, DB rows,
1170
+ # five_hour_blocks rollup, milestones) reads a stable value. See
1171
+ # `_normalize_percent` for the rationale.
1172
+ weekly_percent = _normalize_percent(args.percent)
1173
+ resets_at = int(args.resets_at)
1174
+
1175
+ five_hour_percent: float | None = None
1176
+ five_hour_resets_at_str: str | None = None
1177
+ five_hour_window_key: int | None = None
1178
+ five_hour_resets_at_epoch: int | None = None
1179
+ if args.five_hour_percent is not None:
1180
+ five_hour_percent = _normalize_percent(args.five_hour_percent)
1181
+ if args.five_hour_resets_at is not None:
1182
+ five_hour_resets_at_epoch = int(args.five_hour_resets_at)
1183
+ five_hour_resets_at_str = dt.datetime.fromtimestamp(
1184
+ five_hour_resets_at_epoch, tz=dt.timezone.utc
1185
+ ).isoformat(timespec="seconds")
1186
+ # five_hour_window_key derivation is deferred until after open_db()
1187
+ # so we can pass the most-recent stored sample as the prior anchor.
1188
+ # See _canonical_5h_window_key docstring (spec invariant #3:
1189
+ # boundary-straddling jitter must collapse to the first-seen key).
1190
+
1191
+ # Derive week boundaries from resets_at (exact UTC epoch)
1192
+ week_end_at_dt = dt.datetime.fromtimestamp(resets_at, tz=dt.timezone.utc)
1193
+ week_start_at_dt = week_end_at_dt - dt.timedelta(days=7)
1194
+ week_start_date = week_start_at_dt.date().isoformat()
1195
+ week_end_date = week_end_at_dt.date().isoformat()
1196
+ week_start_at = week_start_at_dt.isoformat(timespec="seconds")
1197
+ week_end_at = week_end_at_dt.isoformat(timespec="seconds")
1198
+
1199
+ # Deduplication: skip if nothing changed since last snapshot
1200
+ should_insert = True
1201
+ conn = open_db()
1202
+ try:
1203
+ # Resolve the canonical 5h window key. Pass the most-recent stored
1204
+ # sample as the prior anchor so seconds-level jitter that straddles
1205
+ # a 600-second floor-bucket boundary (e.g. resets_at=1746014999 vs.
1206
+ # 1746015000) collapses to the first-seen key instead of forking
1207
+ # a new one. Without this, both the DB clamp below and the hwm-5h
1208
+ # file write further down would treat the same physical window as
1209
+ # distinct, regressing the monotonic 5h percent (spec invariant #3).
1210
+ if five_hour_resets_at_epoch is not None:
1211
+ prior_5h_epoch: int | None = None
1212
+ prior_5h_key: int | None = None
1213
+ # Tier 1: blocks-table lookup (steady state). Find the closest
1214
+ # canonical block whose five_hour_resets_at is within ±1800s of
1215
+ # the new resets_at. The blocks table has one canonical row per
1216
+ # physical window after the merge_5h_block_duplicates_v1
1217
+ # migration, so this is more reliable than scanning
1218
+ # weekly_usage_snapshots for an "anchor" row — snapshots can be
1219
+ # noisy when the status line returns out-of-order rate-limit
1220
+ # data from older windows (the F4 incident: snap N+1 carrying a
1221
+ # window-A boundary-jitter resets_at, but snap N reported an
1222
+ # OLDER window B; pre-fix the prior-anchor lookup picked B as
1223
+ # the anchor and the |epoch-prior| > 600 check then forked a
1224
+ # new key for what was actually still window A). 1800s is wide
1225
+ # enough to absorb known jitter, narrow enough that consecutive
1226
+ # 5h blocks (>4h apart in resets_at) cannot collide.
1227
+ try:
1228
+ prior_block_row = conn.execute(
1229
+ """
1230
+ SELECT five_hour_window_key, five_hour_resets_at
1231
+ FROM five_hour_blocks
1232
+ WHERE abs(? - CAST(strftime('%s', five_hour_resets_at) AS INTEGER)) <= ?
1233
+ ORDER BY abs(? - CAST(strftime('%s', five_hour_resets_at) AS INTEGER)) ASC
1234
+ LIMIT 1
1235
+ """,
1236
+ (
1237
+ five_hour_resets_at_epoch,
1238
+ c._FIVE_HOUR_JITTER_FLOOR_SECONDS * 3,
1239
+ five_hour_resets_at_epoch,
1240
+ ),
1241
+ ).fetchone()
1242
+ if prior_block_row is not None:
1243
+ prior_iso = prior_block_row["five_hour_resets_at"]
1244
+ prior_5h_epoch = int(parse_iso_datetime(
1245
+ prior_iso, "prior 5h block anchor"
1246
+ ).timestamp())
1247
+ prior_5h_key = int(prior_block_row["five_hour_window_key"])
1248
+ except (sqlite3.DatabaseError, ValueError, TypeError) as exc:
1249
+ eprint(f"[record-usage] prior 5h block-anchor lookup failed: {exc}")
1250
+
1251
+ # Tier 2: snapshot lookup (legacy fallback). Only run when Tier
1252
+ # 1 missed (no canonical block row exists yet — the brand-new-
1253
+ # window case before any record-usage tick has materialized a
1254
+ # five_hour_blocks row). Tier 1's empty-result guard is the
1255
+ # `prior_block_row is not None` test above; replicating it
1256
+ # here keeps Tier 2 strictly secondary.
1257
+ if prior_5h_key is None:
1258
+ try:
1259
+ prior_5h_row = conn.execute(
1260
+ "SELECT five_hour_resets_at, five_hour_window_key "
1261
+ "FROM weekly_usage_snapshots "
1262
+ "WHERE five_hour_resets_at IS NOT NULL "
1263
+ " AND five_hour_window_key IS NOT NULL "
1264
+ "ORDER BY captured_at_utc DESC, id DESC LIMIT 1"
1265
+ ).fetchone()
1266
+ if prior_5h_row is not None:
1267
+ prior_iso = prior_5h_row["five_hour_resets_at"]
1268
+ prior_5h_epoch = int(parse_iso_datetime(
1269
+ prior_iso, "prior 5h anchor"
1270
+ ).timestamp())
1271
+ prior_5h_key = int(prior_5h_row["five_hour_window_key"])
1272
+ except (sqlite3.DatabaseError, ValueError, TypeError) as exc:
1273
+ eprint(f"[record-usage] prior 5h anchor lookup failed: {exc}")
1274
+
1275
+ # Tier 3 is implicit: with no anchor, _canonical_5h_window_key
1276
+ # falls back to the pure 600-second floor.
1277
+ five_hour_window_key = _canonical_5h_window_key(
1278
+ five_hour_resets_at_epoch,
1279
+ prior_epoch=prior_5h_epoch,
1280
+ prior_key=prior_5h_key,
1281
+ )
1282
+
1283
+ # Mid-week reset detection. When `resets_at` advances before the
1284
+ # previously-declared reset actually fires (Anthropic-initiated
1285
+ # goodwill reset, or any API-side shift), record one week_reset_events
1286
+ # row so display + cost layers can treat the observed moment as the
1287
+ # old week's effective end AND the new week's effective start. The
1288
+ # monotonic check below stays keyed on week_start_date so it still
1289
+ # guards the new week against stale rate-limit data independently.
1290
+ # Both boundaries canonicalize to hour (same rule make_week_ref uses)
1291
+ # so minute/second-level Anthropic jitter doesn't masquerade as a
1292
+ # reset and the stored values match what WeekRef.week_end_at carries.
1293
+ # The 5h-block cross-flag is no longer threaded from here —
1294
+ # maybe_update_five_hour_block re-derives it every tick by JOINing
1295
+ # against week_reset_events (self-healing, see helper for rationale).
1296
+ try:
1297
+ cur_end_canon = _canonicalize_optional_iso(week_end_at, "record.cur")
1298
+ prior = conn.execute(
1299
+ "SELECT week_end_at, weekly_percent FROM weekly_usage_snapshots "
1300
+ "WHERE week_end_at IS NOT NULL "
1301
+ "ORDER BY captured_at_utc DESC, id DESC LIMIT 1"
1302
+ ).fetchone()
1303
+ if prior and prior["week_end_at"] and cur_end_canon:
1304
+ prior_end_canon = _canonicalize_optional_iso(
1305
+ prior["week_end_at"], "record.prior"
1306
+ )
1307
+ prior_pct = prior["weekly_percent"]
1308
+ if prior_end_canon and prior_end_canon != cur_end_canon:
1309
+ prior_end_dt = parse_iso_datetime(prior_end_canon, "prior.week_end_at")
1310
+ now_utc = dt.datetime.now(dt.timezone.utc)
1311
+ # Fire only when (a) prior window was still in the FUTURE
1312
+ # (Anthropic shifted the boundary before natural expiration),
1313
+ # AND (b) weekly_percent dropped by RESET_PCT_DROP_THRESHOLD
1314
+ # or more (filters out API flaps / transient boundary
1315
+ # jitter where usage stays roughly the same).
1316
+ if (
1317
+ prior_end_dt > now_utc
1318
+ and prior_pct is not None
1319
+ and (float(prior_pct) - float(weekly_percent)) >= c._RESET_PCT_DROP_THRESHOLD
1320
+ ):
1321
+ # See _backfill_week_reset_events for why we floor
1322
+ # the reset moment to the hour (natural display
1323
+ # boundary, aligned with Anthropic's hour-only
1324
+ # resets_at values).
1325
+ effective_iso = _floor_to_hour(now_utc).isoformat(timespec="seconds")
1326
+ conn.execute(
1327
+ "INSERT OR IGNORE INTO week_reset_events "
1328
+ "(detected_at_utc, old_week_end_at, new_week_end_at, "
1329
+ " effective_reset_at_utc) VALUES (?, ?, ?, ?)",
1330
+ (now_utc_iso(), prior_end_canon, cur_end_canon,
1331
+ effective_iso),
1332
+ )
1333
+ conn.commit()
1334
+ except (sqlite3.DatabaseError, ValueError) as exc:
1335
+ eprint(f"[record-usage] reset-event detection failed: {exc}")
1336
+
1337
+ # 7-day usage is monotonically non-decreasing within a billing week.
1338
+ # A lower value means stale rate-limit data from a previous API call;
1339
+ # skip the insert to avoid regressing the reported usage.
1340
+ max_row = conn.execute(
1341
+ "SELECT MAX(weekly_percent) AS v FROM weekly_usage_snapshots WHERE week_start_date = ?",
1342
+ (week_start_date,),
1343
+ ).fetchone()
1344
+ if max_row and max_row["v"] is not None and round(weekly_percent, 1) < round(float(max_row["v"]), 1):
1345
+ should_insert = False
1346
+ else:
1347
+ # 5-hour usage is monotonically non-decreasing within a window.
1348
+ # A lower value means stale API data; clamp to existing max.
1349
+ # Joining on five_hour_window_key (canonical 10-min-floored
1350
+ # epoch) absorbs Anthropic's seconds-level jitter on
1351
+ # resets_at; an ISO-string equality at this site silently
1352
+ # skipped the clamp every time a jittered fetch landed in
1353
+ # the same physical 5h window (spec Bug B).
1354
+ if five_hour_percent is not None and five_hour_window_key is not None:
1355
+ max_5h_row = conn.execute(
1356
+ "SELECT MAX(five_hour_percent) AS v FROM weekly_usage_snapshots WHERE five_hour_window_key = ?",
1357
+ (five_hour_window_key,),
1358
+ ).fetchone()
1359
+ if max_5h_row and max_5h_row["v"] is not None and round(five_hour_percent, 1) < round(float(max_5h_row["v"]), 1):
1360
+ five_hour_percent = float(max_5h_row["v"])
1361
+
1362
+ # Dedup vs last snapshot: if BOTH weekly_percent and
1363
+ # five_hour_percent are unchanged from the most recent row in
1364
+ # this week, swallow the insert. Tests of the 5h clamp must
1365
+ # vary --percent (or --five-hour-percent) between calls, or
1366
+ # the second call is dropped here before the clamp even runs
1367
+ # — see bin/cctally-5h-canonical-test scenario B.
1368
+ last = conn.execute(
1369
+ """
1370
+ SELECT weekly_percent, five_hour_percent
1371
+ FROM weekly_usage_snapshots
1372
+ WHERE week_start_date = ?
1373
+ ORDER BY captured_at_utc DESC, id DESC
1374
+ LIMIT 1
1375
+ """,
1376
+ (week_start_date,),
1377
+ ).fetchone()
1378
+ if last is not None:
1379
+ if float(last["weekly_percent"]) == weekly_percent:
1380
+ last_5h = last["five_hour_percent"]
1381
+ if five_hour_percent is None or (
1382
+ last_5h is not None and float(last_5h) == five_hour_percent
1383
+ ):
1384
+ should_insert = False
1385
+
1386
+ # No backfill of 5h data on existing milestones — we don't have
1387
+ # authentic crossing-time values for them. New milestones created
1388
+ # by the status line path will have 5h data set at creation time
1389
+ # via maybe_record_milestone().
1390
+ finally:
1391
+ conn.close()
1392
+
1393
+ if not should_insert:
1394
+ # Self-heal: a prior record-usage invocation may have inserted
1395
+ # the snapshot but been killed (CC self-update, machine sleep,
1396
+ # OOM) before maybe_record_milestone / maybe_update_five_hour_block
1397
+ # could run. Pre-probe both surfaces with cheap indexed SELECTs
1398
+ # and only invoke the helpers when a row is actually missing or
1399
+ # stale. Steady-state cost: 1-3 SELECTs (latest snapshot always;
1400
+ # +max_milestone if floor>=1; +block last_observed if window_key
1401
+ # is set); ZERO JSONL re-ingest on healthy ticks. The helpers themselves are idempotent under
1402
+ # concurrent record-usage instances (INSERT OR IGNORE for
1403
+ # percent_milestones; SQLite write-lock serialization for the
1404
+ # 5h upsert). Without the pre-probe, every dedup tick would
1405
+ # trigger sync_cache + a window walk + replace-all rollups via
1406
+ # maybe_update_five_hour_block's unconditional _compute_block_totals
1407
+ # call. Regression: bin/cctally-record-usage-selfheal-test.
1408
+ try:
1409
+ heal_conn = open_db()
1410
+ try:
1411
+ latest_row = heal_conn.execute(
1412
+ "SELECT * FROM weekly_usage_snapshots "
1413
+ "WHERE week_start_date = ? "
1414
+ "ORDER BY captured_at_utc DESC, id DESC LIMIT 1",
1415
+ (week_start_date,),
1416
+ ).fetchone()
1417
+ if latest_row is None:
1418
+ return 0
1419
+
1420
+ # Probe 1: do we owe a percent milestone? Snap up before
1421
+ # floor (status-line API returns 0.N*100 which can fall
1422
+ # one ULP short of N — same convention as
1423
+ # maybe_record_milestone).
1424
+ latest_floor = math.floor(
1425
+ float(latest_row["weekly_percent"]) + 1e-9
1426
+ )
1427
+ need_milestone_heal = False
1428
+ if latest_floor >= 1:
1429
+ max_existing = heal_conn.execute(
1430
+ "SELECT MAX(percent_threshold) AS m "
1431
+ "FROM percent_milestones "
1432
+ "WHERE week_start_date = ?",
1433
+ (week_start_date,),
1434
+ ).fetchone()
1435
+ if max_existing is None or max_existing["m"] is None:
1436
+ need_milestone_heal = True
1437
+ elif int(max_existing["m"]) < latest_floor:
1438
+ need_milestone_heal = True
1439
+
1440
+ # Probe 2: do we owe a 5h-block update? Either no row
1441
+ # for this canonical window, or the existing row's
1442
+ # last_observed_at_utc is stale relative to the latest
1443
+ # snapshot's captured_at_utc (the kill landed between
1444
+ # insert_usage_snapshot and maybe_update_five_hour_block).
1445
+ need_5h_heal = False
1446
+ window_key = latest_row["five_hour_window_key"]
1447
+ if window_key is not None:
1448
+ block_row = heal_conn.execute(
1449
+ "SELECT last_observed_at_utc "
1450
+ "FROM five_hour_blocks "
1451
+ "WHERE five_hour_window_key = ?",
1452
+ (int(window_key),),
1453
+ ).fetchone()
1454
+ if block_row is None:
1455
+ need_5h_heal = True
1456
+ elif (
1457
+ block_row["last_observed_at_utc"]
1458
+ < latest_row["captured_at_utc"]
1459
+ ):
1460
+ need_5h_heal = True
1461
+ finally:
1462
+ heal_conn.close()
1463
+
1464
+ if need_milestone_heal or need_5h_heal:
1465
+ latest_saved = _saved_dict_from_usage_row(latest_row)
1466
+ if need_milestone_heal:
1467
+ try:
1468
+ maybe_record_milestone(latest_saved)
1469
+ except Exception as exc:
1470
+ eprint(f"[milestone] self-heal error: {exc}")
1471
+ if need_5h_heal:
1472
+ try:
1473
+ maybe_update_five_hour_block(latest_saved)
1474
+ except Exception as exc:
1475
+ eprint(f"[5h-block] self-heal error: {exc}")
1476
+ except Exception as exc:
1477
+ eprint(f"[record-usage] self-heal lookup failed: {exc}")
1478
+ return 0
1479
+
1480
+ payload = {
1481
+ "source": "statusline",
1482
+ "capturedAt": now_utc_iso(),
1483
+ "weeklyPercent": weekly_percent,
1484
+ "weekStartDate": week_start_date,
1485
+ "weekEndDate": week_end_date,
1486
+ "weekStartAt": week_start_at,
1487
+ "weekEndAt": week_end_at,
1488
+ }
1489
+ if five_hour_percent is not None:
1490
+ payload["fiveHourPercent"] = five_hour_percent
1491
+ if five_hour_resets_at_str is not None:
1492
+ payload["fiveHourResetsAt"] = five_hour_resets_at_str
1493
+ if five_hour_window_key is not None:
1494
+ payload["fiveHourWindowKey"] = five_hour_window_key
1495
+
1496
+ saved = insert_usage_snapshot(payload, week_start_name)
1497
+ try:
1498
+ maybe_record_milestone(saved)
1499
+ except Exception as exc:
1500
+ eprint(f"[milestone] unexpected error: {exc}")
1501
+
1502
+ # NEW: 5h-block rollup (paired with maybe_record_milestone for 7d).
1503
+ # The helper performs an opportunistic JOIN against week_reset_events
1504
+ # every tick to flag any open block whose interval contains a recorded
1505
+ # reset; no per-call plumbing needed (self-healing).
1506
+ try:
1507
+ maybe_update_five_hour_block(saved)
1508
+ except Exception as exc:
1509
+ eprint(f"[5h-block] unexpected error: {exc}")
1510
+
1511
+ # Write high-water mark so the status line never displays a regression.
1512
+ # The file contains "week_start_date weekly_percent" on one line.
1513
+ try:
1514
+ hwm_path = c.APP_DIR / "hwm-7d"
1515
+ existing_hwm = 0.0
1516
+ try:
1517
+ parts = hwm_path.read_text().strip().split()
1518
+ if len(parts) == 2 and parts[0] == week_start_date:
1519
+ existing_hwm = float(parts[1])
1520
+ except (FileNotFoundError, ValueError, OSError):
1521
+ pass
1522
+ if weekly_percent >= existing_hwm:
1523
+ hwm_path.write_text(f"{week_start_date} {weekly_percent}\n")
1524
+ except OSError:
1525
+ pass
1526
+
1527
+ # Symmetric 5h HWM. Keyed by the canonical five_hour_window_key derived
1528
+ # under the prior-anchor logic above (NOT a fresh pure-floor recompute) so
1529
+ # boundary-straddling jitter writes to the same file key as the matching
1530
+ # DB row. File format: "<canonical_5h_window_key> <percent>".
1531
+ if (
1532
+ five_hour_percent is not None
1533
+ and five_hour_window_key is not None
1534
+ ):
1535
+ try:
1536
+ five_resets_key = five_hour_window_key
1537
+ hwm5_path = c.APP_DIR / "hwm-5h"
1538
+ existing_hwm5 = 0.0
1539
+ try:
1540
+ parts5 = hwm5_path.read_text().strip().split()
1541
+ if len(parts5) == 2 and parts5[0] == str(five_resets_key):
1542
+ existing_hwm5 = float(parts5[1])
1543
+ except (FileNotFoundError, ValueError, OSError):
1544
+ pass
1545
+ if five_hour_percent >= existing_hwm5:
1546
+ hwm5_path.write_text(f"{five_resets_key} {five_hour_percent}\n")
1547
+ except OSError:
1548
+ pass
1549
+
1550
+ return 0
1551
+
1552
+
1553
+ def _hook_tick_log_line(line: str) -> None:
1554
+ """Append one line to hook-tick.log; create dir if missing.
1555
+
1556
+ Uses O_APPEND so concurrent writers' sub-PIPE_BUF lines don't interleave.
1557
+ Best-effort: any IO error is silently swallowed (hook discipline).
1558
+ """
1559
+ c = _cctally()
1560
+ try:
1561
+ c.HOOK_TICK_LOG_DIR.mkdir(parents=True, exist_ok=True)
1562
+ fd = os.open(c.HOOK_TICK_LOG_PATH, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
1563
+ try:
1564
+ os.write(fd, (line.rstrip("\n") + "\n").encode("utf-8", errors="replace"))
1565
+ finally:
1566
+ os.close(fd)
1567
+ except OSError:
1568
+ pass
1569
+
1570
+
1571
+ def _hook_tick_log_rotate_if_needed() -> None:
1572
+ """If hook-tick.log exceeds the size cap, atomic-rename to .1 (overwriting)."""
1573
+ c = _cctally()
1574
+ try:
1575
+ size = c.HOOK_TICK_LOG_PATH.stat().st_size
1576
+ except FileNotFoundError:
1577
+ return
1578
+ except OSError:
1579
+ return
1580
+ if size <= c.HOOK_TICK_LOG_ROTATE_BYTES:
1581
+ return
1582
+ try:
1583
+ os.replace(c.HOOK_TICK_LOG_PATH, c.HOOK_TICK_LOG_ROTATED_PATH)
1584
+ except OSError:
1585
+ pass
1586
+
1587
+
1588
+ def _hook_tick_throttle_age_seconds() -> float:
1589
+ """Return seconds since last successful OAuth fetch; +inf if never."""
1590
+ c = _cctally()
1591
+ try:
1592
+ mtime = c.HOOK_TICK_THROTTLE_PATH.stat().st_mtime
1593
+ except FileNotFoundError:
1594
+ return float("inf")
1595
+ except OSError:
1596
+ return float("inf")
1597
+ return max(0.0, time.time() - mtime)
1598
+
1599
+
1600
+ def _hook_tick_throttle_touch() -> None:
1601
+ """Update mtime to now (creating the file if missing)."""
1602
+ c = _cctally()
1603
+ try:
1604
+ c.APP_DIR.mkdir(parents=True, exist_ok=True)
1605
+ c.HOOK_TICK_THROTTLE_PATH.touch(exist_ok=True)
1606
+ os.utime(c.HOOK_TICK_THROTTLE_PATH, None)
1607
+ except OSError:
1608
+ pass
1609
+
1610
+
1611
+ def _hook_tick_read_stdin_event(stdin_max_bytes: int = 32 * 1024) -> dict:
1612
+ """Read CC's hook payload (JSON on stdin). Best-effort.
1613
+
1614
+ Returns dict with keys event, session_id, transcript_path, cwd —
1615
+ every value is a string (or "unknown"). Never raises.
1616
+ """
1617
+ out = {"event": "unknown", "session_id": "unknown", "transcript_path": "", "cwd": ""}
1618
+ try:
1619
+ data = sys.stdin.buffer.read(stdin_max_bytes)
1620
+ except (OSError, ValueError):
1621
+ return out
1622
+ if not data:
1623
+ return out
1624
+ try:
1625
+ payload = json.loads(data.decode("utf-8", errors="replace"))
1626
+ except (ValueError, UnicodeDecodeError):
1627
+ return out
1628
+ if not isinstance(payload, dict):
1629
+ return out
1630
+ out["event"] = str(payload.get("hook_event_name") or "unknown")
1631
+ sid = payload.get("session_id")
1632
+ out["session_id"] = str(sid) if isinstance(sid, str) else "unknown"
1633
+ tp = payload.get("transcript_path")
1634
+ out["transcript_path"] = str(tp) if isinstance(tp, str) else ""
1635
+ cwd = payload.get("cwd")
1636
+ out["cwd"] = str(cwd) if isinstance(cwd, str) else ""
1637
+ return out
1638
+
1639
+
1640
+ def _hook_tick_session_short(sid: str) -> str:
1641
+ """First 8 chars of a session id, sanitized for log lines."""
1642
+ if not sid or sid == "unknown":
1643
+ return "unknown"
1644
+ return "".join(c for c in sid[:8] if c.isalnum() or c in "-_")
1645
+
1646
+
1647
+ def _hook_tick_format_log_line(
1648
+ event: str, session: str, ingested: int, oauth_status: str, dur_ms: int
1649
+ ) -> str:
1650
+ ts = now_utc_iso()
1651
+ return (
1652
+ f"{ts} event={event:14s} session={session} "
1653
+ f"ingested={ingested} oauth={oauth_status} dur_ms={dur_ms}"
1654
+ )
1655
+
1656
+
1657
+ def cmd_hook_tick(args: argparse.Namespace) -> int:
1658
+ """Per-fire hook runtime (Section 3 of onboarding spec).
1659
+
1660
+ Normal mode: reads stdin, detaches stdout/stderr to log file, runs
1661
+ sync_cache + (throttled) OAuth refresh, writes one log line, returns 0
1662
+ UNCONDITIONALLY (even on internal failure — hook discipline).
1663
+
1664
+ --explain mode: synchronous, prints to stdout, returns informative
1665
+ exit code.
1666
+ """
1667
+ c = _cctally()
1668
+ explain = bool(getattr(args, "explain", False))
1669
+ no_oauth = bool(getattr(args, "no_oauth", False))
1670
+ # Use an explicit `is None` check so `--throttle-seconds 0` survives the
1671
+ # default-fallback (a `0 or DEFAULT` short-circuit would silently drop
1672
+ # the override and reapply the configured window — defeats the purpose
1673
+ # of the zero-second escape hatch).
1674
+ override = getattr(args, "throttle_seconds", None)
1675
+ if override is not None:
1676
+ throttle_seconds = float(override)
1677
+ else:
1678
+ try:
1679
+ _cfg = _get_oauth_usage_config(load_config())
1680
+ throttle_seconds = float(_cfg["throttle_seconds"])
1681
+ except sys.modules["cctally"].OauthUsageConfigError:
1682
+ throttle_seconds = float(c.HOOK_TICK_DEFAULT_THROTTLE_SECONDS)
1683
+
1684
+ # --- Step 1: read stdin (before detach OR fork) ---
1685
+ # CRITICAL: stdin must be read BEFORE we fork. POSIX (XCU §2.9.3) says
1686
+ # async commands (`cmd &`) in non-interactive shells get stdin redirected
1687
+ # to /dev/null; we previously relied on shell `&` which blanked the
1688
+ # hook payload. Now the settings.json command is bare and we fork here
1689
+ # ourselves — but stdin still has to be drained first.
1690
+ forced_event = getattr(args, "event", None)
1691
+ if explain:
1692
+ meta = {"event": forced_event or "explain", "session_id": "explain",
1693
+ "transcript_path": "", "cwd": ""}
1694
+ else:
1695
+ meta = _hook_tick_read_stdin_event()
1696
+ if forced_event:
1697
+ meta["event"] = forced_event
1698
+
1699
+ # --- Step 1b: fork to background so CC's hook returns immediately ---
1700
+ # Parent returns 0 right away; child carries on with sync_cache + OAuth.
1701
+ # If fork fails (rare: out of pids/memory), fall back to running the
1702
+ # body inline — the parent process must NOT be misclassified as a
1703
+ # forked child, otherwise os.setsid() would detach the parent's
1704
+ # controlling terminal and os._exit(0) at function end would kill it
1705
+ # mid-stack.
1706
+ forked = False
1707
+ pid = 0
1708
+ if not explain:
1709
+ try:
1710
+ pid = os.fork()
1711
+ forked = True
1712
+ except OSError:
1713
+ pass
1714
+ if forked and pid > 0:
1715
+ # Parent of a successful fork: CC unblocks immediately.
1716
+ return 0
1717
+ # Either: child of successful fork, OR inline fallback after fork failure.
1718
+ if forked:
1719
+ # Detach from parent's session so SIGHUP from CC doesn't kill us.
1720
+ try:
1721
+ os.setsid()
1722
+ except OSError:
1723
+ pass
1724
+
1725
+ # --- Step 2: detach stdio (forked child OR inline fallback after fork failure) ---
1726
+ # In the inline-fallback path the parent process re-routes its own stdout/
1727
+ # stderr to the log file for the rest of its short life. Function returns
1728
+ # immediately after Step 7, so the leak is bounded.
1729
+ if not explain:
1730
+ try:
1731
+ c.HOOK_TICK_LOG_DIR.mkdir(parents=True, exist_ok=True)
1732
+ log_fd = os.open(
1733
+ c.HOOK_TICK_LOG_PATH,
1734
+ os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644,
1735
+ )
1736
+ os.dup2(log_fd, 1) # stdout
1737
+ os.dup2(log_fd, 2) # stderr
1738
+ os.close(log_fd)
1739
+ try:
1740
+ devnull = os.open(os.devnull, os.O_RDONLY)
1741
+ os.dup2(devnull, 0)
1742
+ os.close(devnull)
1743
+ except OSError:
1744
+ pass
1745
+ except OSError:
1746
+ pass # log redirect failed; carry on silently
1747
+
1748
+ # --- Steps 3-7: wrap remainder in try/except (always exit 0 in normal mode) ---
1749
+ start = time.monotonic()
1750
+ ingested = 0
1751
+ oauth_status = "skipped-no-oauth" if no_oauth else "throttled(age=?s)"
1752
+ # Pre-fetch throttle state captured for --explain output. The OAuth
1753
+ # block re-touches the throttle marker after a successful fetch, so
1754
+ # re-reading age there would print `mtime: 0s ago → skip` even when
1755
+ # the call we just made was a fetch. Freeze the values at decision
1756
+ # time. `pre_age` is read once now (covers --no-oauth / lock-failure
1757
+ # paths); the throttle block below re-assigns it under flock for the
1758
+ # OAuth-active path so the explain output matches the actual decision.
1759
+ pre_age: float = _hook_tick_throttle_age_seconds()
1760
+ decision: str = "skip"
1761
+
1762
+ try:
1763
+ # Local sync (always)
1764
+ try:
1765
+ cache_conn = open_cache_db()
1766
+ try:
1767
+ stats = sync_cache(cache_conn)
1768
+ ingested = int(stats.rows_inserted)
1769
+ finally:
1770
+ try:
1771
+ cache_conn.close()
1772
+ except Exception:
1773
+ pass
1774
+ except Exception as exc:
1775
+ ingested = -1
1776
+ if explain:
1777
+ eprint(f"[hook-tick] sync_cache failed: {exc}")
1778
+
1779
+ mock = getattr(args, "mock_oauth_response", None)
1780
+ if mock is not None:
1781
+ # Replace the throttle path's fetch fn for this process.
1782
+ sys.modules["cctally"]._hook_tick_oauth_refresh = _hook_tick_make_mock_refresh(mock)
1783
+
1784
+ # Throttle check + OAuth (under flock)
1785
+ if not no_oauth:
1786
+ c.APP_DIR.mkdir(parents=True, exist_ok=True)
1787
+ try:
1788
+ lock_fd = os.open(
1789
+ c.HOOK_TICK_THROTTLE_LOCK_PATH,
1790
+ os.O_WRONLY | os.O_CREAT, 0o644,
1791
+ )
1792
+ except OSError:
1793
+ lock_fd = -1
1794
+ try:
1795
+ if lock_fd >= 0:
1796
+ fcntl.flock(lock_fd, fcntl.LOCK_EX)
1797
+ pre_age = _hook_tick_throttle_age_seconds()
1798
+ if pre_age >= throttle_seconds:
1799
+ decision = "fetch"
1800
+ oauth_status, _ = _hook_tick_oauth_refresh(throttle_seconds=throttle_seconds)
1801
+ if oauth_status.startswith("ok"):
1802
+ _hook_tick_throttle_touch()
1803
+ else:
1804
+ oauth_status = f"throttled(age={int(pre_age)}s)"
1805
+ finally:
1806
+ if lock_fd >= 0:
1807
+ try:
1808
+ fcntl.flock(lock_fd, fcntl.LOCK_UN)
1809
+ except OSError:
1810
+ pass
1811
+ try:
1812
+ os.close(lock_fd)
1813
+ except OSError:
1814
+ pass
1815
+ except Exception as exc:
1816
+ oauth_status = f"err(internal:{type(exc).__name__})"
1817
+ if explain:
1818
+ eprint(f"[hook-tick] internal error: {exc}")
1819
+
1820
+ dur_ms = int((time.monotonic() - start) * 1000)
1821
+
1822
+ # --- Step 7: log line ---
1823
+ line = _hook_tick_format_log_line(
1824
+ event=meta["event"],
1825
+ session=_hook_tick_session_short(meta["session_id"]),
1826
+ ingested=ingested,
1827
+ oauth_status=oauth_status,
1828
+ dur_ms=dur_ms,
1829
+ )
1830
+ _hook_tick_log_line(line)
1831
+ _hook_tick_log_rotate_if_needed()
1832
+
1833
+ # --- Step 9: exit code ---
1834
+ if not explain:
1835
+ # Forked child: skip Python's atexit / argparse / cleanup paths
1836
+ # (they may try to flush already-redirected stdio handles).
1837
+ if forked:
1838
+ os._exit(0)
1839
+ return 0
1840
+ # --explain mapping (Section 3 of spec)
1841
+ if oauth_status == "skipped-no-token":
1842
+ rc = 2
1843
+ elif oauth_status.startswith("err(network") or oauth_status.startswith("err(parse"):
1844
+ rc = 3
1845
+ elif oauth_status.startswith("err(record-usage"):
1846
+ rc = 4
1847
+ elif ingested < 0:
1848
+ rc = 5
1849
+ else:
1850
+ rc = 0
1851
+ # Print --explain decision tree
1852
+ print("[1/4] Local sync (sync_cache)")
1853
+ print(f" → ingested {max(0, ingested)} new entries")
1854
+ print("[2/4] Throttle check")
1855
+ print(f" → throttle file: {c.HOOK_TICK_THROTTLE_PATH}")
1856
+ if pre_age == float("inf"):
1857
+ print(" → mtime: (file absent)")
1858
+ else:
1859
+ print(f" → mtime: {int(pre_age)}s ago")
1860
+ print(f" → threshold: {int(throttle_seconds)}s → {decision}")
1861
+ print("[3/4] OAuth refresh")
1862
+ print(f" → status: {oauth_status}")
1863
+ print(f"[4/4] Log written → {c.HOOK_TICK_LOG_PATH}")
1864
+ print(f"\nDone in {dur_ms} ms.")
1865
+ return rc
1866
+
1867
+
1868
+ def _safe_float(value: Any) -> float:
1869
+ try:
1870
+ num = float(value)
1871
+ except (TypeError, ValueError) as exc:
1872
+ raise ValueError("weeklyPercent must be numeric") from exc
1873
+ if num < 0:
1874
+ raise ValueError("weeklyPercent must be >= 0")
1875
+ if num > 1000:
1876
+ raise ValueError("weeklyPercent is unreasonably large")
1877
+ return num
1878
+
1879
+
1880
+ def _validate_date_optional(value: Any, label: str) -> dt.date | None:
1881
+ if value in (None, ""):
1882
+ return None
1883
+ if not isinstance(value, str):
1884
+ raise ValueError(f"{label} must be a string in YYYY-MM-DD")
1885
+ return parse_date_str(value, label)
1886
+
1887
+
1888
+ @dataclass(frozen=True)
1889
+ class DerivedWeekWindow:
1890
+ week_start: dt.date
1891
+ week_end: dt.date
1892
+ week_start_at: str | None = None
1893
+ week_end_at: str | None = None
1894
+
1895
+
1896
+
1897
+ def _coerce_payload_captured_at(payload: dict[str, Any]) -> tuple[str, dt.datetime]:
1898
+ captured_at_raw = payload.get("capturedAt")
1899
+ if isinstance(captured_at_raw, str) and captured_at_raw.strip():
1900
+ try:
1901
+ return captured_at_raw, parse_iso_datetime(captured_at_raw, "capturedAt")
1902
+ except ValueError:
1903
+ pass
1904
+
1905
+ captured_at = now_utc_iso()
1906
+ return captured_at, parse_iso_datetime(captured_at, "capturedAt")
1907
+
1908
+
1909
+
1910
+ def _derive_week_from_payload(payload: dict[str, Any], week_start_name: str) -> DerivedWeekWindow:
1911
+ ws_at = payload.get("weekStartAt")
1912
+ we_at = payload.get("weekEndAt")
1913
+ if isinstance(ws_at, str) and ws_at.strip() and isinstance(we_at, str) and we_at.strip():
1914
+ start_iso = _canonicalize_optional_iso(ws_at, "weekStartAt")
1915
+ end_iso = _canonicalize_optional_iso(we_at, "weekEndAt")
1916
+ if not start_iso or not end_iso:
1917
+ raise ValueError("weekStartAt/weekEndAt must be non-empty ISO datetime strings")
1918
+ start_at = parse_iso_datetime(start_iso, "weekStartAt")
1919
+ end_at = parse_iso_datetime(end_iso, "weekEndAt")
1920
+ if end_at <= start_at:
1921
+ raise ValueError("weekEndAt must be after weekStartAt")
1922
+ # Anchor the bucket-key date on the canonical UTC ISO, not on
1923
+ # `.date()` of the parsed datetime — `parse_iso_datetime` ends
1924
+ # with `.astimezone()` which converts to host-local TZ. If the
1925
+ # cctally process inherits a TZ whose offset puts the UTC moment
1926
+ # on a different calendar date, `start_at.date()` silently
1927
+ # forks the `week_start_date` column for the SAME physical
1928
+ # subscription week, producing a ghost row that never gets
1929
+ # updated (regression: Israel host briefly running with
1930
+ # TZ=America/Los_Angeles for 7 minutes during refactor work
1931
+ # spawned 18 ghost usage rows + 2 ghost cost rows under
1932
+ # week_start_date='2026-05-08' while every other row sat at
1933
+ # '2026-05-09'). Re-canonicalize to UTC before `.date()` so the
1934
+ # bucket key matches what `cmd_record_usage` writes (it derives
1935
+ # `week_start_date` directly from `resets_at` in UTC).
1936
+ return DerivedWeekWindow(
1937
+ week_start=start_at.astimezone(dt.timezone.utc).date(),
1938
+ week_end=end_at.astimezone(dt.timezone.utc).date(),
1939
+ week_start_at=start_iso,
1940
+ week_end_at=end_iso,
1941
+ )
1942
+
1943
+ ws = _validate_date_optional(payload.get("weekStartDate"), "weekStartDate")
1944
+ we = _validate_date_optional(payload.get("weekEndDate"), "weekEndDate")
1945
+ if ws and we:
1946
+ if we < ws:
1947
+ raise ValueError("weekEndDate must be on or after weekStartDate")
1948
+ return DerivedWeekWindow(week_start=ws, week_end=we)
1949
+ if ws and not we:
1950
+ return DerivedWeekWindow(week_start=ws, week_end=ws + dt.timedelta(days=6))
1951
+
1952
+ captured_raw = payload.get("capturedAt")
1953
+ if isinstance(captured_raw, str) and captured_raw.strip():
1954
+ try:
1955
+ captured_dt = dt.datetime.fromisoformat(captured_raw.replace("Z", "+00:00"))
1956
+ if captured_dt.tzinfo is None:
1957
+ captured_dt = captured_dt.replace(tzinfo=dt.timezone.utc)
1958
+ except ValueError:
1959
+ # internal fallback: host-local intentional
1960
+ captured_dt = dt.datetime.now().astimezone()
1961
+ else:
1962
+ # internal fallback: host-local intentional
1963
+ captured_dt = dt.datetime.now().astimezone()
1964
+
1965
+ start, end = compute_week_bounds(captured_dt, week_start_name)
1966
+ return DerivedWeekWindow(week_start=start, week_end=end)
1967
+
1968
+
1969
+ def insert_usage_snapshot(payload: dict[str, Any], week_start_name: str) -> dict[str, Any]:
1970
+ weekly_percent = _safe_float(payload.get("weeklyPercent"))
1971
+ captured_at, captured_at_dt = _coerce_payload_captured_at(payload)
1972
+
1973
+ page_url = payload.get("pageUrl") if isinstance(payload.get("pageUrl"), str) else None
1974
+ source = payload.get("source") if isinstance(payload.get("source"), str) else "userscript"
1975
+
1976
+ five_hour_percent = payload.get("fiveHourPercent")
1977
+ if five_hour_percent is not None:
1978
+ five_hour_percent = float(five_hour_percent)
1979
+ five_hour_resets_at = payload.get("fiveHourResetsAt")
1980
+ if five_hour_resets_at is not None:
1981
+ five_hour_resets_at = str(five_hour_resets_at)
1982
+ five_hour_window_key = payload.get("fiveHourWindowKey")
1983
+ if five_hour_window_key is not None:
1984
+ try:
1985
+ five_hour_window_key = int(five_hour_window_key)
1986
+ except (TypeError, ValueError) as exc:
1987
+ # Loud-skip on first failure only (module-level guard) so a
1988
+ # misbehaving caller doesn't spam the log on every insert.
1989
+ global _logged_window_key_coerce_failure
1990
+ if not _logged_window_key_coerce_failure:
1991
+ # Use the local (already extracted from payload at line
1992
+ # ~13858) instead of re-reading; payload is mutable and
1993
+ # could in principle change between extraction and the
1994
+ # except branch.
1995
+ eprint(
1996
+ f"[record-usage] fiveHourWindowKey coerce failed "
1997
+ f"(got {type(five_hour_window_key).__name__}: "
1998
+ f"{five_hour_window_key!r}); "
1999
+ f"5h DB clamp will be skipped for this row: {exc}"
2000
+ )
2001
+ _logged_window_key_coerce_failure = True
2002
+ five_hour_window_key = None
2003
+
2004
+ conn = open_db()
2005
+ try:
2006
+ week_window = _derive_week_from_payload(payload, week_start_name)
2007
+
2008
+ # Use the canonical boundary already established for this week_start_date.
2009
+ # This prevents relative-reset drift from creating duplicate weeks.
2010
+ date_str = week_window.week_start.isoformat()
2011
+ canon_start, canon_end = _get_canonical_boundary_for_date(conn, date_str)
2012
+ if canon_start and canon_end:
2013
+ week_window = DerivedWeekWindow(
2014
+ week_start=week_window.week_start,
2015
+ week_end=week_window.week_end,
2016
+ week_start_at=canon_start,
2017
+ week_end_at=canon_end,
2018
+ )
2019
+
2020
+ week_start = week_window.week_start
2021
+ week_end = week_window.week_end
2022
+
2023
+ cur = conn.execute(
2024
+ """
2025
+ INSERT INTO weekly_usage_snapshots
2026
+ (
2027
+ captured_at_utc,
2028
+ week_start_date,
2029
+ week_end_date,
2030
+ week_start_at,
2031
+ week_end_at,
2032
+ weekly_percent,
2033
+ page_url,
2034
+ source,
2035
+ payload_json,
2036
+ five_hour_percent,
2037
+ five_hour_resets_at,
2038
+ five_hour_window_key
2039
+ )
2040
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2041
+ """,
2042
+ (
2043
+ captured_at,
2044
+ week_start.isoformat(),
2045
+ week_end.isoformat(),
2046
+ week_window.week_start_at,
2047
+ week_window.week_end_at,
2048
+ weekly_percent,
2049
+ page_url,
2050
+ source,
2051
+ json.dumps(payload, separators=(",", ":")),
2052
+ five_hour_percent,
2053
+ five_hour_resets_at,
2054
+ five_hour_window_key,
2055
+ ),
2056
+ )
2057
+ conn.commit()
2058
+ snapshot_id = int(cur.lastrowid)
2059
+ finally:
2060
+ conn.close()
2061
+
2062
+ out = {
2063
+ "id": snapshot_id,
2064
+ "capturedAt": captured_at,
2065
+ "weekStartDate": week_start.isoformat(),
2066
+ "weekEndDate": week_end.isoformat(),
2067
+ "weeklyPercent": weekly_percent,
2068
+ }
2069
+ if week_window.week_start_at:
2070
+ out["weekStartAt"] = week_window.week_start_at
2071
+ if week_window.week_end_at:
2072
+ out["weekEndAt"] = week_window.week_end_at
2073
+ if isinstance(payload.get("resetText"), str):
2074
+ out["resetText"] = payload["resetText"]
2075
+ if five_hour_percent is not None:
2076
+ out["fiveHourPercent"] = five_hour_percent
2077
+ if five_hour_resets_at is not None:
2078
+ out["fiveHourResetsAt"] = five_hour_resets_at
2079
+ if five_hour_window_key is not None:
2080
+ out["fiveHourWindowKey"] = five_hour_window_key
2081
+ return out
2082
+
2083
+
2084
+ def _saved_dict_from_usage_row(row: sqlite3.Row) -> dict[str, Any]:
2085
+ """Mirror ``insert_usage_snapshot``'s output dict from an existing
2086
+ weekly_usage_snapshots row. Used by ``cmd_record_usage``'s dedup
2087
+ self-heal path so ``maybe_record_milestone`` and
2088
+ ``maybe_update_five_hour_block`` can re-run on the latest snapshot
2089
+ when an earlier invocation was killed between snapshot insert and
2090
+ milestone insert (e.g. CC self-update kill window, 2026-05-08).
2091
+
2092
+ Field omissions match ``insert_usage_snapshot``: keys whose values
2093
+ would be ``None`` are not emitted, so downstream ``saved.get(...)``
2094
+ callers see the same shape they'd see on a fresh insert.
2095
+
2096
+ Note: ``resetText`` (the only userscript-payload-only key
2097
+ ``insert_usage_snapshot`` re-emits in its output dict) is
2098
+ intentionally omitted — no downstream ``saved``-dict consumer in
2099
+ this codebase reads it. ``pageUrl`` is a column on
2100
+ ``weekly_usage_snapshots`` but is never propagated into the output
2101
+ dict either path.
2102
+ """
2103
+ out: dict[str, Any] = {
2104
+ "id": int(row["id"]),
2105
+ "capturedAt": row["captured_at_utc"],
2106
+ "weekStartDate": row["week_start_date"],
2107
+ "weekEndDate": row["week_end_date"],
2108
+ "weeklyPercent": float(row["weekly_percent"]),
2109
+ }
2110
+ if row["week_start_at"] is not None:
2111
+ out["weekStartAt"] = row["week_start_at"]
2112
+ if row["week_end_at"] is not None:
2113
+ out["weekEndAt"] = row["week_end_at"]
2114
+ if row["five_hour_percent"] is not None:
2115
+ out["fiveHourPercent"] = float(row["five_hour_percent"])
2116
+ if row["five_hour_resets_at"] is not None:
2117
+ out["fiveHourResetsAt"] = row["five_hour_resets_at"]
2118
+ if row["five_hour_window_key"] is not None:
2119
+ out["fiveHourWindowKey"] = int(row["five_hour_window_key"])
2120
+ return out