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 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
@@ -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 = _c._floor_to_ten_minutes(d)
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
- canonical_pairs: list[tuple[dt.datetime, dt.datetime]] = []
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[0])
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
- snapped = _c._floor_to_ten_minutes(rs)
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[snapped_orig]
476
- # to the truncated interval under the
477
- # truncated key. issue #76: the
478
- # partitioner reads canonical_intervals
479
- # for the exact bs/rs; the truncated entry
480
- # must reflect the credit-shifted upper
481
- # bound (cm_floored) AND the real bs (the
482
- # override) so partition + Phase 1.5
483
- # render the credit-shortened block
484
- # consistently.
485
- snapped_orig = _c._floor_to_ten_minutes(rs)
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 bs, rs in truncated_pairs:
505
- snapped = _c._floor_to_ten_minutes(rs)
506
- if rs != _c._floor_to_ten_minutes(rs):
507
- if rs in block_start_overrides:
508
- block_start_overrides[snapped] = block_start_overrides.pop(rs)
509
- # Identify truncated anchors by membership in the override map
510
- # (only credit-truncated entries land there).
511
- if snapped in block_start_overrides:
512
- truncated_anchors.add(snapped)
513
- counts[snapped] = counts.get(snapped, 0) + _CANONICAL_WEIGHT_THRESHOLD
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": {"turns": turn_counts, "tool_counts": tool_counts,
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