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.
@@ -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": {"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"]},
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