cctally 1.44.3 → 1.46.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 +10 -0
- package/bin/_cctally_cache.py +94 -19
- package/bin/_cctally_config.py +150 -0
- package/bin/_cctally_dashboard.py +223 -3
- package/bin/_lib_conversation_query.py +143 -4
- package/bin/_lib_conversation_watch.py +67 -0
- package/dashboard/static/assets/index-DHfwfOHz.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 +2 -1
- package/dashboard/static/assets/index-L_FSvwQH.js +0 -68
|
@@ -317,7 +317,7 @@ from _lib_subscription_weeks import _compute_subscription_weeks
|
|
|
317
317
|
from _lib_blocks import _group_entries_into_blocks
|
|
318
318
|
from _cctally_config import save_config, _load_config_unlocked
|
|
319
319
|
from _cctally_db import _render_migration_error_banner
|
|
320
|
-
from _cctally_cache import get_entries, open_cache_db
|
|
320
|
+
from _cctally_cache import get_entries, open_cache_db, sync_cache
|
|
321
321
|
|
|
322
322
|
|
|
323
323
|
# === Module-level back-ref shims for helpers that STAY in bin/cctally ======
|
|
@@ -623,6 +623,12 @@ BLOCK_DURATION = sys.modules["cctally"].BLOCK_DURATION
|
|
|
623
623
|
# === STATIC_DIR — dashboard static-asset root ==============================
|
|
624
624
|
STATIC_DIR = pathlib.Path(__file__).resolve().parent.parent / "dashboard" / "static"
|
|
625
625
|
|
|
626
|
+
# Conversation live-tail watch loop (spec §4.1). Single-file stat poll: cheap.
|
|
627
|
+
_LIVE_TAIL_POLL_INTERVAL = 1.0 # seconds between stat polls of the open file(s)
|
|
628
|
+
_LIVE_TAIL_DEBOUNCE = 0.25 # settle window after first detected growth
|
|
629
|
+
_LIVE_TAIL_KEEPALIVE = 15.0 # idle keep-alive cadence (proxy guard)
|
|
630
|
+
_LIVE_TAIL_FILE_RESET_EVERY = 10 # re-resolve the session file set every N cycles
|
|
631
|
+
|
|
626
632
|
|
|
627
633
|
# === Dashboard bind validators (config + cmd_dashboard) ====================
|
|
628
634
|
|
|
@@ -4912,6 +4918,20 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
4912
4918
|
"notifier": _alerts_cfg.get("notifier", "auto"),
|
|
4913
4919
|
"command_configured": _alerts_cfg.get("command_template") is not None,
|
|
4914
4920
|
}
|
|
4921
|
+
# Dashboard render-prefs mirror (cache-failure-markers opt-out, spec §5).
|
|
4922
|
+
# Reuses the single `_cfg_for_alerts = load_config()` read above (no extra
|
|
4923
|
+
# FS hit on the hot path). Default true — absence is treated as ON (opt-out,
|
|
4924
|
+
# not opt-in); a hand-edited non-bool surfaces the default. The on/off toggle
|
|
4925
|
+
# is honored entirely at client render time; the kernel + API always emit the
|
|
4926
|
+
# marker data.
|
|
4927
|
+
_dash_cfg = _cfg_for_alerts.get("dashboard") if isinstance(
|
|
4928
|
+
_cfg_for_alerts.get("dashboard"), dict) else {}
|
|
4929
|
+
_cfm = _dash_cfg.get("cache_failure_markers", True)
|
|
4930
|
+
_lt = _dash_cfg.get("live_tail", True)
|
|
4931
|
+
dashboard_prefs = {
|
|
4932
|
+
"cache_failure_markers": _cfm if isinstance(_cfm, bool) else True,
|
|
4933
|
+
"live_tail": _lt if isinstance(_lt, bool) else True,
|
|
4934
|
+
}
|
|
4915
4935
|
|
|
4916
4936
|
# Mirror update-state.json + update-suppress.json into the envelope
|
|
4917
4937
|
# so the dashboard's amber "Update available" badge repaints from
|
|
@@ -5149,6 +5169,12 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
5149
5169
|
"alerts": alerts_array,
|
|
5150
5170
|
"alerts_settings": alerts_settings,
|
|
5151
5171
|
|
|
5172
|
+
# Dashboard render-prefs mirror (cache-failure-markers opt-out, spec
|
|
5173
|
+
# §5). Additive optional, like alerts_settings — envelope_version
|
|
5174
|
+
# stays at 2. The client derives `markersEnabled` from this, defaulting
|
|
5175
|
+
# to true when the field is undefined (older server / first tick).
|
|
5176
|
+
"dashboard_prefs": dashboard_prefs,
|
|
5177
|
+
|
|
5152
5178
|
# update-subcommand SSE mirror (see comment above the
|
|
5153
5179
|
# `_load_update_state()` block). Shape matches GET
|
|
5154
5180
|
# /api/update/status's payload (`{state, suppress}`) so the
|
|
@@ -5250,6 +5276,30 @@ def _qs_str(q: dict, key: str, default: str | None) -> str | None:
|
|
|
5250
5276
|
_CONV_SEARCH_KINDS = ("all", "prompts", "assistant", "tools", "thinking")
|
|
5251
5277
|
|
|
5252
5278
|
|
|
5279
|
+
def _cached_file_sigs(conn, paths):
|
|
5280
|
+
"""{path: size_bytes} from session_files for the given paths — the cache's
|
|
5281
|
+
own view of how far each file is ingested. Size-only by design, matching the
|
|
5282
|
+
watch kernel's size-only signature (`file_sig`) and sync_cache's size-only
|
|
5283
|
+
delta signal: mtime is NOT consulted, because a size-unchanged ingest does
|
|
5284
|
+
not refresh session_files.mtime_ns and a stale mtime would re-detect
|
|
5285
|
+
'changed' every cycle forever. Used to baseline the live-tail watch so a file
|
|
5286
|
+
the cache hasn't caught up on reads as 'changed' on cycle 1 (spec §2.4).
|
|
5287
|
+
Paths with no row are simply absent → treated as changed."""
|
|
5288
|
+
out = {}
|
|
5289
|
+
if not paths:
|
|
5290
|
+
return out
|
|
5291
|
+
placeholders = ",".join("?" for _ in paths)
|
|
5292
|
+
try:
|
|
5293
|
+
rows = conn.execute(
|
|
5294
|
+
f"SELECT path, size_bytes FROM session_files "
|
|
5295
|
+
f"WHERE path IN ({placeholders})", list(paths)).fetchall()
|
|
5296
|
+
except sqlite3.OperationalError:
|
|
5297
|
+
return out
|
|
5298
|
+
for p, size in rows:
|
|
5299
|
+
out[p] = size
|
|
5300
|
+
return out
|
|
5301
|
+
|
|
5302
|
+
|
|
5253
5303
|
class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
5254
5304
|
"""Routes:
|
|
5255
5305
|
GET / → dashboard.html
|
|
@@ -5388,6 +5438,10 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5388
5438
|
# #177 S6: in-conversation find → rendered-turn anchors. Matched
|
|
5389
5439
|
# BEFORE the <id> reader catch-all (same precedence as /outline).
|
|
5390
5440
|
self._handle_get_conversation_find(path)
|
|
5441
|
+
elif path.startswith("/api/conversation/") and path.endswith("/events"):
|
|
5442
|
+
# Live-tail SSE for the open reader (spec §2). Matched BEFORE the
|
|
5443
|
+
# <id> reader catch-all.
|
|
5444
|
+
self._handle_get_conversation_events(path)
|
|
5391
5445
|
elif path.startswith("/api/conversation/"):
|
|
5392
5446
|
self._handle_get_conversation_detail(path)
|
|
5393
5447
|
else:
|
|
@@ -5685,7 +5739,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5685
5739
|
|
|
5686
5740
|
# Reject unknown top-level keys (forward-compat hygiene).
|
|
5687
5741
|
allowed_top_keys = {
|
|
5688
|
-
"display", "alerts", "update", "cache_report", "budget",
|
|
5742
|
+
"display", "alerts", "update", "cache_report", "budget", "dashboard",
|
|
5689
5743
|
}
|
|
5690
5744
|
for k in payload.keys():
|
|
5691
5745
|
if k not in allowed_top_keys:
|
|
@@ -5701,12 +5755,13 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5701
5755
|
and "update" not in payload
|
|
5702
5756
|
and "cache_report" not in payload
|
|
5703
5757
|
and "budget" not in payload
|
|
5758
|
+
and "dashboard" not in payload
|
|
5704
5759
|
):
|
|
5705
5760
|
self._respond_json(
|
|
5706
5761
|
400,
|
|
5707
5762
|
{"error": (
|
|
5708
5763
|
"body must contain at least one of: "
|
|
5709
|
-
"display, alerts, update, cache_report, budget"
|
|
5764
|
+
"display, alerts, update, cache_report, budget, dashboard"
|
|
5710
5765
|
)},
|
|
5711
5766
|
)
|
|
5712
5767
|
return
|
|
@@ -5835,6 +5890,50 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5835
5890
|
)
|
|
5836
5891
|
return
|
|
5837
5892
|
|
|
5893
|
+
# Pre-validate the dashboard block (spec §5). Only
|
|
5894
|
+
# ``cache_failure_markers`` is dashboard-writable — a JSON boolean
|
|
5895
|
+
# (string/int rejected, mirroring the strict bool checks for
|
|
5896
|
+
# ``alerts.enabled``). ``dashboard.bind`` / ``dashboard.expose_transcripts``
|
|
5897
|
+
# are bind-time / privacy-gate settings, NOT live-mutable, so they are
|
|
5898
|
+
# rejected explicitly here (rather than silently dropped). Outside the
|
|
5899
|
+
# config_writer_lock so a 400 short-circuit doesn't take the lock.
|
|
5900
|
+
dashboard_validated: "dict | None" = None
|
|
5901
|
+
if "dashboard" in payload:
|
|
5902
|
+
dashboard_block = payload["dashboard"]
|
|
5903
|
+
if not isinstance(dashboard_block, dict):
|
|
5904
|
+
self._respond_json(
|
|
5905
|
+
400,
|
|
5906
|
+
{"error": "dashboard must be an object", "field": "dashboard"},
|
|
5907
|
+
)
|
|
5908
|
+
return
|
|
5909
|
+
for leaf in dashboard_block.keys():
|
|
5910
|
+
if leaf in ("bind", "expose_transcripts"):
|
|
5911
|
+
self._respond_json(
|
|
5912
|
+
400,
|
|
5913
|
+
{"error": (f"dashboard.{leaf} is not settable via the "
|
|
5914
|
+
"dashboard (bind-time / privacy-gate setting)"),
|
|
5915
|
+
"field": f"dashboard.{leaf}"},
|
|
5916
|
+
)
|
|
5917
|
+
return
|
|
5918
|
+
if leaf not in ("cache_failure_markers", "live_tail"):
|
|
5919
|
+
self._respond_json(
|
|
5920
|
+
400,
|
|
5921
|
+
{"error": f"unknown dashboard settings key: {leaf}",
|
|
5922
|
+
"field": f"dashboard.{leaf}"},
|
|
5923
|
+
)
|
|
5924
|
+
return
|
|
5925
|
+
dashboard_validated = {}
|
|
5926
|
+
for _leaf in ("cache_failure_markers", "live_tail"):
|
|
5927
|
+
if _leaf in dashboard_block:
|
|
5928
|
+
if not isinstance(dashboard_block[_leaf], bool):
|
|
5929
|
+
self._respond_json(
|
|
5930
|
+
400,
|
|
5931
|
+
{"error": f"dashboard.{_leaf} must be a JSON boolean",
|
|
5932
|
+
"field": f"dashboard.{_leaf}"},
|
|
5933
|
+
)
|
|
5934
|
+
return
|
|
5935
|
+
dashboard_validated[_leaf] = dashboard_block[_leaf]
|
|
5936
|
+
|
|
5838
5937
|
# Pre-validate update shape. Only `update.check.{enabled,ttl_hours}`
|
|
5839
5938
|
# is settable today; any other key under `update` or `update.check`
|
|
5840
5939
|
# is rejected so adding e.g. `update.banner.*` later is forward
|
|
@@ -6061,6 +6160,23 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
6061
6160
|
merged_cr.update(cache_report_validated)
|
|
6062
6161
|
merged["cache_report"] = merged_cr
|
|
6063
6162
|
|
|
6163
|
+
if dashboard_validated is not None:
|
|
6164
|
+
# Same hand-edited-junk guard as the other blocks: a non-dict
|
|
6165
|
+
# stored ``dashboard`` block should surface as a recoverable
|
|
6166
|
+
# 400, not a 500. Partial-merge so the (bind-time, CLI-only)
|
|
6167
|
+
# ``bind`` / ``expose_transcripts`` siblings are PRESERVED —
|
|
6168
|
+
# cache_failure_markers writes must never clobber them.
|
|
6169
|
+
existing_dash = merged.get("dashboard")
|
|
6170
|
+
if existing_dash is not None and not isinstance(existing_dash, dict):
|
|
6171
|
+
self._respond_json(
|
|
6172
|
+
400, {"error": "dashboard must be an object",
|
|
6173
|
+
"field": "dashboard"}
|
|
6174
|
+
)
|
|
6175
|
+
return
|
|
6176
|
+
merged_dash = dict(existing_dash or {})
|
|
6177
|
+
merged_dash.update(dashboard_validated)
|
|
6178
|
+
merged["dashboard"] = merged_dash
|
|
6179
|
+
|
|
6064
6180
|
save_config(merged)
|
|
6065
6181
|
|
|
6066
6182
|
# Build the response: subset of touched blocks.
|
|
@@ -6152,6 +6268,17 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
6152
6268
|
out["cache_report"] = {
|
|
6153
6269
|
"anomaly_threshold_pp": stored_threshold,
|
|
6154
6270
|
}
|
|
6271
|
+
if dashboard_validated is not None:
|
|
6272
|
+
# Echo the persisted dashboard-writable leaves (cache_failure_markers
|
|
6273
|
+
# + live_tail) so the SettingsOverlay can repaint without a
|
|
6274
|
+
# follow-up GET. Default true (opt-out) when nothing is persisted.
|
|
6275
|
+
persisted_dash = merged.get("dashboard") or {}
|
|
6276
|
+
out["dashboard"] = {
|
|
6277
|
+
"cache_failure_markers": bool(
|
|
6278
|
+
persisted_dash.get("cache_failure_markers", True)
|
|
6279
|
+
),
|
|
6280
|
+
"live_tail": bool(persisted_dash.get("live_tail", True)),
|
|
6281
|
+
}
|
|
6155
6282
|
out["saved_at"] = (
|
|
6156
6283
|
dt.datetime.now(dt.timezone.utc)
|
|
6157
6284
|
.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
@@ -7389,6 +7516,99 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
7389
7516
|
return
|
|
7390
7517
|
self._respond_json(200, body)
|
|
7391
7518
|
|
|
7519
|
+
def _handle_get_conversation_events(self, path: str) -> None:
|
|
7520
|
+
"""``GET /api/conversation/<id>/events`` — per-conversation live-tail
|
|
7521
|
+
SSE (spec §2). Fail-closed behind the same transcript privacy gate as
|
|
7522
|
+
the other conversation routes. Watches only this session's file(s);
|
|
7523
|
+
emits ``event: tail`` on growth, ``: keep-alive`` when idle. Passive
|
|
7524
|
+
(no ingest, no emit) under ``--no-sync``."""
|
|
7525
|
+
if not self._require_transcripts_allowed():
|
|
7526
|
+
return
|
|
7527
|
+
import time as _time
|
|
7528
|
+
import urllib.parse as _u
|
|
7529
|
+
watch = sys.modules["cctally"]._load_sibling("_lib_conversation_watch")
|
|
7530
|
+
cq = self._conversation_query()
|
|
7531
|
+
session_id = _u.unquote(path[len("/api/conversation/"):-len("/events")])
|
|
7532
|
+
if not session_id:
|
|
7533
|
+
self.send_error(404, "conversation not found")
|
|
7534
|
+
return
|
|
7535
|
+
|
|
7536
|
+
self.send_response(200)
|
|
7537
|
+
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
|
7538
|
+
self.send_header("Cache-Control", "no-cache")
|
|
7539
|
+
self.send_header("Connection", "keep-alive")
|
|
7540
|
+
self.send_header("X-Accel-Buffering", "no")
|
|
7541
|
+
self.end_headers()
|
|
7542
|
+
|
|
7543
|
+
passive = bool(type(self).no_sync)
|
|
7544
|
+
|
|
7545
|
+
try:
|
|
7546
|
+
conn = open_cache_db()
|
|
7547
|
+
except (sqlite3.DatabaseError, OSError):
|
|
7548
|
+
# Cache unavailable — degrade to keep-alive only; client backstop
|
|
7549
|
+
# tick still surfaces turns. (Headers already sent; can't 500.)
|
|
7550
|
+
passive = True
|
|
7551
|
+
conn = None
|
|
7552
|
+
|
|
7553
|
+
def _resolve():
|
|
7554
|
+
return cq.session_source_paths(conn, session_id) if conn else []
|
|
7555
|
+
|
|
7556
|
+
def _ingest(changed):
|
|
7557
|
+
return sync_cache(conn, only_paths=set(changed))
|
|
7558
|
+
|
|
7559
|
+
try:
|
|
7560
|
+
if passive:
|
|
7561
|
+
# Frozen-data contract: no ingest, no emit. Keep-alive only.
|
|
7562
|
+
while True:
|
|
7563
|
+
_time.sleep(_LIVE_TAIL_KEEPALIVE)
|
|
7564
|
+
self.wfile.write(b": keep-alive\n\n")
|
|
7565
|
+
self.wfile.flush()
|
|
7566
|
+
|
|
7567
|
+
files = _resolve()
|
|
7568
|
+
# Best-effort connect ingest for immediacy, then baseline `seen`
|
|
7569
|
+
# from the cache's own offsets (session_files) so any pre-connect
|
|
7570
|
+
# growth the connect-ingest declined is still caught on cycle 1.
|
|
7571
|
+
try:
|
|
7572
|
+
if files:
|
|
7573
|
+
sync_cache(conn, only_paths=set(files))
|
|
7574
|
+
except sqlite3.DatabaseError:
|
|
7575
|
+
pass
|
|
7576
|
+
seen = _cached_file_sigs(conn, files)
|
|
7577
|
+
|
|
7578
|
+
idle = 0.0
|
|
7579
|
+
cycles = 0
|
|
7580
|
+
while True:
|
|
7581
|
+
_time.sleep(_LIVE_TAIL_POLL_INTERVAL)
|
|
7582
|
+
cycles += 1
|
|
7583
|
+
changed = watch.changed_paths(files, seen)
|
|
7584
|
+
if changed:
|
|
7585
|
+
_time.sleep(_LIVE_TAIL_DEBOUNCE)
|
|
7586
|
+
new_seen, emitted = watch.watch_step(
|
|
7587
|
+
files, seen, ingest_fn=_ingest,
|
|
7588
|
+
committed_sig_fn=lambda p: _cached_file_sigs(conn, [p]).get(p))
|
|
7589
|
+
seen = new_seen
|
|
7590
|
+
if emitted:
|
|
7591
|
+
self.wfile.write(
|
|
7592
|
+
("event: tail\ndata: "
|
|
7593
|
+
+ json.dumps({"sessionId": session_id})
|
|
7594
|
+
+ "\n\n").encode("utf-8"))
|
|
7595
|
+
self.wfile.flush()
|
|
7596
|
+
idle = 0.0
|
|
7597
|
+
continue
|
|
7598
|
+
idle += _LIVE_TAIL_POLL_INTERVAL
|
|
7599
|
+
if idle >= _LIVE_TAIL_KEEPALIVE:
|
|
7600
|
+
self.wfile.write(b": keep-alive\n\n")
|
|
7601
|
+
self.wfile.flush()
|
|
7602
|
+
idle = 0.0
|
|
7603
|
+
if cycles % _LIVE_TAIL_FILE_RESET_EVERY == 0:
|
|
7604
|
+
files = _resolve()
|
|
7605
|
+
seen = {p: s for p, s in seen.items() if p in set(files)}
|
|
7606
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
7607
|
+
pass # client disconnect is normal
|
|
7608
|
+
finally:
|
|
7609
|
+
if conn is not None:
|
|
7610
|
+
conn.close()
|
|
7611
|
+
|
|
7392
7612
|
def _handle_get_conversation_search(self) -> None:
|
|
7393
7613
|
"""``GET /api/conversation/search?q=...&kind=...`` — cross-session
|
|
7394
7614
|
FTS/LIKE search (spec §3.3). Matched BEFORE the ``<id>`` reader in
|
|
@@ -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
|
|
@@ -352,6 +451,20 @@ def _session_latest_meta_map(conn, session_ids):
|
|
|
352
451
|
return meta
|
|
353
452
|
|
|
354
453
|
|
|
454
|
+
def session_source_paths(conn, session_id):
|
|
455
|
+
"""Distinct JSONL source files backing one session — the file-set the
|
|
456
|
+
live-tail watch loop polls (spec §2.3). Reads ``conversation_messages``,
|
|
457
|
+
the reader's own source of truth, NOT ``session_files`` (whose ``session_id``
|
|
458
|
+
is lazy / filename-fallback). Returns a list of path strings; empty for an
|
|
459
|
+
unknown or not-yet-ingested session.
|
|
460
|
+
"""
|
|
461
|
+
rows = conn.execute(
|
|
462
|
+
"SELECT DISTINCT source_path FROM conversation_messages "
|
|
463
|
+
"WHERE session_id=? AND source_path IS NOT NULL",
|
|
464
|
+
(session_id,)).fetchall()
|
|
465
|
+
return [r[0] for r in rows]
|
|
466
|
+
|
|
467
|
+
|
|
355
468
|
# Rail sort keys, in the rollup table's STRUCTURAL columns (Task A). ``recent``
|
|
356
469
|
# rides idx_conv_sessions_recent(last_activity_utc DESC, session_id DESC) and
|
|
357
470
|
# early-terminates at LIMIT with no temp B-tree; ``oldest`` scan-sorts the few
|
|
@@ -862,6 +975,12 @@ def _assemble_session(conn, session_id):
|
|
|
862
975
|
it.pop("_has_prose", None)
|
|
863
976
|
header_cost = round(header_cost, 6)
|
|
864
977
|
|
|
978
|
+
# Stamp cache-failure markers (spec §1) AFTER tokens are on each item and
|
|
979
|
+
# while `items` is still document-ordered (the running-max walk is
|
|
980
|
+
# order-dependent). Healthy turns get no key (absent, not zero). Shared
|
|
981
|
+
# assembly -> the flag reaches BOTH the reader detail and the outline.
|
|
982
|
+
_stamp_cache_failures(items)
|
|
983
|
+
|
|
865
984
|
# Strip the internal Phase-4b threading key from EVERY item (meta/human items
|
|
866
985
|
# carry it too, not just assistant turns) so it never surfaces in the public
|
|
867
986
|
# item JSON.
|
|
@@ -995,6 +1114,12 @@ def get_conversation_outline(conn, session_id):
|
|
|
995
1114
|
tool_counts, models = {}, {}
|
|
996
1115
|
error_count = 0
|
|
997
1116
|
tokens = {"input": 0, "output": 0, "cache_creation": 0, "cache_read": 0}
|
|
1117
|
+
# Cache-failure aggregate (spec §2): count + summed lost-prefix tokens +
|
|
1118
|
+
# summed wasted-cost over the flagged turns. Emitted only when count > 0
|
|
1119
|
+
# (the per-turn "absent, not zero" convention; ~65% of sessions have zero).
|
|
1120
|
+
cf_count = 0
|
|
1121
|
+
cf_tokens = 0
|
|
1122
|
+
cf_wasted = 0.0
|
|
998
1123
|
for it in items:
|
|
999
1124
|
kind = it["kind"]
|
|
1000
1125
|
turn_counts["total"] += 1
|
|
@@ -1036,6 +1161,15 @@ def get_conversation_outline(conn, session_id):
|
|
|
1036
1161
|
t["tokens"] = tok
|
|
1037
1162
|
for k in tokens:
|
|
1038
1163
|
tokens[k] += tok.get(k, 0)
|
|
1164
|
+
# Copy the cache-failure marker onto the OutlineTurn exactly where
|
|
1165
|
+
# tokens is copied (assistant-only, rides the same source row) and
|
|
1166
|
+
# accumulate the session-level aggregate (spec §2).
|
|
1167
|
+
cf = it.get("cache_failure")
|
|
1168
|
+
if cf is not None:
|
|
1169
|
+
t["cache_failure"] = cf
|
|
1170
|
+
cf_count += 1
|
|
1171
|
+
cf_tokens += cf.get("tokens_recreated", 0)
|
|
1172
|
+
cf_wasted += cf.get("est_wasted_usd", 0.0)
|
|
1039
1173
|
if kind == "meta":
|
|
1040
1174
|
t["meta_kind"] = it.get("meta_kind")
|
|
1041
1175
|
t["skill_name"] = it.get("skill_name")
|
|
@@ -1046,12 +1180,17 @@ def get_conversation_outline(conn, session_id):
|
|
|
1046
1180
|
d0 = _parse_outline_ts(ts_vals[0] if ts_vals else None)
|
|
1047
1181
|
d1 = _parse_outline_ts(ts_vals[-1] if ts_vals else None)
|
|
1048
1182
|
duration = int((d1 - d0).total_seconds()) if d0 and d1 else None
|
|
1183
|
+
stats = {"turns": turn_counts, "tool_counts": tool_counts,
|
|
1184
|
+
"error_count": error_count, "models": models,
|
|
1185
|
+
"duration_seconds": duration, "tokens": tokens,
|
|
1186
|
+
"cost_usd": asm["header_cost"]}
|
|
1187
|
+
if cf_count:
|
|
1188
|
+
stats["cache_failures"] = {"count": cf_count,
|
|
1189
|
+
"tokens_recreated": cf_tokens,
|
|
1190
|
+
"est_wasted_usd": cf_wasted}
|
|
1049
1191
|
return {"session_id": session_id,
|
|
1050
1192
|
"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"]},
|
|
1193
|
+
"stats": stats,
|
|
1055
1194
|
"turns": turns}
|
|
1056
1195
|
|
|
1057
1196
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Pure watch-step kernel for the conversation live-tail (spec §2.4).
|
|
2
|
+
|
|
3
|
+
No I/O of its own — the filesystem `stat` and the targeted ingest are injected
|
|
4
|
+
as callables so the cycle is unit-testable without real timing, threads, or a
|
|
5
|
+
DB. The thin SSE/sleep/keep-alive driver lives in bin/_cctally_dashboard.py.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def file_sig(path):
|
|
10
|
+
"""Size-only signature (st_size, an int) for a path, or None if it can't be
|
|
11
|
+
stat'd (deleted / rotated). Size-only by design: it must match sync_cache's
|
|
12
|
+
own size-only delta signal (Claude Code's JSONL sessions are strictly
|
|
13
|
+
append-only, so a size change is sufficient) — mtime jitter must NOT drive a
|
|
14
|
+
re-emit, since a size-unchanged ingest does not refresh session_files.mtime_ns
|
|
15
|
+
and a stale mtime would otherwise re-detect "changed" every cycle forever.
|
|
16
|
+
Pure-ish — the only I/O, isolated here so callers can inject a fake in
|
|
17
|
+
tests."""
|
|
18
|
+
import os
|
|
19
|
+
try:
|
|
20
|
+
st = os.stat(path)
|
|
21
|
+
except OSError:
|
|
22
|
+
return None
|
|
23
|
+
return st.st_size
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def changed_paths(files, seen, stat_fn=file_sig):
|
|
27
|
+
"""Paths whose current size-only signature differs from `seen` (matches
|
|
28
|
+
sync_cache's size-only delta; mtime jitter must not drive a re-emit). An
|
|
29
|
+
unstatable path (stat_fn → None) is skipped (dropped this cycle, re-resolved
|
|
30
|
+
later); a path absent from `seen` counts as changed (first observation /
|
|
31
|
+
pre-connect growth)."""
|
|
32
|
+
out = []
|
|
33
|
+
for p in files:
|
|
34
|
+
sig = stat_fn(p)
|
|
35
|
+
if sig is None:
|
|
36
|
+
continue
|
|
37
|
+
if seen.get(p) != sig:
|
|
38
|
+
out.append(p)
|
|
39
|
+
return out
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def watch_step(files, seen, *, stat_fn=file_sig, ingest_fn, committed_sig_fn=None):
|
|
43
|
+
"""One watch cycle. Returns (new_seen, emitted).
|
|
44
|
+
|
|
45
|
+
Detect changed files (by size-only signature — matches sync_cache's
|
|
46
|
+
size-only delta; mtime jitter must not drive a re-emit) → run
|
|
47
|
+
ingest_fn(changed) (targeted sync_cache). Emit + advance `seen` ONLY on a
|
|
48
|
+
clean ingest (stats.targeted_clean). `seen` is advanced to the COMMITTED
|
|
49
|
+
cache cursor (committed_sig_fn) — NOT a fresh filesystem re-stat — so a file
|
|
50
|
+
that grew during the ingest is still seen as changed next cycle (the cache
|
|
51
|
+
cursor, in session_files, lags the new disk size). committed_sig_fn defaults
|
|
52
|
+
to stat_fn for pure unit tests with no cache. A contended/declined/failed
|
|
53
|
+
ingest leaves `seen` untouched so the next cycle retries (the 5s backstop is
|
|
54
|
+
the floor)."""
|
|
55
|
+
committed_sig_fn = committed_sig_fn or stat_fn
|
|
56
|
+
changed = changed_paths(files, seen, stat_fn)
|
|
57
|
+
if not changed:
|
|
58
|
+
return seen, False
|
|
59
|
+
stats = ingest_fn(changed)
|
|
60
|
+
if not getattr(stats, "targeted_clean", False):
|
|
61
|
+
return seen, False
|
|
62
|
+
new_seen = dict(seen)
|
|
63
|
+
for p in changed:
|
|
64
|
+
sig = committed_sig_fn(p)
|
|
65
|
+
if sig is not None:
|
|
66
|
+
new_seen[p] = sig
|
|
67
|
+
return new_seen, True
|