cctally 1.44.2 → 1.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/bin/_cctally_config.py +82 -0
- package/bin/_cctally_dashboard.py +94 -2
- package/bin/_cctally_five_hour.py +58 -35
- package/bin/_lib_conversation_query.py +129 -4
- package/dashboard/static/assets/index-Ci-5UHk3.js +68 -0
- package/dashboard/static/assets/{index-Drpkfv6k.css → index-Dgq5Vvml.css} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +1 -1
- package/dashboard/static/assets/index-L_FSvwQH.js +0 -68
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,17 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.45.0] - 2026-06-15
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Conversation viewer: the dashboard now marks any assistant turn that suffered a **prompt-cache failure** — where Claude re-created the bulk of its cached prompt prefix instead of reading it (most often on the first turn after you send a new message — usually well inside the cache TTL, not just on long idle gaps), re-billing those tokens at the higher cache-write rate. The failing turn gets an amber `⚡ CACHE REBUILT · <tokens> · +$<wasted>` chip in its header, and the conversation outline gains a matching amber landmark, a `⚡` quick-jump button (`c` / `C` keys), and a "Cache" count in the at-a-glance stats card. Detection is conservative (per model + thread: `cache_read` collapses below half its running high-water mark **and** ≥75% of the turn's context is freshly re-created, with the unavoidable first cache prime, model switches, and context compaction all excluded), so a typical session shows about one marker. The markers are on by default and can be turned off via the new `dashboard.cache_failure_markers` config key (or the dashboard's Settings → "Conversation viewer" toggle). The wasted-cost figure is a display-only estimate recomputed per read — no new database tables and no migration.
|
|
12
|
+
|
|
13
|
+
## [1.44.3] - 2026-06-15
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- `blocks` and the dashboard Blocks panel no longer split a single 5-hour window into two overlapping blocks when Anthropic's `rate_limits.5h.resets_at` jitters by one second across a 10-minute boundary (e.g. `20:39:59` vs `20:40:00`). The recorded-window loader identified each window by re-flooring the raw, jittery reset string to 10-minute buckets, so a 1-second straddle landed in two different buckets and rendered as two adjacent ~5h rows (with the bulk of the cost in the spurious *earlier* row, e.g. a phantom `6:30 p.m.` block alongside the real `6:40 p.m.` one); the weighted non-overlap scheduler then kept the phantom because its heavy canonical neighbor outweighed the real chain by a single point, and the #116 force-restore added the real anchor back without evicting the phantom. The loader now keys each window by the canonical, jitter-collapsed `five_hour_window_key` the `record-usage` path already stores — so a straddle collapses to one bucket and both the CLI and the dashboard show a single block with the correct total. The committed `five_hour_blocks` rollup was always correct; only this display path was affected, and it self-corrects on the next read with no migration (#201).
|
|
17
|
+
- **Internal (test infra, no user-facing change): the per-migration golden byte-idempotency guard (#197) now passes on the public Linux CI matrix.** That guard compared each rebuilt golden to the committed `.sqlite` byte-for-byte, normalizing only the 4-byte writer-version header — but SQLite's whole on-disk page layout (page allocation / freelist / B-tree balancing) is not portable across library versions, and FTS5 stores its inverted index as version-dependent binary segments, so the goldens (generated on the maintainer's macOS SQLite 3.53.2) byte-mismatched the public Linux CI's SQLite 3.45.1 even though their logical content is identical. v1.44.2 was the guard's first exposure to a non-macOS SQLite (the matrix only runs on tag push / weekly cron / dispatch), so it went red. The guard is now two-tier: STRICT byte-idempotency on the goldens' origin SQLite version (the maintainer's machine, the full #197 guarantee that a regen won't dirty the committed file), falling back to a version-portable SEMANTIC fingerprint — `user_version` / `application_id` plus the canonical SQL dump of every real table, taken from an in-memory copy with the FTS5 virtual tables dropped — on any other SQLite version. Still catches real builder/data drift (verified non-vacuous); nothing to do on upgrade (#199).
|
|
18
|
+
|
|
8
19
|
## [1.44.2] - 2026-06-14
|
|
9
20
|
|
|
10
21
|
### Fixed
|
package/bin/_cctally_config.py
CHANGED
|
@@ -302,6 +302,7 @@ ALLOWED_CONFIG_KEYS = (
|
|
|
302
302
|
"alerts.command_template",
|
|
303
303
|
"dashboard.bind",
|
|
304
304
|
"dashboard.expose_transcripts",
|
|
305
|
+
"dashboard.cache_failure_markers",
|
|
305
306
|
"update.check.enabled",
|
|
306
307
|
"update.check.ttl_hours",
|
|
307
308
|
"statusline.visual_burn_rate",
|
|
@@ -478,6 +479,28 @@ def _config_known_value(config: dict, key: str) -> "object":
|
|
|
478
479
|
except ValueError:
|
|
479
480
|
return False
|
|
480
481
|
return False
|
|
482
|
+
if key == "dashboard.cache_failure_markers":
|
|
483
|
+
# Boolean opt-OUT (spec §5). Default TRUE — absence is treated as ON
|
|
484
|
+
# (unlike dashboard.expose_transcripts, an opt-IN default of False).
|
|
485
|
+
# A hand-edited junk value surfaces the True default rather than
|
|
486
|
+
# crashing (mirrors dashboard.bind / expose_transcripts).
|
|
487
|
+
block = config.get("dashboard") if isinstance(config, dict) else None
|
|
488
|
+
if not isinstance(block, dict):
|
|
489
|
+
block = {}
|
|
490
|
+
stored = block.get("cache_failure_markers")
|
|
491
|
+
if stored is None:
|
|
492
|
+
return True
|
|
493
|
+
if isinstance(stored, bool):
|
|
494
|
+
return stored
|
|
495
|
+
# Only str spellings are normalizable; any other JSON scalar surfaces
|
|
496
|
+
# the default (the shared normalizer's .strip() would AttributeError on
|
|
497
|
+
# a bare int — uncaught by `except ValueError`).
|
|
498
|
+
if isinstance(stored, str):
|
|
499
|
+
try:
|
|
500
|
+
return c._normalize_alerts_enabled_value(stored)
|
|
501
|
+
except ValueError:
|
|
502
|
+
return True
|
|
503
|
+
return True
|
|
481
504
|
if key in ("update.check.enabled", "update.check.ttl_hours"):
|
|
482
505
|
# Defaults mirror `_is_update_check_due` (True / 24 hours).
|
|
483
506
|
# Hand-edited junk surfaces as the default — matches dashboard.bind.
|
|
@@ -864,6 +887,49 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
864
887
|
f"{'true' if canonical else 'false'}"
|
|
865
888
|
)
|
|
866
889
|
return 0
|
|
890
|
+
if key == "dashboard.cache_failure_markers":
|
|
891
|
+
# Same read-modify-write posture as dashboard.expose_transcripts:
|
|
892
|
+
# validate first, then write under config_writer_lock with
|
|
893
|
+
# _load_config_unlocked (load_config inside the writer-lock
|
|
894
|
+
# self-deadlocks — fcntl.flock is per-fd). Preserves sibling
|
|
895
|
+
# dashboard.bind / dashboard.expose_transcripts. Reuse the shared
|
|
896
|
+
# bool-normalizer; catch + re-message with the actual key name (it
|
|
897
|
+
# hardcodes "alerts.enabled" in its ValueError text).
|
|
898
|
+
try:
|
|
899
|
+
canonical = c._normalize_alerts_enabled_value(raw)
|
|
900
|
+
except ValueError:
|
|
901
|
+
print(
|
|
902
|
+
f"cctally: invalid boolean value for "
|
|
903
|
+
f"dashboard.cache_failure_markers: "
|
|
904
|
+
f"{raw!r} (expected true|false|yes|no|1|0|on|off)",
|
|
905
|
+
file=sys.stderr,
|
|
906
|
+
)
|
|
907
|
+
return 2
|
|
908
|
+
with config_writer_lock():
|
|
909
|
+
config = _load_config_unlocked()
|
|
910
|
+
existing = config.get("dashboard")
|
|
911
|
+
if existing is not None and not isinstance(existing, dict):
|
|
912
|
+
print(
|
|
913
|
+
"cctally: dashboard config error: dashboard must be an object",
|
|
914
|
+
file=sys.stderr,
|
|
915
|
+
)
|
|
916
|
+
return 2
|
|
917
|
+
block = dict(existing or {})
|
|
918
|
+
block["cache_failure_markers"] = canonical
|
|
919
|
+
config["dashboard"] = block
|
|
920
|
+
save_config(config)
|
|
921
|
+
if getattr(args, "emit_json", False):
|
|
922
|
+
print(
|
|
923
|
+
json.dumps(
|
|
924
|
+
{"dashboard": {"cache_failure_markers": canonical}}, indent=2
|
|
925
|
+
)
|
|
926
|
+
)
|
|
927
|
+
else:
|
|
928
|
+
print(
|
|
929
|
+
f"dashboard.cache_failure_markers="
|
|
930
|
+
f"{'true' if canonical else 'false'}"
|
|
931
|
+
)
|
|
932
|
+
return 0
|
|
867
933
|
if key in (
|
|
868
934
|
"statusline.visual_burn_rate",
|
|
869
935
|
"statusline.cost_source",
|
|
@@ -1236,6 +1302,22 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
|
|
|
1236
1302
|
save_config(config)
|
|
1237
1303
|
# idempotent: silent on missing key
|
|
1238
1304
|
return 0
|
|
1305
|
+
if key == "dashboard.cache_failure_markers":
|
|
1306
|
+
# Mirror the dashboard.expose_transcripts unset branch: drop only the
|
|
1307
|
+
# cache_failure_markers leaf; if the dashboard block ends up empty, drop
|
|
1308
|
+
# the parent too so config.json stays tidy. Sibling dashboard.bind /
|
|
1309
|
+
# expose_transcripts survive. Unsetting restores the True (opt-out)
|
|
1310
|
+
# default at read time.
|
|
1311
|
+
with config_writer_lock():
|
|
1312
|
+
config = _load_config_unlocked()
|
|
1313
|
+
block = config.get("dashboard")
|
|
1314
|
+
if isinstance(block, dict) and "cache_failure_markers" in block:
|
|
1315
|
+
del block["cache_failure_markers"]
|
|
1316
|
+
if not block:
|
|
1317
|
+
config.pop("dashboard", None)
|
|
1318
|
+
save_config(config)
|
|
1319
|
+
# idempotent: silent on missing key
|
|
1320
|
+
return 0
|
|
1239
1321
|
if key in (
|
|
1240
1322
|
"statusline.visual_burn_rate",
|
|
1241
1323
|
"statusline.cost_source",
|
|
@@ -4912,6 +4912,18 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
4912
4912
|
"notifier": _alerts_cfg.get("notifier", "auto"),
|
|
4913
4913
|
"command_configured": _alerts_cfg.get("command_template") is not None,
|
|
4914
4914
|
}
|
|
4915
|
+
# Dashboard render-prefs mirror (cache-failure-markers opt-out, spec §5).
|
|
4916
|
+
# Reuses the single `_cfg_for_alerts = load_config()` read above (no extra
|
|
4917
|
+
# FS hit on the hot path). Default true — absence is treated as ON (opt-out,
|
|
4918
|
+
# not opt-in); a hand-edited non-bool surfaces the default. The on/off toggle
|
|
4919
|
+
# is honored entirely at client render time; the kernel + API always emit the
|
|
4920
|
+
# marker data.
|
|
4921
|
+
_dash_cfg = _cfg_for_alerts.get("dashboard") if isinstance(
|
|
4922
|
+
_cfg_for_alerts.get("dashboard"), dict) else {}
|
|
4923
|
+
_cfm = _dash_cfg.get("cache_failure_markers", True)
|
|
4924
|
+
dashboard_prefs = {
|
|
4925
|
+
"cache_failure_markers": _cfm if isinstance(_cfm, bool) else True,
|
|
4926
|
+
}
|
|
4915
4927
|
|
|
4916
4928
|
# Mirror update-state.json + update-suppress.json into the envelope
|
|
4917
4929
|
# so the dashboard's amber "Update available" badge repaints from
|
|
@@ -5149,6 +5161,12 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
5149
5161
|
"alerts": alerts_array,
|
|
5150
5162
|
"alerts_settings": alerts_settings,
|
|
5151
5163
|
|
|
5164
|
+
# Dashboard render-prefs mirror (cache-failure-markers opt-out, spec
|
|
5165
|
+
# §5). Additive optional, like alerts_settings — envelope_version
|
|
5166
|
+
# stays at 2. The client derives `markersEnabled` from this, defaulting
|
|
5167
|
+
# to true when the field is undefined (older server / first tick).
|
|
5168
|
+
"dashboard_prefs": dashboard_prefs,
|
|
5169
|
+
|
|
5152
5170
|
# update-subcommand SSE mirror (see comment above the
|
|
5153
5171
|
# `_load_update_state()` block). Shape matches GET
|
|
5154
5172
|
# /api/update/status's payload (`{state, suppress}`) so the
|
|
@@ -5685,7 +5703,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5685
5703
|
|
|
5686
5704
|
# Reject unknown top-level keys (forward-compat hygiene).
|
|
5687
5705
|
allowed_top_keys = {
|
|
5688
|
-
"display", "alerts", "update", "cache_report", "budget",
|
|
5706
|
+
"display", "alerts", "update", "cache_report", "budget", "dashboard",
|
|
5689
5707
|
}
|
|
5690
5708
|
for k in payload.keys():
|
|
5691
5709
|
if k not in allowed_top_keys:
|
|
@@ -5701,12 +5719,13 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5701
5719
|
and "update" not in payload
|
|
5702
5720
|
and "cache_report" not in payload
|
|
5703
5721
|
and "budget" not in payload
|
|
5722
|
+
and "dashboard" not in payload
|
|
5704
5723
|
):
|
|
5705
5724
|
self._respond_json(
|
|
5706
5725
|
400,
|
|
5707
5726
|
{"error": (
|
|
5708
5727
|
"body must contain at least one of: "
|
|
5709
|
-
"display, alerts, update, cache_report, budget"
|
|
5728
|
+
"display, alerts, update, cache_report, budget, dashboard"
|
|
5710
5729
|
)},
|
|
5711
5730
|
)
|
|
5712
5731
|
return
|
|
@@ -5835,6 +5854,52 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5835
5854
|
)
|
|
5836
5855
|
return
|
|
5837
5856
|
|
|
5857
|
+
# Pre-validate the dashboard block (spec §5). Only
|
|
5858
|
+
# ``cache_failure_markers`` is dashboard-writable — a JSON boolean
|
|
5859
|
+
# (string/int rejected, mirroring the strict bool checks for
|
|
5860
|
+
# ``alerts.enabled``). ``dashboard.bind`` / ``dashboard.expose_transcripts``
|
|
5861
|
+
# are bind-time / privacy-gate settings, NOT live-mutable, so they are
|
|
5862
|
+
# rejected explicitly here (rather than silently dropped). Outside the
|
|
5863
|
+
# config_writer_lock so a 400 short-circuit doesn't take the lock.
|
|
5864
|
+
dashboard_validated: "dict | None" = None
|
|
5865
|
+
if "dashboard" in payload:
|
|
5866
|
+
dashboard_block = payload["dashboard"]
|
|
5867
|
+
if not isinstance(dashboard_block, dict):
|
|
5868
|
+
self._respond_json(
|
|
5869
|
+
400,
|
|
5870
|
+
{"error": "dashboard must be an object", "field": "dashboard"},
|
|
5871
|
+
)
|
|
5872
|
+
return
|
|
5873
|
+
for leaf in dashboard_block.keys():
|
|
5874
|
+
if leaf in ("bind", "expose_transcripts"):
|
|
5875
|
+
self._respond_json(
|
|
5876
|
+
400,
|
|
5877
|
+
{"error": (f"dashboard.{leaf} is not settable via the "
|
|
5878
|
+
"dashboard (bind-time / privacy-gate setting)"),
|
|
5879
|
+
"field": f"dashboard.{leaf}"},
|
|
5880
|
+
)
|
|
5881
|
+
return
|
|
5882
|
+
if leaf != "cache_failure_markers":
|
|
5883
|
+
self._respond_json(
|
|
5884
|
+
400,
|
|
5885
|
+
{"error": f"unknown dashboard settings key: {leaf}",
|
|
5886
|
+
"field": f"dashboard.{leaf}"},
|
|
5887
|
+
)
|
|
5888
|
+
return
|
|
5889
|
+
dashboard_validated = {}
|
|
5890
|
+
if "cache_failure_markers" in dashboard_block:
|
|
5891
|
+
if not isinstance(dashboard_block["cache_failure_markers"], bool):
|
|
5892
|
+
self._respond_json(
|
|
5893
|
+
400,
|
|
5894
|
+
{"error": ("dashboard.cache_failure_markers must be a "
|
|
5895
|
+
"JSON boolean"),
|
|
5896
|
+
"field": "dashboard.cache_failure_markers"},
|
|
5897
|
+
)
|
|
5898
|
+
return
|
|
5899
|
+
dashboard_validated["cache_failure_markers"] = (
|
|
5900
|
+
dashboard_block["cache_failure_markers"]
|
|
5901
|
+
)
|
|
5902
|
+
|
|
5838
5903
|
# Pre-validate update shape. Only `update.check.{enabled,ttl_hours}`
|
|
5839
5904
|
# is settable today; any other key under `update` or `update.check`
|
|
5840
5905
|
# is rejected so adding e.g. `update.banner.*` later is forward
|
|
@@ -6061,6 +6126,23 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
6061
6126
|
merged_cr.update(cache_report_validated)
|
|
6062
6127
|
merged["cache_report"] = merged_cr
|
|
6063
6128
|
|
|
6129
|
+
if dashboard_validated is not None:
|
|
6130
|
+
# Same hand-edited-junk guard as the other blocks: a non-dict
|
|
6131
|
+
# stored ``dashboard`` block should surface as a recoverable
|
|
6132
|
+
# 400, not a 500. Partial-merge so the (bind-time, CLI-only)
|
|
6133
|
+
# ``bind`` / ``expose_transcripts`` siblings are PRESERVED —
|
|
6134
|
+
# cache_failure_markers writes must never clobber them.
|
|
6135
|
+
existing_dash = merged.get("dashboard")
|
|
6136
|
+
if existing_dash is not None and not isinstance(existing_dash, dict):
|
|
6137
|
+
self._respond_json(
|
|
6138
|
+
400, {"error": "dashboard must be an object",
|
|
6139
|
+
"field": "dashboard"}
|
|
6140
|
+
)
|
|
6141
|
+
return
|
|
6142
|
+
merged_dash = dict(existing_dash or {})
|
|
6143
|
+
merged_dash.update(dashboard_validated)
|
|
6144
|
+
merged["dashboard"] = merged_dash
|
|
6145
|
+
|
|
6064
6146
|
save_config(merged)
|
|
6065
6147
|
|
|
6066
6148
|
# Build the response: subset of touched blocks.
|
|
@@ -6152,6 +6234,16 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
6152
6234
|
out["cache_report"] = {
|
|
6153
6235
|
"anomaly_threshold_pp": stored_threshold,
|
|
6154
6236
|
}
|
|
6237
|
+
if dashboard_validated is not None:
|
|
6238
|
+
# Echo the persisted cache_failure_markers (the only dashboard-
|
|
6239
|
+
# writable leaf) so the SettingsOverlay can repaint without a
|
|
6240
|
+
# follow-up GET. Default true (opt-out) when nothing is persisted.
|
|
6241
|
+
persisted_dash = merged.get("dashboard") or {}
|
|
6242
|
+
out["dashboard"] = {
|
|
6243
|
+
"cache_failure_markers": bool(
|
|
6244
|
+
persisted_dash.get("cache_failure_markers", True)
|
|
6245
|
+
),
|
|
6246
|
+
}
|
|
6155
6247
|
out["saved_at"] = (
|
|
6156
6248
|
dt.datetime.now(dt.timezone.utc)
|
|
6157
6249
|
.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
@@ -307,7 +307,7 @@ def _load_recorded_five_hour_windows(
|
|
|
307
307
|
try:
|
|
308
308
|
with open_db() as conn:
|
|
309
309
|
rows = conn.execute(
|
|
310
|
-
"SELECT five_hour_resets_at "
|
|
310
|
+
"SELECT five_hour_resets_at, five_hour_window_key "
|
|
311
311
|
"FROM weekly_usage_snapshots "
|
|
312
312
|
"WHERE five_hour_resets_at IS NOT NULL "
|
|
313
313
|
" AND five_hour_resets_at >= ? "
|
|
@@ -326,7 +326,8 @@ def _load_recorded_five_hour_windows(
|
|
|
326
326
|
canonical_rows: list[Any] = []
|
|
327
327
|
try:
|
|
328
328
|
canonical_rows = conn.execute(
|
|
329
|
-
"SELECT five_hour_resets_at, block_start_at "
|
|
329
|
+
"SELECT five_hour_resets_at, block_start_at, "
|
|
330
|
+
" five_hour_window_key "
|
|
330
331
|
"FROM five_hour_blocks "
|
|
331
332
|
"WHERE five_hour_resets_at IS NOT NULL "
|
|
332
333
|
" AND five_hour_resets_at >= ? "
|
|
@@ -378,9 +379,25 @@ def _load_recorded_five_hour_windows(
|
|
|
378
379
|
# denied on parent dir) that propagate from open_db() before any
|
|
379
380
|
# SQL runs. Either way, fall back to the heuristic anchor path.
|
|
380
381
|
return [], {}, {}
|
|
382
|
+
# issue #201: identify each 5h window by its canonical, jitter-collapsed
|
|
383
|
+
# ``five_hour_window_key`` (the 10-min-floored epoch the record path
|
|
384
|
+
# already stored via the anchored ``_canonical_5h_window_key`` reuse)
|
|
385
|
+
# instead of re-flooring the raw ``five_hour_resets_at`` string. A
|
|
386
|
+
# 1-second reset jitter straddling a 10-minute floor boundary
|
|
387
|
+
# (``20:39:59`` vs ``20:40:00``) floors to two different buckets and
|
|
388
|
+
# would otherwise fork one physical window into two overlapping blocks
|
|
389
|
+
# — the exact split this column exists to prevent. Falls back to the
|
|
390
|
+
# pure floor for legacy rows whose key wasn't backfilled (``open_db``
|
|
391
|
+
# backfills NULL keys before this query runs, so this is defensive).
|
|
392
|
+
def _bucket_dt(window_key: Any, resets_dt: dt.datetime) -> dt.datetime:
|
|
393
|
+
if window_key is not None:
|
|
394
|
+
return dt.datetime.fromtimestamp(int(window_key), dt.timezone.utc)
|
|
395
|
+
return _c._floor_to_ten_minutes(resets_dt)
|
|
396
|
+
|
|
381
397
|
counts: dict[dt.datetime, int] = {}
|
|
382
398
|
for row in rows:
|
|
383
399
|
raw = row["five_hour_resets_at"] if hasattr(row, "keys") else row[0]
|
|
400
|
+
wkey = row["five_hour_window_key"] if hasattr(row, "keys") else row[1]
|
|
384
401
|
if raw is None:
|
|
385
402
|
continue
|
|
386
403
|
try:
|
|
@@ -391,7 +408,7 @@ def _load_recorded_five_hour_windows(
|
|
|
391
408
|
d = d.replace(tzinfo=dt.timezone.utc)
|
|
392
409
|
else:
|
|
393
410
|
d = d.astimezone(dt.timezone.utc)
|
|
394
|
-
snapped =
|
|
411
|
+
snapped = _bucket_dt(wkey, d)
|
|
395
412
|
counts[snapped] = counts.get(snapped, 0) + 1
|
|
396
413
|
# Overlay canonical rollup anchors at heavy weight. Same flooring
|
|
397
414
|
# rule so a jittered raw value (e.g. 17:48Z) and its canonicalized
|
|
@@ -411,10 +428,16 @@ def _load_recorded_five_hour_windows(
|
|
|
411
428
|
# truncated R keeps both blocks visible — without this fix the
|
|
412
429
|
# earlier block's entries are silently rendered as a phantom
|
|
413
430
|
# heuristic "~" row by `_group_entries_into_blocks`.
|
|
414
|
-
|
|
431
|
+
# issue #201: each triple is ``(wkey_dt, bs, rs)`` — ``wkey_dt`` is the
|
|
432
|
+
# canonical window_key (decoded to a UTC datetime) and is the SAME
|
|
433
|
+
# bucket identity used for the raw-snapshot counts above, so the heavy
|
|
434
|
+
# canonical overlay always lands in the same bucket as its supporting
|
|
435
|
+
# raw rows (never a jitter-split sibling bucket).
|
|
436
|
+
canonical_pairs: list[tuple[dt.datetime, dt.datetime, dt.datetime]] = []
|
|
415
437
|
for row in canonical_rows:
|
|
416
438
|
rs_raw = row["five_hour_resets_at"] if hasattr(row, "keys") else row[0]
|
|
417
439
|
bs_raw = row["block_start_at"] if hasattr(row, "keys") else row[1]
|
|
440
|
+
wkey = row["five_hour_window_key"] if hasattr(row, "keys") else row[2]
|
|
418
441
|
if rs_raw is None or bs_raw is None:
|
|
419
442
|
continue
|
|
420
443
|
try:
|
|
@@ -430,8 +453,8 @@ def _load_recorded_five_hour_windows(
|
|
|
430
453
|
bs = bs.replace(tzinfo=dt.timezone.utc)
|
|
431
454
|
else:
|
|
432
455
|
bs = bs.astimezone(dt.timezone.utc)
|
|
433
|
-
canonical_pairs.append((bs, rs))
|
|
434
|
-
canonical_pairs.sort(key=lambda p: p[
|
|
456
|
+
canonical_pairs.append((_bucket_dt(wkey, rs), bs, rs))
|
|
457
|
+
canonical_pairs.sort(key=lambda p: p[1])
|
|
435
458
|
|
|
436
459
|
# issue #76: canonical_intervals maps every floored R -> its EXACT
|
|
437
460
|
# (block_start_at, five_hour_resets_at) — both UTC, rs un-floored
|
|
@@ -444,9 +467,8 @@ def _load_recorded_five_hour_windows(
|
|
|
444
467
|
canonical_intervals: dict[
|
|
445
468
|
dt.datetime, tuple[dt.datetime, dt.datetime]
|
|
446
469
|
] = {}
|
|
447
|
-
for bs, rs in canonical_pairs:
|
|
448
|
-
|
|
449
|
-
canonical_intervals[snapped] = (bs, rs)
|
|
470
|
+
for wkey_dt, bs, rs in canonical_pairs:
|
|
471
|
+
canonical_intervals[wkey_dt] = (bs, rs)
|
|
450
472
|
|
|
451
473
|
# Detect overlap-with-credit and replace the earlier R with a
|
|
452
474
|
# credit-truncated anchor. The (anchor → real_block_start) map is
|
|
@@ -454,11 +476,12 @@ def _load_recorded_five_hour_windows(
|
|
|
454
476
|
# real block_start_at on the display row (instead of the default
|
|
455
477
|
# R - 5h, which would be hours earlier for a 2h-truncated block).
|
|
456
478
|
block_start_overrides: dict[dt.datetime, dt.datetime] = {}
|
|
457
|
-
truncated_pairs: list[tuple[dt.datetime, dt.datetime]] = []
|
|
458
|
-
for i, (bs, rs) in enumerate(canonical_pairs):
|
|
479
|
+
truncated_pairs: list[tuple[dt.datetime, dt.datetime, dt.datetime]] = []
|
|
480
|
+
for i, (wkey_dt, bs, rs) in enumerate(canonical_pairs):
|
|
481
|
+
anchor_key = wkey_dt # canonical window_key identity (issue #201)
|
|
459
482
|
truncated_R = rs
|
|
460
483
|
if i + 1 < len(canonical_pairs):
|
|
461
|
-
next_bs, _next_rs = canonical_pairs[i + 1]
|
|
484
|
+
_next_wk, next_bs, _next_rs = canonical_pairs[i + 1]
|
|
462
485
|
if rs > next_bs: # overlap with next block
|
|
463
486
|
# Look for a credit moment inside [next_bs, rs] — the
|
|
464
487
|
# part of the earlier block that overlaps the next.
|
|
@@ -471,24 +494,24 @@ def _load_recorded_five_hour_windows(
|
|
|
471
494
|
# drop one via its weight-tiebreaker.
|
|
472
495
|
if bs < cm_floored < rs:
|
|
473
496
|
truncated_R = cm_floored
|
|
497
|
+
anchor_key = cm_floored
|
|
474
498
|
block_start_overrides[cm_floored] = bs
|
|
475
|
-
# Rewrite canonical_intervals
|
|
476
|
-
#
|
|
477
|
-
#
|
|
478
|
-
#
|
|
479
|
-
#
|
|
480
|
-
#
|
|
481
|
-
#
|
|
482
|
-
#
|
|
483
|
-
#
|
|
484
|
-
#
|
|
485
|
-
|
|
486
|
-
canonical_intervals.pop(snapped_orig, None)
|
|
499
|
+
# Rewrite canonical_intervals under the
|
|
500
|
+
# truncated key. issue #76: the partitioner
|
|
501
|
+
# reads canonical_intervals for the exact
|
|
502
|
+
# bs/rs; the truncated entry must reflect the
|
|
503
|
+
# credit-shifted upper bound (cm_floored) AND
|
|
504
|
+
# the real bs (the override) so partition +
|
|
505
|
+
# Phase 1.5 render the credit-shortened block
|
|
506
|
+
# consistently. issue #201: the original
|
|
507
|
+
# entry is keyed by the canonical window_key,
|
|
508
|
+
# so pop under ``wkey_dt`` (not floor(rs)).
|
|
509
|
+
canonical_intervals.pop(wkey_dt, None)
|
|
487
510
|
canonical_intervals[cm_floored] = (
|
|
488
511
|
bs, cm_floored,
|
|
489
512
|
)
|
|
490
513
|
break
|
|
491
|
-
truncated_pairs.append((bs, truncated_R))
|
|
514
|
+
truncated_pairs.append((anchor_key, bs, truncated_R))
|
|
492
515
|
|
|
493
516
|
# Truncated anchors are credit-adjusted and known-good; bypass the
|
|
494
517
|
# `_select_non_overlapping_recorded_windows` weighted scheduler for
|
|
@@ -501,16 +524,16 @@ def _load_recorded_five_hour_windows(
|
|
|
501
524
|
# input weight (so jittered same-bucket raw values still collapse)
|
|
502
525
|
# but skip them when computing the overlap-safe subset.
|
|
503
526
|
truncated_anchors: set[dt.datetime] = set()
|
|
504
|
-
for
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
# Identify truncated anchors by membership in the override
|
|
510
|
-
# (only credit-truncated entries land there).
|
|
511
|
-
if
|
|
512
|
-
truncated_anchors.add(
|
|
513
|
-
counts[
|
|
527
|
+
for anchor_key, _bs, _rs in truncated_pairs:
|
|
528
|
+
# ``anchor_key`` is already a floored canonical key — the window_key
|
|
529
|
+
# for an untruncated anchor (issue #201) or the credit-floored key
|
|
530
|
+
# for a truncated one — and the override map is keyed by it
|
|
531
|
+
# directly, so the legacy floor-relocation dance is no longer
|
|
532
|
+
# needed. Identify truncated anchors by membership in the override
|
|
533
|
+
# map (only credit-truncated entries land there).
|
|
534
|
+
if anchor_key in block_start_overrides:
|
|
535
|
+
truncated_anchors.add(anchor_key)
|
|
536
|
+
counts[anchor_key] = counts.get(anchor_key, 0) + _CANONICAL_WEIGHT_THRESHOLD
|
|
514
537
|
|
|
515
538
|
non_truncated_items = [
|
|
516
539
|
(a, w) for a, w in counts.items() if a not in truncated_anchors
|
|
@@ -285,6 +285,105 @@ def _entry_cost(model, inp, out, cc, cr, cost_usd_raw) -> float:
|
|
|
285
285
|
return _calculate_entry_cost(model or "", usage, cost_usd=cost_usd_raw)
|
|
286
286
|
|
|
287
287
|
|
|
288
|
+
# --- Cache-failure detection (spec §1) -------------------------------------
|
|
289
|
+
# A prompt-cache "failure" is an assistant turn that re-creates the bulk of its
|
|
290
|
+
# cached prefix instead of reading it: cache_read collapses while cache_creation
|
|
291
|
+
# balloons, re-billing those tokens at the higher cache-WRITE rate. We flag only
|
|
292
|
+
# clear mid-session prefix losses (the Conservative profile, ~1 per session).
|
|
293
|
+
#
|
|
294
|
+
# Thresholds (maintainer-tunable module constants, NOT user config). Rationale
|
|
295
|
+
# from a scan of 58 recent sessions (spec Evidence): a 0.5 running-max-collapse
|
|
296
|
+
# threshold sits in the wide empty gap between the worst healthy turn and the
|
|
297
|
+
# mildest failure, and the recreated-fraction guard (0.75) sits in the matching
|
|
298
|
+
# fraction gap (healthy max 0.723 < 0.75 <= failure min 0.759), both with margin.
|
|
299
|
+
_CACHE_FAILURE_COLLAPSE_FRACTION = 0.5 # cache_read must fall to <= half the prior running-max
|
|
300
|
+
_CACHE_FAILURE_RECREATE_FRACTION = 0.75 # >= 75% of THIS turn's context freshly created
|
|
301
|
+
_CACHE_FAILURE_CACHE_FLOOR = 20_000 # prior cache must be meaningful to "lose"
|
|
302
|
+
_CACHE_FAILURE_CREATE_FLOOR = 20_000 # the re-creation must be substantial / real cost
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _cache_failure_wasted_usd(model, lost):
|
|
306
|
+
"""Marginal extra paid by re-creating `lost` previously-cached tokens at the
|
|
307
|
+
cache-WRITE rate instead of reading them at the cache-READ rate. Reuses the
|
|
308
|
+
pricing chokepoint `_calculate_entry_cost` (zero on unknown models — the
|
|
309
|
+
helper emits its own one-shot stderr warning, never raises). NEVER summed into
|
|
310
|
+
any cost-snapshot / budget / reconciled figure — a display-only estimate."""
|
|
311
|
+
write = _calculate_entry_cost(model or "", {"cache_creation_input_tokens": lost})
|
|
312
|
+
read = _calculate_entry_cost(model or "", {"cache_read_input_tokens": lost})
|
|
313
|
+
return write - read
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _stamp_cache_failures(items):
|
|
317
|
+
"""Stamp ``item["cache_failure"]`` on each assistant turn that re-creates the
|
|
318
|
+
bulk of its cached prefix instead of reading it (spec §1). Mutates `items` in
|
|
319
|
+
place; healthy turns are left WITHOUT the key (absent, not zero — matching the
|
|
320
|
+
``tokens?`` "absent, not zero" convention).
|
|
321
|
+
|
|
322
|
+
Walks items in DOCUMENT ORDER (the order `_assemble_session` produces),
|
|
323
|
+
maintaining a running-max of ``cache_read`` keyed by ``(subagent_key, model)``
|
|
324
|
+
— ``None`` subagent_key = main session. The key is per-thread AND per-model
|
|
325
|
+
because Anthropic prompt caches are model-specific: a model switch within a
|
|
326
|
+
thread legitimately starts a fresh cache (cr ~ 0) and must not read as a loss,
|
|
327
|
+
and the compound key makes that the first turn under its own key (no prior rm).
|
|
328
|
+
The per-key running-max is RESET when a context-compaction boundary is crossed
|
|
329
|
+
(a ``meta`` item whose ``meta_kind == "compaction"``), since compaction
|
|
330
|
+
legitimately invalidates the prefix and the post-compaction re-prime is not a
|
|
331
|
+
failure.
|
|
332
|
+
|
|
333
|
+
For each assistant item with a ``tokens`` dict — cc = tokens["cache_creation"],
|
|
334
|
+
cr = tokens["cache_read"], rm = the running-max for this key BEFORE this turn —
|
|
335
|
+
flag iff rm >= CACHE_FLOOR and cc >= CREATE_FLOOR and cr <= COLLAPSE_FRACTION*rm
|
|
336
|
+
and cc/(cc+cr) >= RECREATE_FRACTION. The fourth term keeps the rule aligned
|
|
337
|
+
with the Evidence: it requires that most of THIS turn's context was freshly
|
|
338
|
+
created, not merely that cache_read dipped. After the check, update
|
|
339
|
+
``running_max[key] = max(rm, cr)`` (a failure's small cr never lowers the
|
|
340
|
+
high-water mark; the next healthy turn re-establishes it).
|
|
341
|
+
|
|
342
|
+
Payload (lost-prefix basis — NOT raw cc, which would over-count when the turn
|
|
343
|
+
also writes genuinely-new cacheable content that was never cached):
|
|
344
|
+
lost = min(cc, max(0, rm - cr))
|
|
345
|
+
tokens_recreated = lost
|
|
346
|
+
prev_cached = rm
|
|
347
|
+
est_wasted_usd = write(lost) - read(lost)
|
|
348
|
+
|
|
349
|
+
Items lacking a ``tokens`` dict (no session_entries row, or non-assistant
|
|
350
|
+
items other than a compaction meta) are skipped and do NOT move the
|
|
351
|
+
running-max. Order-dependent by construction; run over document-ordered items.
|
|
352
|
+
"""
|
|
353
|
+
running_max = {} # (subagent_key, model) -> high-water cache_read
|
|
354
|
+
for it in items:
|
|
355
|
+
# Context-compaction boundary resets the per-key prefix high-water mark:
|
|
356
|
+
# the post-compaction re-prime is a legitimate fresh build, not a loss.
|
|
357
|
+
# Reset the whole map (compaction invalidates every thread's prefix in
|
|
358
|
+
# this session view; a per-key reset would need a thread tag the meta row
|
|
359
|
+
# does not reliably carry).
|
|
360
|
+
if it.get("kind") == "meta" and it.get("meta_kind") == "compaction":
|
|
361
|
+
running_max.clear()
|
|
362
|
+
continue
|
|
363
|
+
if it.get("kind") != "assistant":
|
|
364
|
+
continue
|
|
365
|
+
tok = it.get("tokens")
|
|
366
|
+
if not isinstance(tok, dict):
|
|
367
|
+
continue # no session_entries row -> skip, don't move max
|
|
368
|
+
cc = tok.get("cache_creation", 0) or 0
|
|
369
|
+
cr = tok.get("cache_read", 0) or 0
|
|
370
|
+
key = (it.get("subagent_key"), it.get("model"))
|
|
371
|
+
rm = running_max.get(key, 0)
|
|
372
|
+
total = cc + cr
|
|
373
|
+
if (rm >= _CACHE_FAILURE_CACHE_FLOOR
|
|
374
|
+
and cc >= _CACHE_FAILURE_CREATE_FLOOR
|
|
375
|
+
and cr <= _CACHE_FAILURE_COLLAPSE_FRACTION * rm
|
|
376
|
+
and total > 0
|
|
377
|
+
and cc / total >= _CACHE_FAILURE_RECREATE_FRACTION):
|
|
378
|
+
lost = min(cc, max(0, rm - cr))
|
|
379
|
+
it["cache_failure"] = {
|
|
380
|
+
"tokens_recreated": lost,
|
|
381
|
+
"prev_cached": rm,
|
|
382
|
+
"est_wasted_usd": _cache_failure_wasted_usd(it.get("model"), lost),
|
|
383
|
+
}
|
|
384
|
+
running_max[key] = max(rm, cr)
|
|
385
|
+
|
|
386
|
+
|
|
288
387
|
def _session_cost_map(conn, session_ids):
|
|
289
388
|
"""{session_id: total_cost_usd} for the given sessions. Joins
|
|
290
389
|
conversation_messages turn keys to the single deduped session_entries row
|
|
@@ -862,6 +961,12 @@ def _assemble_session(conn, session_id):
|
|
|
862
961
|
it.pop("_has_prose", None)
|
|
863
962
|
header_cost = round(header_cost, 6)
|
|
864
963
|
|
|
964
|
+
# Stamp cache-failure markers (spec §1) AFTER tokens are on each item and
|
|
965
|
+
# while `items` is still document-ordered (the running-max walk is
|
|
966
|
+
# order-dependent). Healthy turns get no key (absent, not zero). Shared
|
|
967
|
+
# assembly -> the flag reaches BOTH the reader detail and the outline.
|
|
968
|
+
_stamp_cache_failures(items)
|
|
969
|
+
|
|
865
970
|
# Strip the internal Phase-4b threading key from EVERY item (meta/human items
|
|
866
971
|
# carry it too, not just assistant turns) so it never surfaces in the public
|
|
867
972
|
# item JSON.
|
|
@@ -995,6 +1100,12 @@ def get_conversation_outline(conn, session_id):
|
|
|
995
1100
|
tool_counts, models = {}, {}
|
|
996
1101
|
error_count = 0
|
|
997
1102
|
tokens = {"input": 0, "output": 0, "cache_creation": 0, "cache_read": 0}
|
|
1103
|
+
# Cache-failure aggregate (spec §2): count + summed lost-prefix tokens +
|
|
1104
|
+
# summed wasted-cost over the flagged turns. Emitted only when count > 0
|
|
1105
|
+
# (the per-turn "absent, not zero" convention; ~65% of sessions have zero).
|
|
1106
|
+
cf_count = 0
|
|
1107
|
+
cf_tokens = 0
|
|
1108
|
+
cf_wasted = 0.0
|
|
998
1109
|
for it in items:
|
|
999
1110
|
kind = it["kind"]
|
|
1000
1111
|
turn_counts["total"] += 1
|
|
@@ -1036,6 +1147,15 @@ def get_conversation_outline(conn, session_id):
|
|
|
1036
1147
|
t["tokens"] = tok
|
|
1037
1148
|
for k in tokens:
|
|
1038
1149
|
tokens[k] += tok.get(k, 0)
|
|
1150
|
+
# Copy the cache-failure marker onto the OutlineTurn exactly where
|
|
1151
|
+
# tokens is copied (assistant-only, rides the same source row) and
|
|
1152
|
+
# accumulate the session-level aggregate (spec §2).
|
|
1153
|
+
cf = it.get("cache_failure")
|
|
1154
|
+
if cf is not None:
|
|
1155
|
+
t["cache_failure"] = cf
|
|
1156
|
+
cf_count += 1
|
|
1157
|
+
cf_tokens += cf.get("tokens_recreated", 0)
|
|
1158
|
+
cf_wasted += cf.get("est_wasted_usd", 0.0)
|
|
1039
1159
|
if kind == "meta":
|
|
1040
1160
|
t["meta_kind"] = it.get("meta_kind")
|
|
1041
1161
|
t["skill_name"] = it.get("skill_name")
|
|
@@ -1046,12 +1166,17 @@ def get_conversation_outline(conn, session_id):
|
|
|
1046
1166
|
d0 = _parse_outline_ts(ts_vals[0] if ts_vals else None)
|
|
1047
1167
|
d1 = _parse_outline_ts(ts_vals[-1] if ts_vals else None)
|
|
1048
1168
|
duration = int((d1 - d0).total_seconds()) if d0 and d1 else None
|
|
1169
|
+
stats = {"turns": turn_counts, "tool_counts": tool_counts,
|
|
1170
|
+
"error_count": error_count, "models": models,
|
|
1171
|
+
"duration_seconds": duration, "tokens": tokens,
|
|
1172
|
+
"cost_usd": asm["header_cost"]}
|
|
1173
|
+
if cf_count:
|
|
1174
|
+
stats["cache_failures"] = {"count": cf_count,
|
|
1175
|
+
"tokens_recreated": cf_tokens,
|
|
1176
|
+
"est_wasted_usd": cf_wasted}
|
|
1049
1177
|
return {"session_id": session_id,
|
|
1050
1178
|
"subagent_meta": asm["subagent_meta"],
|
|
1051
|
-
"stats":
|
|
1052
|
-
"error_count": error_count, "models": models,
|
|
1053
|
-
"duration_seconds": duration, "tokens": tokens,
|
|
1054
|
-
"cost_usd": asm["header_cost"]},
|
|
1179
|
+
"stats": stats,
|
|
1055
1180
|
"turns": turns}
|
|
1056
1181
|
|
|
1057
1182
|
|