cctally 1.103.0 → 1.105.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +6 -6
  3. package/bin/_cctally_alerts.py +104 -10
  4. package/bin/_cctally_cache.py +193 -71
  5. package/bin/_cctally_config.py +62 -2
  6. package/bin/_cctally_core.py +62 -1
  7. package/bin/_cctally_dashboard.py +694 -70
  8. package/bin/_cctally_dashboard_conversation.py +400 -6
  9. package/bin/_cctally_dashboard_envelope.py +336 -14
  10. package/bin/_cctally_dashboard_perf.py +93 -0
  11. package/bin/_cctally_dashboard_share.py +56 -25
  12. package/bin/_cctally_dashboard_sources.py +988 -94
  13. package/bin/_cctally_db.py +30 -3
  14. package/bin/_cctally_diagnosis_sources.py +699 -186
  15. package/bin/_cctally_doctor.py +77 -0
  16. package/bin/_cctally_forecast.py +932 -51
  17. package/bin/_cctally_journal.py +430 -24
  18. package/bin/_cctally_parser.py +66 -0
  19. package/bin/_cctally_project.py +535 -10
  20. package/bin/_cctally_quota.py +28 -0
  21. package/bin/_cctally_quota_calibration.py +146 -0
  22. package/bin/_cctally_quota_model.py +2142 -0
  23. package/bin/_cctally_record.py +204 -30
  24. package/bin/_cctally_share.py +16 -8
  25. package/bin/_cctally_statusline.py +34 -0
  26. package/bin/_cctally_tui.py +531 -115
  27. package/bin/_lib_codex_conversation_query.py +258 -12
  28. package/bin/_lib_codex_hooks.py +26 -0
  29. package/bin/_lib_conversation_query.py +145 -15
  30. package/bin/_lib_dashboard_json.py +105 -0
  31. package/bin/_lib_dashboard_settings_contract.py +2 -0
  32. package/bin/_lib_diagnosis.py +21 -2
  33. package/bin/_lib_doctor.py +215 -1
  34. package/bin/_lib_forecast.py +337 -43
  35. package/bin/_lib_ingest_frontier.py +889 -0
  36. package/bin/_lib_meter_rate_change.py +360 -0
  37. package/bin/_lib_perf.py +22 -0
  38. package/bin/_lib_pricing.py +30 -3
  39. package/bin/_lib_quota_calibration.py +311 -0
  40. package/bin/_lib_quota_copy.py +157 -0
  41. package/bin/_lib_quota_model.py +2520 -0
  42. package/bin/_lib_rate_change_delivery.py +119 -0
  43. package/bin/_lib_record.py +50 -0
  44. package/bin/_lib_rederive.py +10 -0
  45. package/bin/_lib_render.py +6 -0
  46. package/bin/_lib_retained_size.py +187 -0
  47. package/bin/_lib_share_templates.py +37 -5
  48. package/bin/_lib_snapshot_cache.py +504 -17
  49. package/bin/_lib_statusline.py +200 -2
  50. package/bin/_lib_tick_stats.py +28 -4
  51. package/bin/_lib_view_models.py +30 -12
  52. package/bin/cctally +32 -0
  53. package/dashboard/static/assets/ConversationsView-DA4j7Yso.js +72 -0
  54. package/dashboard/static/assets/DoctorModal-D4PbVnbE.js +1 -0
  55. package/dashboard/static/assets/ModalRoot-D4oSPxPp.js +1 -0
  56. package/dashboard/static/assets/ProjectsDrillPanel-DkPlB9JH.js +1 -0
  57. package/dashboard/static/assets/SourceDetailModal-BE7FONsS.js +1 -0
  58. package/dashboard/static/assets/UpdateModal-BJuI8glf.js +7 -0
  59. package/dashboard/static/assets/index-klO46NcU.css +1 -0
  60. package/dashboard/static/assets/index-w_bINkJ8.js +13 -0
  61. package/dashboard/static/assets/outlineNavigation-zDVm6Hdd.js +9 -0
  62. package/dashboard/static/assets/useKeymap-CJ-Pi17D.js +1 -0
  63. package/dashboard/static/dashboard.html +3 -2
  64. package/package.json +10 -1
  65. package/dashboard/static/assets/index-Di2hljvB.css +0 -1
  66. package/dashboard/static/assets/index-XYCIWjVG.js +0 -97
@@ -48,7 +48,9 @@ import datetime as dt
48
48
  import hashlib
49
49
  import os
50
50
  import sqlite3
51
+ import sys
51
52
  import threading
53
+ import time
52
54
  from dataclasses import dataclass
53
55
  from typing import TYPE_CHECKING, Callable, NamedTuple
54
56
 
@@ -61,6 +63,12 @@ from _cctally_core import parse_iso_datetime
61
63
  # still a TYPE_CHECKING-only hint). _lib_aggregators imports only _cctally_core,
62
64
  # so there is no import cycle.
63
65
  from _lib_aggregators import _fold_entry, _finalize_bucket, _new_bucket_acc
66
+ from _lib_retained_size import (
67
+ RETAINED_SIZE_WORK_LOCK,
68
+ RETAINED_SIZE_WORKER_DUTY,
69
+ RetainedSizeCancelled,
70
+ retained_size_bytes,
71
+ )
64
72
 
65
73
  if TYPE_CHECKING: # type hints only — no runtime coupling to the aggregators
66
74
  from _lib_aggregators import BucketUsage, ClaudeSessionUsage
@@ -413,6 +421,12 @@ def reset_owner_thread() -> None:
413
421
  _OWNER_THREAD_IDENT = None
414
422
 
415
423
 
424
+ def _restore_owner_thread(owner_ident: int | None) -> None:
425
+ """Restore the owner that was active before a lock-scoped helper."""
426
+ global _OWNER_THREAD_IDENT
427
+ _OWNER_THREAD_IDENT = owner_ident
428
+
429
+
416
430
  def _assert_owner() -> None:
417
431
  if _OWNER_THREAD_IDENT is not None and threading.get_ident() != _OWNER_THREAD_IDENT:
418
432
  raise RuntimeError(
@@ -421,6 +435,98 @@ def _assert_owner() -> None:
421
435
  )
422
436
 
423
437
 
438
+ # #684: the retained-memory verifier keys work to actual cache mutation, not
439
+ # publisher ticks or wall-clock-derived data-version strings. The first write
440
+ # after a claimed generation advances the counter; subsequent writes coalesce
441
+ # until the publisher submits one shallow container snapshot. A later write
442
+ # advances it again and cooperatively cancels the now-superseded walk.
443
+ _SNAPSHOT_ACCELERATOR_DIRTY = True
444
+ _SNAPSHOT_ACCELERATOR_DIRTY_LOCK = threading.Lock()
445
+ _SNAPSHOT_ACCELERATOR_GENERATION = 0
446
+
447
+
448
+ def _mark_snapshot_accelerators_dirty() -> None:
449
+ global _SNAPSHOT_ACCELERATOR_DIRTY
450
+ global _SNAPSHOT_ACCELERATOR_GENERATION
451
+ if _SNAPSHOT_ACCELERATOR_DIRTY:
452
+ return
453
+ with _SNAPSHOT_ACCELERATOR_DIRTY_LOCK:
454
+ if not _SNAPSHOT_ACCELERATOR_DIRTY:
455
+ _SNAPSHOT_ACCELERATOR_DIRTY = True
456
+ _SNAPSHOT_ACCELERATOR_GENERATION += 1
457
+
458
+
459
+ def _take_snapshot_accelerator_dirty() -> int | None:
460
+ """Claim the coalesced mutation generation for one background walk."""
461
+ global _SNAPSHOT_ACCELERATOR_DIRTY
462
+ with _SNAPSHOT_ACCELERATOR_DIRTY_LOCK:
463
+ if not _SNAPSHOT_ACCELERATOR_DIRTY:
464
+ return None
465
+ _SNAPSHOT_ACCELERATOR_DIRTY = False
466
+ return int(_SNAPSHOT_ACCELERATOR_GENERATION)
467
+
468
+
469
+ def _snapshot_retained_shape(value) -> tuple:
470
+ """Cheap replacement signature; deep truth is rechecked periodically."""
471
+ try:
472
+ length = len(value)
473
+ except (TypeError, AttributeError):
474
+ length = None
475
+ namespace = getattr(value, "__dict__", None)
476
+ children = ()
477
+ if isinstance(namespace, dict):
478
+ children = tuple(sorted(
479
+ (
480
+ str(key),
481
+ type(child).__name__,
482
+ sys.getsizeof(child),
483
+ len(child) if hasattr(child, "__len__") else None,
484
+ )
485
+ for key, child in namespace.items()
486
+ ))
487
+ return (type(value).__name__, sys.getsizeof(value), length, children)
488
+
489
+
490
+ class _ObservedSnapshotDict(dict):
491
+ """Dictionary whose successful mutations invalidate aggregate sizing."""
492
+
493
+ def __init__(self, *args, always_mark_replacements=False, **kwargs):
494
+ self._always_mark_replacements = bool(always_mark_replacements)
495
+ super().__init__(*args, **kwargs)
496
+
497
+ def __setitem__(self, key, value):
498
+ existed = key in self
499
+ prior = self.get(key)
500
+ super().__setitem__(key, value)
501
+ if (
502
+ not existed
503
+ or self._always_mark_replacements
504
+ or _snapshot_retained_shape(prior) != _snapshot_retained_shape(value)
505
+ ):
506
+ _mark_snapshot_accelerators_dirty()
507
+
508
+ def __delitem__(self, key):
509
+ super().__delitem__(key)
510
+ _mark_snapshot_accelerators_dirty()
511
+
512
+ def clear(self):
513
+ if self:
514
+ super().clear()
515
+ _mark_snapshot_accelerators_dirty()
516
+
517
+ def pop(self, key, default=None):
518
+ existed = key in self
519
+ value = super().pop(key, default)
520
+ if existed:
521
+ _mark_snapshot_accelerators_dirty()
522
+ return value
523
+
524
+ def update(self, *args, **kwargs):
525
+ super().update(*args, **kwargs)
526
+ if args or kwargs:
527
+ _mark_snapshot_accelerators_dirty()
528
+
529
+
424
530
  # === Task 0.4 — Group A bucket cache holder ================================
425
531
 
426
532
 
@@ -441,7 +547,8 @@ class BucketCache:
441
547
  """
442
548
 
443
549
  def __init__(self) -> None:
444
- self._store: "dict[tuple[str, str], BucketUsage]" = {}
550
+ self._store: "dict[tuple[str, str], BucketUsage]" = (
551
+ _ObservedSnapshotDict())
445
552
 
446
553
  def get(self, builder_key: str, bucket_label: str) -> "BucketUsage | None":
447
554
  """Return the cached aggregate for this bucket, or None on a miss."""
@@ -493,7 +600,8 @@ class SessionCache:
493
600
  """
494
601
 
495
602
  def __init__(self) -> None:
496
- self._store: "dict[str, ClaudeSessionUsage]" = {}
603
+ self._store: "dict[str, ClaudeSessionUsage]" = _ObservedSnapshotDict(
604
+ always_mark_replacements=True)
497
605
 
498
606
  def get_all(self) -> "dict[str, ClaudeSessionUsage]":
499
607
  """Return a shallow COPY of every cached session, keyed by identity.
@@ -546,7 +654,7 @@ class SessionCache:
546
654
  # assembled list each tick and never mutate a cached aggregate (Codex F7).
547
655
 
548
656
  _GROUP_A_CACHE = BucketCache()
549
- _GROUP_A_LAST_SEEN: "dict[str, dict]" = {}
657
+ _GROUP_A_LAST_SEEN: "dict[str, dict]" = _ObservedSnapshotDict()
550
658
 
551
659
 
552
660
  def group_a_cache() -> BucketCache:
@@ -597,7 +705,7 @@ class CurrentBucketAccumulator:
597
705
  last_now: dt.datetime # now_utc upper bound used to clamp the last fold
598
706
 
599
707
 
600
- _GROUP_A_CURRENT: "dict[str, CurrentBucketAccumulator]" = {}
708
+ _GROUP_A_CURRENT: "dict[str, CurrentBucketAccumulator]" = _ObservedSnapshotDict()
601
709
 
602
710
 
603
711
  def reset_group_a_current_state() -> None:
@@ -947,7 +1055,7 @@ def affected_session_keys(
947
1055
  # (Codex F7). Mutated ONLY on the sync thread (single-writer, like Group A).
948
1056
 
949
1057
  _SESSION_CACHE = SessionCache()
950
- _SESSION_LAST_SEEN: "dict" = {}
1058
+ _SESSION_LAST_SEEN: "dict" = _ObservedSnapshotDict()
951
1059
 
952
1060
 
953
1061
  def session_cache() -> SessionCache:
@@ -983,7 +1091,7 @@ class CodexAccountingCacheResult:
983
1091
  changed_new: tuple[object, ...] = ()
984
1092
 
985
1093
 
986
- _CODEX_ACCOUNTING_CACHE_STATE: dict[str, object] = {}
1094
+ _CODEX_ACCOUNTING_CACHE_STATE: dict[str, object] = _ObservedSnapshotDict()
987
1095
  _CODEX_ACCOUNTING_MAX_DIRTY_PATHS = 300
988
1096
 
989
1097
 
@@ -1182,6 +1290,10 @@ def build_cached_codex_accounting(
1182
1290
  dirty_accounts = tuple(sorted(
1183
1291
  {str(account_of(entry)) for entry in (*changed_old, *changed_new)}
1184
1292
  ))
1293
+ # A same-cardinality path replacement can change the deep retained graph
1294
+ # while keeping the tuple's shallow shape. Mark this semantic replacement
1295
+ # explicitly; the routine ``seq``/``end`` watermark updates below do not.
1296
+ _mark_snapshot_accelerators_dirty()
1185
1297
  state["entries"] = entries
1186
1298
  state["seq"] = current_seq
1187
1299
  state["end"] = range_end
@@ -1248,7 +1360,8 @@ def build_cached_sessions(
1248
1360
  # byte-identical to from-scratch even for an id-stable update.
1249
1361
  for sess in reaggregate(last_seen_seq, affected):
1250
1362
  _SESSION_CACHE.put(sess.session_id, sess)
1251
- _SESSION_LAST_SEEN.clear()
1363
+ # Fixed-key watermark update. Clearing first would falsely classify every
1364
+ # unchanged tick as retained-memory growth and cancel the deep verifier.
1252
1365
  _SESSION_LAST_SEEN["max_id"] = cur_max_id
1253
1366
  _SESSION_LAST_SEEN["max_seq"] = cur_max_seq
1254
1367
  _SESSION_LAST_SEEN["extra"] = extra_signature
@@ -1270,6 +1383,9 @@ def build_cached_sessions(
1270
1383
  DOCTOR_MEMO_TTL_S = 30.0
1271
1384
 
1272
1385
  _DOCTOR_MEMO_LOCK = threading.Lock()
1386
+ # Tiny, fixed-key TTL payload. Its routine 30-second clear/repopulate must not
1387
+ # cancel a production-scale accounting walk forever; the five-minute aggregate
1388
+ # remeasurement backstop still includes and verifies it.
1273
1389
  _DOCTOR_MEMO: "dict" = {}
1274
1390
 
1275
1391
 
@@ -1393,8 +1509,8 @@ def reset_dispatch_state() -> None:
1393
1509
  # each rebuild builds FRESH trend / forecast presentation objects and never
1394
1510
  # mutates a cached value (Codex F7).
1395
1511
 
1396
- _WEEKREF_COST_CACHE: dict = {} # {(week_start_iso, week_end_iso): cost_usd}
1397
- _WEEKREF_COST_LAST_SEEN: dict = {} # {"max_id": int, "reset_sig": tuple}
1512
+ _WEEKREF_COST_CACHE: dict = _ObservedSnapshotDict()
1513
+ _WEEKREF_COST_LAST_SEEN: dict = _ObservedSnapshotDict()
1398
1514
 
1399
1515
 
1400
1516
  def _weekref_key(week_start_at, week_end_at):
@@ -1532,9 +1648,9 @@ def reconcile_weekref_cache(cache_conn, *, max_entry_id, max_mutation_seq, reset
1532
1648
  # Single-writer (sync thread only), immutable values, fresh presentation each
1533
1649
  # tick — the Group A / weekref discipline (spec §6, Codex F7).
1534
1650
 
1535
- _PROJECTS_ENV_WEEK_CACHE: dict = {} # {(bucket_path, week_iso): agg}
1536
- _PROJECTS_ENV_WEEK_TOTALS: dict = {} # {week_iso: total_cost} (also the registry)
1537
- _PROJECTS_ENV_LAST_SEEN: dict = {} # {"max_id", "max_wus_id", "sf_sig"}
1651
+ _PROJECTS_ENV_WEEK_CACHE: dict = _ObservedSnapshotDict()
1652
+ _PROJECTS_ENV_WEEK_TOTALS: dict = _ObservedSnapshotDict()
1653
+ _PROJECTS_ENV_LAST_SEEN: dict = _ObservedSnapshotDict()
1538
1654
 
1539
1655
  # #271 M4 (spec §20): the CURRENT-week per-project aggregate is re-folded from
1540
1656
  # scratch every warm tick (the closed weeks are already cache-served). This
@@ -1546,7 +1662,7 @@ _PROJECTS_ENV_LAST_SEEN: dict = {} # {"max_id", "max_wus_id", "sf_sig"}
1546
1662
  # reachable from a published DataSnapshot (F7 — the snapshot holds the finalized
1547
1663
  # buckets, not the running `mut`). The `mut` is opaque here (the dashboard
1548
1664
  # packs/unpacks it — same "no dashboard import" discipline as `BucketCache`).
1549
- _PROJECTS_ENV_CURRENT: dict = {"state": None} # single-slot current-week fold
1665
+ _PROJECTS_ENV_CURRENT: dict = _ObservedSnapshotDict({"state": None})
1550
1666
 
1551
1667
 
1552
1668
  _PROJECTS_ENV_KEY_SEP = "|"
@@ -1973,8 +2089,8 @@ class BugKSegment(NamedTuple):
1973
2089
  entry_count: int
1974
2090
 
1975
2091
 
1976
- _BUGK_SEGMENT_CACHE: dict = {} # {(orig_start_utc_iso, eff_utc_iso): BugKSegment}
1977
- _BUGK_SEGMENT_LAST_SEEN: dict = {} # {"max_id": int, "reset_sig": tuple}
2092
+ _BUGK_SEGMENT_CACHE: dict = _ObservedSnapshotDict()
2093
+ _BUGK_SEGMENT_LAST_SEEN: dict = _ObservedSnapshotDict()
1978
2094
 
1979
2095
 
1980
2096
  def _bugk_key(original_start_at, effective_at):
@@ -2095,8 +2211,379 @@ def reconcile_bugk_cache(cache_conn, *, max_entry_id, max_mutation_seq, reset_si
2095
2211
  # / `reset_sig` / `sf_sig` / display-tz signals, mirroring
2096
2212
  # `reconcile_bugk_cache` + the projects-env `session_files_sig` leg.
2097
2213
 
2098
- _CACHE_REPORT_DAY_CACHE: dict = {} # {date_key: CachedCacheReportDay}
2099
- _CACHE_REPORT_LAST_SEEN: dict = {} # {max_id, max_seq, reset_sig, sf_sig, tz_key}
2214
+ _CACHE_REPORT_DAY_CACHE: dict = _ObservedSnapshotDict()
2215
+ _CACHE_REPORT_LAST_SEEN: dict = _ObservedSnapshotDict()
2216
+
2217
+
2218
+ # #684: one admission owner for every cross-build accelerator in this module.
2219
+ # The opaque last-published snapshot is deliberately not in this set: it is the
2220
+ # product state shared with the SSE hub, not an accelerator, and counting it
2221
+ # here would double-count the same object. All historical bucket/session/
2222
+ # accounting/doctor caches and their invalidation watermarks are included.
2223
+ _SNAPSHOT_ACCELERATOR_MAX_BYTES = 384 * 1024 * 1024
2224
+ _SNAPSHOT_ACCELERATOR_MAX_ENTRIES = 500_000
2225
+ _SNAPSHOT_ACCELERATOR_STATS_LOCK = threading.Lock()
2226
+ _SNAPSHOT_ACCELERATOR_ESTIMATED_BYTES = 0
2227
+ _SNAPSHOT_ACCELERATOR_ENTRY_COUNT = 0
2228
+ _SNAPSHOT_ACCELERATOR_EVICTIONS = 0
2229
+ _SNAPSHOT_ACCELERATOR_FALLBACKS = 0
2230
+ _SNAPSHOT_ACCELERATOR_MEASUREMENT_ERRORS = 0
2231
+ _SNAPSHOT_ACCELERATOR_MEASURED_GENERATION = -1
2232
+ _SNAPSHOT_ACCELERATOR_LAST_MEASURED_MONOTONIC = 0.0
2233
+ _SNAPSHOT_ACCELERATOR_REMEASURE_SECONDS = 300.0
2234
+ _SNAPSHOT_MEMORY_COMPLETION_LOCK = None
2235
+
2236
+
2237
+ class _SnapshotMemoryWorker:
2238
+ """One cooperative latest-generation retained-size walk."""
2239
+
2240
+ def __init__(self) -> None:
2241
+ self._condition = threading.Condition()
2242
+ self._pending: tuple[int, tuple[dict, ...], int] | None = None
2243
+ self._result: tuple[int, int | None, str | None] | None = None
2244
+ self._latest_generation = -1
2245
+ self._active_generation: int | None = None
2246
+ self._stop = False
2247
+ self._thread: threading.Thread | None = None
2248
+ self._duty_wall = 0.0
2249
+ self._duty_cpu = 0.0
2250
+
2251
+ def submit(
2252
+ self, generation: int, roots: tuple[dict, ...], entry_count: int,
2253
+ ) -> None:
2254
+ with self._condition:
2255
+ if self._stop:
2256
+ return
2257
+ self._latest_generation = int(generation)
2258
+ self._pending = (int(generation), roots, int(entry_count))
2259
+ if self._thread is None or not self._thread.is_alive():
2260
+ self._thread = threading.Thread(
2261
+ target=self._run,
2262
+ name="cctally-snapshot-memory",
2263
+ daemon=True,
2264
+ )
2265
+ self._thread.start()
2266
+ self._condition.notify()
2267
+
2268
+ def take_result(self) -> tuple[int, int | None, str | None] | None:
2269
+ with self._condition:
2270
+ result = self._result
2271
+ self._result = None
2272
+ return result
2273
+
2274
+ def _cooperate(self) -> bool:
2275
+ with self._condition:
2276
+ cancelled = (
2277
+ self._stop
2278
+ or self._active_generation != self._latest_generation
2279
+ )
2280
+ if cancelled:
2281
+ return True
2282
+ cpu_elapsed = time.thread_time() - self._duty_cpu
2283
+ wall_elapsed = time.monotonic() - self._duty_wall
2284
+ delay = (cpu_elapsed / RETAINED_SIZE_WORKER_DUTY) - wall_elapsed
2285
+ if delay > 0:
2286
+ time.sleep(min(delay, 0.01))
2287
+ with self._condition:
2288
+ return (
2289
+ self._stop
2290
+ or self._active_generation != self._latest_generation
2291
+ )
2292
+
2293
+ def _run(self) -> None:
2294
+ while True:
2295
+ with self._condition:
2296
+ while self._pending is None and not self._stop:
2297
+ self._condition.wait()
2298
+ if self._stop:
2299
+ return
2300
+ generation, roots, entry_count = self._pending
2301
+ self._pending = None
2302
+ self._active_generation = generation
2303
+ estimated: int | None = None
2304
+ error: str | None = None
2305
+ try:
2306
+ while not RETAINED_SIZE_WORK_LOCK.acquire(timeout=0.05):
2307
+ if self._cooperate():
2308
+ raise RetainedSizeCancelled()
2309
+ try:
2310
+ self._duty_wall = time.monotonic()
2311
+ self._duty_cpu = time.thread_time()
2312
+ estimated = retained_size_bytes(
2313
+ roots,
2314
+ stop_after=_SNAPSHOT_ACCELERATOR_MAX_BYTES,
2315
+ cancelled=self._cooperate,
2316
+ )
2317
+ finally:
2318
+ RETAINED_SIZE_WORK_LOCK.release()
2319
+ except RetainedSizeCancelled:
2320
+ continue
2321
+ except Exception as exc: # noqa: BLE001 - keep the sole worker alive
2322
+ error = f"{type(exc).__name__}: {exc}"
2323
+ finally:
2324
+ roots = ()
2325
+ published = (
2326
+ error is None
2327
+ and estimated is not None
2328
+ and _publish_snapshot_measurement(
2329
+ generation, int(estimated), entry_count)
2330
+ )
2331
+ enforced = False
2332
+ completion_lock = _SNAPSHOT_MEMORY_COMPLETION_LOCK
2333
+ if not published and completion_lock is not None:
2334
+ with completion_lock:
2335
+ previous_owner = _OWNER_THREAD_IDENT
2336
+ mark_owner_thread()
2337
+ try:
2338
+ enforced = _enforce_snapshot_unsafe_completion(
2339
+ generation,
2340
+ None if estimated is None else int(estimated),
2341
+ error,
2342
+ )
2343
+ finally:
2344
+ _restore_owner_thread(previous_owner)
2345
+ with self._condition:
2346
+ self._active_generation = None
2347
+ if (
2348
+ not published and not enforced
2349
+ and not self._stop
2350
+ and generation == self._latest_generation
2351
+ ):
2352
+ self._result = (
2353
+ generation,
2354
+ None if estimated is None else int(estimated),
2355
+ error,
2356
+ )
2357
+
2358
+ def shutdown(self) -> bool:
2359
+ with self._condition:
2360
+ self._stop = True
2361
+ self._pending = None
2362
+ thread = self._thread
2363
+ self._condition.notify_all()
2364
+ if thread is not None:
2365
+ thread.join(timeout=1.0)
2366
+ return thread is None or not thread.is_alive()
2367
+
2368
+ def is_alive(self) -> bool:
2369
+ with self._condition:
2370
+ return self._thread is not None and self._thread.is_alive()
2371
+
2372
+
2373
+ _SNAPSHOT_MEMORY_WORKER = _SnapshotMemoryWorker()
2374
+
2375
+
2376
+ def set_snapshot_memory_completion_lock(lock) -> None:
2377
+ """Bind unsafe verifier completions to the dashboard publisher lock."""
2378
+ global _SNAPSHOT_MEMORY_COMPLETION_LOCK
2379
+ _SNAPSHOT_MEMORY_COMPLETION_LOCK = lock
2380
+
2381
+
2382
+ def _enforce_snapshot_unsafe_completion(
2383
+ generation: int, estimated: int | None, error: str | None,
2384
+ ) -> bool:
2385
+ """Evict a current over-cap/error result without awaiting another tick."""
2386
+ if error is None and (
2387
+ estimated is None or estimated <= _SNAPSHOT_ACCELERATOR_MAX_BYTES
2388
+ ):
2389
+ return False
2390
+ with _SNAPSHOT_ACCELERATOR_DIRTY_LOCK:
2391
+ if (
2392
+ _SNAPSHOT_ACCELERATOR_DIRTY
2393
+ or generation != _SNAPSHOT_ACCELERATOR_GENERATION
2394
+ ):
2395
+ return False
2396
+ entry_count = _snapshot_accelerator_entry_count()
2397
+ global _SNAPSHOT_ACCELERATOR_ESTIMATED_BYTES
2398
+ global _SNAPSHOT_ACCELERATOR_ENTRY_COUNT
2399
+ global _SNAPSHOT_ACCELERATOR_EVICTIONS
2400
+ global _SNAPSHOT_ACCELERATOR_FALLBACKS
2401
+ global _SNAPSHOT_ACCELERATOR_MEASUREMENT_ERRORS
2402
+ with _SNAPSHOT_ACCELERATOR_STATS_LOCK:
2403
+ _clear_snapshot_accelerators()
2404
+ _SNAPSHOT_ACCELERATOR_EVICTIONS += entry_count
2405
+ _SNAPSHOT_ACCELERATOR_FALLBACKS += 1
2406
+ if error is not None:
2407
+ _SNAPSHOT_ACCELERATOR_MEASUREMENT_ERRORS += 1
2408
+ _SNAPSHOT_ACCELERATOR_ESTIMATED_BYTES = 0
2409
+ _SNAPSHOT_ACCELERATOR_ENTRY_COUNT = _snapshot_accelerator_entry_count()
2410
+ return True
2411
+
2412
+
2413
+ def _publish_snapshot_measurement(
2414
+ generation: int, estimated: int, entry_count: int,
2415
+ ) -> bool:
2416
+ """Publish a safe under-cap result immediately, between publisher ticks."""
2417
+ if estimated > _SNAPSHOT_ACCELERATOR_MAX_BYTES:
2418
+ return False
2419
+ with _SNAPSHOT_ACCELERATOR_DIRTY_LOCK:
2420
+ if (
2421
+ _SNAPSHOT_ACCELERATOR_DIRTY
2422
+ or generation != _SNAPSHOT_ACCELERATOR_GENERATION
2423
+ ):
2424
+ return False
2425
+ global _SNAPSHOT_ACCELERATOR_ESTIMATED_BYTES
2426
+ global _SNAPSHOT_ACCELERATOR_ENTRY_COUNT
2427
+ global _SNAPSHOT_ACCELERATOR_MEASURED_GENERATION
2428
+ global _SNAPSHOT_ACCELERATOR_LAST_MEASURED_MONOTONIC
2429
+ with _SNAPSHOT_ACCELERATOR_STATS_LOCK:
2430
+ _SNAPSHOT_ACCELERATOR_ESTIMATED_BYTES = int(estimated)
2431
+ _SNAPSHOT_ACCELERATOR_ENTRY_COUNT = int(entry_count)
2432
+ _SNAPSHOT_ACCELERATOR_MEASURED_GENERATION = int(generation)
2433
+ _SNAPSHOT_ACCELERATOR_LAST_MEASURED_MONOTONIC = time.monotonic()
2434
+ return True
2435
+
2436
+
2437
+ def _snapshot_accelerator_roots() -> tuple[object, ...]:
2438
+ """Every cross-build retained accelerator, in one drift-resistant list."""
2439
+ return (
2440
+ _GROUP_A_CACHE._store,
2441
+ _GROUP_A_LAST_SEEN,
2442
+ _GROUP_A_CURRENT,
2443
+ _SESSION_CACHE._store,
2444
+ _SESSION_LAST_SEEN,
2445
+ _CODEX_ACCOUNTING_CACHE_STATE,
2446
+ _DOCTOR_MEMO,
2447
+ _WEEKREF_COST_CACHE,
2448
+ _WEEKREF_COST_LAST_SEEN,
2449
+ _PROJECTS_ENV_WEEK_CACHE,
2450
+ _PROJECTS_ENV_WEEK_TOTALS,
2451
+ _PROJECTS_ENV_LAST_SEEN,
2452
+ _PROJECTS_ENV_CURRENT,
2453
+ _BUGK_SEGMENT_CACHE,
2454
+ _BUGK_SEGMENT_LAST_SEEN,
2455
+ _CACHE_REPORT_DAY_CACHE,
2456
+ _CACHE_REPORT_LAST_SEEN,
2457
+ )
2458
+
2459
+
2460
+ def _snapshot_accelerator_entry_count() -> int:
2461
+ return sum(len(root) for root in _snapshot_accelerator_roots())
2462
+
2463
+
2464
+ def _clear_snapshot_accelerators() -> None:
2465
+ """Response-preserving overflow fallback: the next tick recomputes cold."""
2466
+ for root in _snapshot_accelerator_roots():
2467
+ root.clear()
2468
+ # Preserve the single-slot mapping shape expected by its callers.
2469
+ _PROJECTS_ENV_CURRENT["state"] = None
2470
+
2471
+
2472
+ def enforce_snapshot_accelerator_bounds(*, data_version: str) -> None:
2473
+ """Admit the completed accelerator estate or discard it as one unit.
2474
+
2475
+ Called after a complete source build, when the just-built public snapshot
2476
+ no longer depends on these mutable stores. Overflow therefore changes only
2477
+ next-tick reuse, never the response that triggered admission.
2478
+ """
2479
+ _assert_owner()
2480
+ del data_version # mutation generations, not render identity, own admission
2481
+ entry_count = _snapshot_accelerator_entry_count()
2482
+ global _SNAPSHOT_ACCELERATOR_ESTIMATED_BYTES
2483
+ global _SNAPSHOT_ACCELERATOR_ENTRY_COUNT
2484
+ global _SNAPSHOT_ACCELERATOR_EVICTIONS
2485
+ global _SNAPSHOT_ACCELERATOR_FALLBACKS
2486
+ global _SNAPSHOT_ACCELERATOR_MEASUREMENT_ERRORS
2487
+ global _SNAPSHOT_ACCELERATOR_GENERATION
2488
+ global _SNAPSHOT_ACCELERATOR_MEASURED_GENERATION
2489
+ global _SNAPSHOT_ACCELERATOR_LAST_MEASURED_MONOTONIC
2490
+ completed = _SNAPSHOT_MEMORY_WORKER.take_result()
2491
+ with _SNAPSHOT_ACCELERATOR_STATS_LOCK:
2492
+ last_measured = _SNAPSHOT_ACCELERATOR_LAST_MEASURED_MONOTONIC
2493
+ last_measured_generation = _SNAPSHOT_ACCELERATOR_MEASURED_GENERATION
2494
+ if (
2495
+ last_measured > 0
2496
+ and last_measured_generation == _SNAPSHOT_ACCELERATOR_GENERATION
2497
+ and time.monotonic() - last_measured
2498
+ >= _SNAPSHOT_ACCELERATOR_REMEASURE_SECONDS
2499
+ ):
2500
+ _mark_snapshot_accelerators_dirty()
2501
+ with _SNAPSHOT_ACCELERATOR_DIRTY_LOCK:
2502
+ current_generation = int(_SNAPSHOT_ACCELERATOR_GENERATION)
2503
+ currently_dirty = bool(_SNAPSHOT_ACCELERATOR_DIRTY)
2504
+ with _SNAPSHOT_ACCELERATOR_STATS_LOCK:
2505
+ measured = (
2506
+ None if completed is None or completed[1] is None
2507
+ else int(completed[1])
2508
+ )
2509
+ measured_generation = -1 if completed is None else int(completed[0])
2510
+ measurement_error = None if completed is None else completed[2]
2511
+ result_is_current = (
2512
+ measured_generation == current_generation and not currently_dirty)
2513
+
2514
+ if entry_count > _SNAPSHOT_ACCELERATOR_MAX_ENTRIES:
2515
+ _clear_snapshot_accelerators()
2516
+ _SNAPSHOT_ACCELERATOR_EVICTIONS += entry_count
2517
+ _SNAPSHOT_ACCELERATOR_FALLBACKS += 1
2518
+ _SNAPSHOT_ACCELERATOR_ESTIMATED_BYTES = 0
2519
+ _SNAPSHOT_ACCELERATOR_ENTRY_COUNT = _snapshot_accelerator_entry_count()
2520
+ elif result_is_current and measurement_error is not None:
2521
+ _clear_snapshot_accelerators()
2522
+ _SNAPSHOT_ACCELERATOR_EVICTIONS += entry_count
2523
+ _SNAPSHOT_ACCELERATOR_FALLBACKS += 1
2524
+ _SNAPSHOT_ACCELERATOR_MEASUREMENT_ERRORS += 1
2525
+ _SNAPSHOT_ACCELERATOR_ESTIMATED_BYTES = 0
2526
+ _SNAPSHOT_ACCELERATOR_ENTRY_COUNT = _snapshot_accelerator_entry_count()
2527
+ elif (
2528
+ result_is_current
2529
+ and measured is not None
2530
+ and measured > _SNAPSHOT_ACCELERATOR_MAX_BYTES
2531
+ ):
2532
+ _clear_snapshot_accelerators()
2533
+ _SNAPSHOT_ACCELERATOR_EVICTIONS += entry_count
2534
+ _SNAPSHOT_ACCELERATOR_FALLBACKS += 1
2535
+ _SNAPSHOT_ACCELERATOR_ESTIMATED_BYTES = 0
2536
+ _SNAPSHOT_ACCELERATOR_ENTRY_COUNT = _snapshot_accelerator_entry_count()
2537
+ elif result_is_current and measured is not None:
2538
+ _SNAPSHOT_ACCELERATOR_ESTIMATED_BYTES = measured
2539
+ _SNAPSHOT_ACCELERATOR_ENTRY_COUNT = int(entry_count)
2540
+ _SNAPSHOT_ACCELERATOR_MEASURED_GENERATION = measured_generation
2541
+ _SNAPSHOT_ACCELERATOR_LAST_MEASURED_MONOTONIC = time.monotonic()
2542
+ else:
2543
+ _SNAPSHOT_ACCELERATOR_ENTRY_COUNT = int(entry_count)
2544
+
2545
+ generation = _take_snapshot_accelerator_dirty()
2546
+ if generation is not None:
2547
+ measured_entry_count = _snapshot_accelerator_entry_count()
2548
+ _SNAPSHOT_MEMORY_WORKER.submit(
2549
+ generation,
2550
+ tuple(dict(root) for root in _snapshot_accelerator_roots()),
2551
+ measured_entry_count,
2552
+ )
2553
+
2554
+
2555
+ def snapshot_accelerator_memory_stats():
2556
+ """Safe numeric diagnostics for the complete snapshot accelerator owner."""
2557
+ with _SNAPSHOT_ACCELERATOR_STATS_LOCK:
2558
+ return {
2559
+ "estimatedBytes": int(_SNAPSHOT_ACCELERATOR_ESTIMATED_BYTES),
2560
+ "maxBytes": int(_SNAPSHOT_ACCELERATOR_MAX_BYTES),
2561
+ "entryCount": int(_SNAPSHOT_ACCELERATOR_ENTRY_COUNT),
2562
+ "maxEntries": int(_SNAPSHOT_ACCELERATOR_MAX_ENTRIES),
2563
+ "evictionCount": int(_SNAPSHOT_ACCELERATOR_EVICTIONS),
2564
+ "fallbackCount": int(_SNAPSHOT_ACCELERATOR_FALLBACKS),
2565
+ "measurementErrorCount": int(
2566
+ _SNAPSHOT_ACCELERATOR_MEASUREMENT_ERRORS),
2567
+ "measuredGeneration": int(
2568
+ _SNAPSHOT_ACCELERATOR_MEASURED_GENERATION),
2569
+ "currentGeneration": int(_SNAPSHOT_ACCELERATOR_GENERATION),
2570
+ "measurementPending": int(
2571
+ _SNAPSHOT_ACCELERATOR_MEASURED_GENERATION
2572
+ != _SNAPSHOT_ACCELERATOR_GENERATION
2573
+ ),
2574
+ "workerAlive": int(
2575
+ getattr(_SNAPSHOT_MEMORY_WORKER, "is_alive", lambda: False)()
2576
+ ),
2577
+ }
2578
+
2579
+
2580
+ def shutdown_snapshot_memory_worker() -> None:
2581
+ """Cancel and join retained-size traversal before replacing its owner."""
2582
+ global _SNAPSHOT_MEMORY_WORKER
2583
+ worker = _SNAPSHOT_MEMORY_WORKER
2584
+ if not worker.shutdown():
2585
+ raise RuntimeError("snapshot memory verifier did not stop")
2586
+ _SNAPSHOT_MEMORY_WORKER = _SnapshotMemoryWorker()
2100
2587
 
2101
2588
 
2102
2589
  def reset_cache_report_state():