cctally 1.20.1 → 1.20.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/bin/_cctally_record.py +83 -4
- package/bin/cctally +34 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,16 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.20.3] - 2026-05-28
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **The dashboard's Blocks panel no longer renders the just-started active block with a `~` heuristic marker, and the previous block no longer silently disappears from the panel ~20 minutes after a 5h reset.** Both symptoms came from the same root cause in `_select_non_overlapping_recorded_windows`: when two genuinely-adjacent canonical 5h windows had `resets_at` values whose 10-minute-floored keys landed less than 5h apart (e.g. OLD `R=09:00:01Z` → floor `09:00`, NEW `R=13:59:59Z` → floor `13:50` — a 4h 50m floored-distance for a real block-pair with a 2-second sub-second overlap on the boundary), the weighted-interval-scheduling DP treated them as conflicting and dropped the lighter-weighted one. At `t≈0` after a reset that meant the NEW canonical window vanished from the selector's output, so the dashboard's Blocks panel partitioned NEW-window entries into the heuristic leftover bucket and rendered them with `anchor='heuristic'` and a `_floor_to_hour` `start_at` (the `~` chip in `BlocksPanel.tsx:33-34`); ~20 minutes later, once the NEW window's snapshot count caught up, the DP would flip and drop OLD instead, making the previous block silently disappear. CLI `cctally blocks --active` was unaffected (it queries `five_hour_blocks` directly with `is_closed=0`, bypassing this scheduler). Fix: any item carrying the canonical-source weight overlay (`_CANONICAL_WEIGHT_THRESHOLD = 1000`) is force-restored to the result set if the DP dropped it — `maybe_update_five_hour_block` already deduplicated via `_canonical_5h_window_key` pre-insert, so two canonical rows are by definition non-overlapping physically and don't need re-arbitration on a floor-distance check. The DP still runs over the full item set (canonical + raw), so a non-canonical phantom R adjacent to a canonical anchor in a different floor bucket continues to lose by weight comparison; the bypass only restores canonical items, never adds back a raw-only phantom. (#116)
|
|
12
|
+
|
|
13
|
+
## [1.20.2] - 2026-05-28
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- **`cctally record-usage` now rejects implausibly-dated `--resets-at` epochs before they can poison the SQLite stats history, and refuses to charge already-expired 5h windows against the previous block.** A real 366-day-off statusline payload silently wrote a phantom-year row that displaced subsequent weeks in `cctally report` / `cctally-dollar-per-percent`. The new guard validates both arguments before any `datetime.fromtimestamp()` call (so ms-epochs, year-off bugs, and negative epochs reject gracefully instead of crashing): `--resets-at ∈ [now − 30d, now + 8d]` exits 2 on miss (preserving the documented "manually replay a missed snapshot" path within 30d), and `--five-hour-resets-at ∈ [now − 10m, now + 6h]` drops the 5h fields and continues with the weekly snapshot on miss (so a stale-5h replay still writes its weekly row). The 10-minute past slack matches `_FIVE_HOUR_JITTER_FLOOR_SECONDS` — wide enough for boundary jitter / clock skew, tight enough that lagged status-line responses around a 5h rollover can no longer pollute the prior block's `_compute_block_totals` with entries that belong to the new window. The refresh-layer wrapper (`_refresh_usage_inproc`) surfaces guard misses as `status="record_failed"` so the dashboard / CLI don't silently report success on a dropped payload. (#112)
|
|
17
|
+
|
|
8
18
|
## [1.20.1] - 2026-05-28
|
|
9
19
|
|
|
10
20
|
### Fixed
|
package/bin/_cctally_record.py
CHANGED
|
@@ -171,6 +171,7 @@ from _cctally_core import (
|
|
|
171
171
|
make_week_ref,
|
|
172
172
|
_get_alerts_config,
|
|
173
173
|
_AlertsConfigError,
|
|
174
|
+
_command_as_of,
|
|
174
175
|
)
|
|
175
176
|
from _lib_five_hour import _canonical_5h_window_key
|
|
176
177
|
from _lib_pricing import _calculate_entry_cost
|
|
@@ -300,6 +301,35 @@ def _hook_tick_make_mock_refresh(*args, **kwargs):
|
|
|
300
301
|
# and no dependency on cctally's module instance.
|
|
301
302
|
_PERCENT_NORMALIZE_DECIMALS = 10
|
|
302
303
|
|
|
304
|
+
# Plausibility band for --resets-at / --five-hour-resets-at (issue #112).
|
|
305
|
+
# Out-of-band epochs are guarded at cmd_record_usage ingress before any
|
|
306
|
+
# datetime.fromtimestamp() call, so absurd values (ms-epochs, year-off
|
|
307
|
+
# bugs) can't crash the call or stamp phantom-week rows.
|
|
308
|
+
#
|
|
309
|
+
# The two bands are deliberately asymmetric and reject differently:
|
|
310
|
+
#
|
|
311
|
+
# --resets-at: 30d past / 8d future. Wide past slack preserves the
|
|
312
|
+
# documented "manually replay a missed snapshot" use case
|
|
313
|
+
# (docs/commands/record-usage.md). Out-of-band → return 2
|
|
314
|
+
# (entire call rejected, no weekly row written).
|
|
315
|
+
#
|
|
316
|
+
# --five-hour-resets-at: 10m past / 6h future. Tight past slack is
|
|
317
|
+
# intentional: maybe_update_five_hour_block computes
|
|
318
|
+
# _compute_block_totals(block_start_at, captured_at_dt) where
|
|
319
|
+
# captured_at_dt ≈ now and block_start_at = resets_at - 5h, so
|
|
320
|
+
# accepting an already-expired 5h resets_at pollutes the prior
|
|
321
|
+
# block with session_entries that belong to the NEXT block. 10m
|
|
322
|
+
# matches _FIVE_HOUR_JITTER_FLOOR_SECONDS (the canonical-window-key
|
|
323
|
+
# jitter floor) — enough for boundary jitter / clock skew, not
|
|
324
|
+
# enough for cross-block pollution. Out-of-band → drop the 5h
|
|
325
|
+
# fields and continue (the weekly snapshot still writes), so a
|
|
326
|
+
# manual replay with stale 5h flags doesn't fail-close on a
|
|
327
|
+
# documented recovery path.
|
|
328
|
+
_RECORD_USAGE_WEEK_PAST_SLACK_S = 30 * 86400
|
|
329
|
+
_RECORD_USAGE_WEEK_FUTURE_BAND_S = 8 * 86400
|
|
330
|
+
_RECORD_USAGE_5H_PAST_SLACK_S = 600 # 10 min; matches _FIVE_HOUR_JITTER_FLOOR_SECONDS
|
|
331
|
+
_RECORD_USAGE_5H_FUTURE_BAND_S = 6 * 3600
|
|
332
|
+
|
|
303
333
|
|
|
304
334
|
# One-shot guard so a misbehaving caller passing a non-int
|
|
305
335
|
# fiveHourWindowKey doesn't spam the log on every insert. Set on first
|
|
@@ -1273,6 +1303,24 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
|
|
|
1273
1303
|
weekly_percent = _normalize_percent(args.percent)
|
|
1274
1304
|
resets_at = int(args.resets_at)
|
|
1275
1305
|
|
|
1306
|
+
# Plausibility guard (issue #112). Band-check epochs BEFORE any
|
|
1307
|
+
# dt.datetime.fromtimestamp() call so absurd values (ms-epoch,
|
|
1308
|
+
# year-off bugs, negative) reject gracefully instead of raising
|
|
1309
|
+
# OverflowError. Reject path returns exit 2 so
|
|
1310
|
+
# _refresh_usage_inproc maps it to status="record_failed" instead
|
|
1311
|
+
# of silently reporting success on a dropped payload.
|
|
1312
|
+
now_dt = _command_as_of()
|
|
1313
|
+
now_epoch = int(now_dt.timestamp())
|
|
1314
|
+
if not (now_epoch - _RECORD_USAGE_WEEK_PAST_SLACK_S
|
|
1315
|
+
<= resets_at
|
|
1316
|
+
<= now_epoch + _RECORD_USAGE_WEEK_FUTURE_BAND_S):
|
|
1317
|
+
eprint(
|
|
1318
|
+
f"[record-usage] rejecting --resets-at={resets_at}: outside "
|
|
1319
|
+
f"plausibility band [now-30d, now+8d]; "
|
|
1320
|
+
f"now={now_epoch} ({now_dt.isoformat()}). No row written."
|
|
1321
|
+
)
|
|
1322
|
+
return 2
|
|
1323
|
+
|
|
1276
1324
|
five_hour_percent: float | None = None
|
|
1277
1325
|
five_hour_resets_at_str: str | None = None
|
|
1278
1326
|
five_hour_window_key: int | None = None
|
|
@@ -1281,9 +1329,35 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
|
|
|
1281
1329
|
five_hour_percent = _normalize_percent(args.five_hour_percent)
|
|
1282
1330
|
if args.five_hour_resets_at is not None:
|
|
1283
1331
|
five_hour_resets_at_epoch = int(args.five_hour_resets_at)
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1332
|
+
# Band-check BEFORE fromtimestamp (issue #112).
|
|
1333
|
+
#
|
|
1334
|
+
# Out-of-band 5h is non-fatal: drop the 5h fields and continue
|
|
1335
|
+
# so the weekly snapshot still writes. Two motivating cases:
|
|
1336
|
+
# (a) docs' manual-replay path (record-usage.md) emits the
|
|
1337
|
+
# original status-line args verbatim, including stale 5h
|
|
1338
|
+
# flags — rejecting the whole call there contradicts the
|
|
1339
|
+
# wider 30d weekly past slack.
|
|
1340
|
+
# (b) An already-expired 5h resets_at would pollute the prior
|
|
1341
|
+
# block's totals (block_start_at = resets_at - 5h →
|
|
1342
|
+
# _compute_block_totals charges entries past the real
|
|
1343
|
+
# reset to this block). Dropping the 5h portion here
|
|
1344
|
+
# skips maybe_update_five_hour_block entirely.
|
|
1345
|
+
if not (now_epoch - _RECORD_USAGE_5H_PAST_SLACK_S
|
|
1346
|
+
<= five_hour_resets_at_epoch
|
|
1347
|
+
<= now_epoch + _RECORD_USAGE_5H_FUTURE_BAND_S):
|
|
1348
|
+
eprint(
|
|
1349
|
+
f"[record-usage] dropping --five-hour-resets-at="
|
|
1350
|
+
f"{five_hour_resets_at_epoch}: outside plausibility band "
|
|
1351
|
+
f"[now-10m, now+6h]; now={now_epoch} "
|
|
1352
|
+
f"({now_dt.isoformat()}). Weekly snapshot still written; "
|
|
1353
|
+
f"5h fields will be NULL."
|
|
1354
|
+
)
|
|
1355
|
+
five_hour_percent = None
|
|
1356
|
+
five_hour_resets_at_epoch = None
|
|
1357
|
+
else:
|
|
1358
|
+
five_hour_resets_at_str = dt.datetime.fromtimestamp(
|
|
1359
|
+
five_hour_resets_at_epoch, tz=dt.timezone.utc
|
|
1360
|
+
).isoformat(timespec="seconds")
|
|
1287
1361
|
# five_hour_window_key derivation is deferred until after open_db()
|
|
1288
1362
|
# so we can pass the most-recent stored sample as the prior anchor.
|
|
1289
1363
|
# See _canonical_5h_window_key docstring (spec invariant #3:
|
|
@@ -1406,7 +1480,12 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
|
|
|
1406
1480
|
prior["week_end_at"], "record.prior"
|
|
1407
1481
|
)
|
|
1408
1482
|
prior_pct = prior["weekly_percent"]
|
|
1409
|
-
|
|
1483
|
+
# Use _command_as_of() so CCTALLY_AS_OF pins the predicate
|
|
1484
|
+
# for tests (no behavior change in production — falls back
|
|
1485
|
+
# to wall-clock when the env hook is unset). This makes
|
|
1486
|
+
# mid-week-reset detection deterministic against fixtures
|
|
1487
|
+
# whose `prior_end` is a fixed historical instant.
|
|
1488
|
+
now_utc = _command_as_of()
|
|
1410
1489
|
if prior_end_canon and prior_end_canon != cur_end_canon:
|
|
1411
1490
|
prior_end_dt = parse_iso_datetime(prior_end_canon, "prior.week_end_at")
|
|
1412
1491
|
# Fire only when (a) prior window was still in the FUTURE
|
package/bin/cctally
CHANGED
|
@@ -3951,11 +3951,21 @@ def _resolve_block_selector(
|
|
|
3951
3951
|
return dict(row) if row else None
|
|
3952
3952
|
|
|
3953
3953
|
|
|
3954
|
+
# Items in `_select_non_overlapping_recorded_windows` whose weight is
|
|
3955
|
+
# >= this threshold are treated as canonical (sourced from
|
|
3956
|
+
# ``five_hour_blocks``) and bypass the 5h non-overlap constraint. Must
|
|
3957
|
+
# stay in sync with the ``counts[snapped] = counts.get(snapped, 0) + 1000``
|
|
3958
|
+
# overlay in ``_load_recorded_five_hour_windows`` — same constant, same
|
|
3959
|
+
# meaning. Issue #116.
|
|
3960
|
+
_CANONICAL_WEIGHT_THRESHOLD = 1000
|
|
3961
|
+
|
|
3962
|
+
|
|
3954
3963
|
def _select_non_overlapping_recorded_windows(
|
|
3955
3964
|
items: list[tuple[dt.datetime, int]],
|
|
3956
3965
|
) -> list[dt.datetime]:
|
|
3957
3966
|
"""Pick the max-weight subset of recorded ``R`` values that respect
|
|
3958
|
-
the 5h non-overlap constraint
|
|
3967
|
+
the 5h non-overlap constraint, with canonical anchors guaranteed
|
|
3968
|
+
to survive.
|
|
3959
3969
|
|
|
3960
3970
|
Anthropic 5h windows cannot truly overlap: the next window only
|
|
3961
3971
|
opens once the previous one resets, so consecutive real ``R``
|
|
@@ -3968,6 +3978,23 @@ def _select_non_overlapping_recorded_windows(
|
|
|
3968
3978
|
snapshots: the subset that maximizes total support wins. Tie-break
|
|
3969
3979
|
in the take branch favors including more ``R`` values.
|
|
3970
3980
|
|
|
3981
|
+
Canonical bypass (issue #116): any item with weight at least
|
|
3982
|
+
``_CANONICAL_WEIGHT_THRESHOLD`` came from the authoritative
|
|
3983
|
+
``five_hour_blocks`` rollup (the caller overlays at +1000).
|
|
3984
|
+
``maybe_update_five_hour_block`` already deduped via
|
|
3985
|
+
``_canonical_5h_window_key`` pre-insert, so two canonical rows are
|
|
3986
|
+
by definition non-overlapping physically — they only appear "in
|
|
3987
|
+
conflict" here when their 10-min-floored keys land less than
|
|
3988
|
+
``BLOCK_DURATION`` apart, which happens at every real reset
|
|
3989
|
+
boundary when Anthropic's ``resets_at`` jitters sub-second across
|
|
3990
|
+
the boundary (e.g. OLD ``R=09:00:01Z`` floors to ``09:00``, NEW
|
|
3991
|
+
``R=13:59:59Z`` floors to ``13:50`` — 4h 50m floored-distance for
|
|
3992
|
+
a genuinely-adjacent block pair). The DP still runs over the full
|
|
3993
|
+
item set so non-canonical phantoms next to a canonical anchor get
|
|
3994
|
+
dropped by weight comparison; the canonical-bypass only force-
|
|
3995
|
+
restores canonical items the DP dropped, never adds back a raw-
|
|
3996
|
+
only phantom.
|
|
3997
|
+
|
|
3971
3998
|
Args:
|
|
3972
3999
|
items: ``(R, support_count)`` pairs.
|
|
3973
4000
|
|
|
@@ -4012,6 +4039,11 @@ def _select_non_overlapping_recorded_windows(
|
|
|
4012
4039
|
else:
|
|
4013
4040
|
i -= 1
|
|
4014
4041
|
chosen.reverse()
|
|
4042
|
+
# Canonical bypass: force-restore any canonical-weight anchor the DP
|
|
4043
|
+
# dropped (issue #116). Keep the result sorted ascending.
|
|
4044
|
+
canonical = {R for R, w in items_sorted if w >= _CANONICAL_WEIGHT_THRESHOLD}
|
|
4045
|
+
if canonical and not canonical.issubset(chosen):
|
|
4046
|
+
return sorted(set(chosen) | canonical)
|
|
4015
4047
|
return chosen
|
|
4016
4048
|
|
|
4017
4049
|
|
|
@@ -4297,7 +4329,7 @@ def _load_recorded_five_hour_windows(
|
|
|
4297
4329
|
# (only credit-truncated entries land there).
|
|
4298
4330
|
if snapped in block_start_overrides:
|
|
4299
4331
|
truncated_anchors.add(snapped)
|
|
4300
|
-
counts[snapped] = counts.get(snapped, 0) +
|
|
4332
|
+
counts[snapped] = counts.get(snapped, 0) + _CANONICAL_WEIGHT_THRESHOLD
|
|
4301
4333
|
|
|
4302
4334
|
non_truncated_items = [
|
|
4303
4335
|
(a, w) for a, w in counts.items() if a not in truncated_anchors
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cctally",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.3",
|
|
4
4
|
"description": "Claude Code usage tracker and local dashboard for Pro/Max subscription limits - weekly cost-per-percent trend, quota forecasts, threshold alerts. ccusage-compatible.",
|
|
5
5
|
"homepage": "https://github.com/omrikais/cctally",
|
|
6
6
|
"repository": {
|