cctally 1.104.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 (48) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +6 -6
  3. package/bin/_cctally_alerts.py +46 -13
  4. package/bin/_cctally_cache.py +193 -71
  5. package/bin/_cctally_dashboard.py +683 -70
  6. package/bin/_cctally_dashboard_conversation.py +400 -6
  7. package/bin/_cctally_dashboard_envelope.py +17 -0
  8. package/bin/_cctally_dashboard_perf.py +93 -0
  9. package/bin/_cctally_dashboard_sources.py +988 -94
  10. package/bin/_cctally_db.py +30 -3
  11. package/bin/_cctally_diagnosis_sources.py +699 -186
  12. package/bin/_cctally_doctor.py +22 -8
  13. package/bin/_cctally_forecast.py +17 -9
  14. package/bin/_cctally_journal.py +298 -40
  15. package/bin/_cctally_quota.py +15 -0
  16. package/bin/_cctally_quota_model.py +584 -58
  17. package/bin/_cctally_record.py +133 -67
  18. package/bin/_cctally_tui.py +317 -70
  19. package/bin/_lib_codex_conversation_query.py +258 -12
  20. package/bin/_lib_codex_hooks.py +26 -0
  21. package/bin/_lib_conversation_query.py +145 -15
  22. package/bin/_lib_dashboard_json.py +105 -0
  23. package/bin/_lib_diagnosis.py +21 -2
  24. package/bin/_lib_doctor.py +64 -8
  25. package/bin/_lib_ingest_frontier.py +889 -0
  26. package/bin/_lib_meter_rate_change.py +95 -29
  27. package/bin/_lib_perf.py +22 -0
  28. package/bin/_lib_pricing.py +30 -3
  29. package/bin/_lib_quota_copy.py +30 -4
  30. package/bin/_lib_quota_model.py +215 -28
  31. package/bin/_lib_rate_change_delivery.py +119 -0
  32. package/bin/_lib_record.py +50 -0
  33. package/bin/_lib_retained_size.py +187 -0
  34. package/bin/_lib_snapshot_cache.py +504 -17
  35. package/bin/_lib_statusline.py +3 -29
  36. package/bin/_lib_tick_stats.py +28 -4
  37. package/dashboard/static/assets/ConversationsView-DA4j7Yso.js +72 -0
  38. package/dashboard/static/assets/DoctorModal-D4PbVnbE.js +1 -0
  39. package/dashboard/static/assets/ModalRoot-D4oSPxPp.js +1 -0
  40. package/dashboard/static/assets/ProjectsDrillPanel-DkPlB9JH.js +1 -0
  41. package/dashboard/static/assets/SourceDetailModal-BE7FONsS.js +1 -0
  42. package/dashboard/static/assets/UpdateModal-BJuI8glf.js +7 -0
  43. package/dashboard/static/assets/index-w_bINkJ8.js +13 -0
  44. package/dashboard/static/assets/outlineNavigation-zDVm6Hdd.js +9 -0
  45. package/dashboard/static/assets/useKeymap-CJ-Pi17D.js +1 -0
  46. package/dashboard/static/dashboard.html +2 -1
  47. package/package.json +4 -1
  48. package/dashboard/static/assets/index-D19TO7Mg.js +0 -97
@@ -266,6 +266,7 @@ import contextlib
266
266
  import dataclasses
267
267
  import datetime as dt
268
268
  import gzip
269
+ import hashlib
269
270
  import hmac
270
271
  import io
271
272
  import json
@@ -286,11 +287,13 @@ import urllib.parse
286
287
  import urllib.request
287
288
  import webbrowser as _wb
288
289
  import zlib
290
+ from collections import OrderedDict
289
291
  from dataclasses import dataclass, field, replace
290
292
  from collections.abc import Mapping
291
293
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
292
294
  from typing import Any, NamedTuple
293
295
  from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
296
+ from _lib_retained_size import retained_size_bytes
294
297
 
295
298
 
296
299
  class _QuietThreadingHTTPServer(ThreadingHTTPServer):
@@ -321,6 +324,84 @@ class _QuietThreadingHTTPServer(ThreadingHTTPServer):
321
324
  super().handle_error(request, client_address)
322
325
 
323
326
 
327
+ @dataclass
328
+ class _DiagnosisFlight:
329
+ """One exact-scope diagnosis shared by concurrent request threads."""
330
+
331
+ done: threading.Event = field(default_factory=threading.Event)
332
+ result: Any = None
333
+ error: BaseException | None = None
334
+ callers: int = 0
335
+
336
+
337
+ def _diagnosis_flight_key(
338
+ scope, transcripts_visible: bool, reveal_projects: bool,
339
+ ) -> tuple[Any, bool, bool]:
340
+ """The complete build-authorization identity for one diagnosis request.
341
+
342
+ ``DiagnosisScope`` is frozen and includes source, account, half-open range,
343
+ effective speed, display timezone and label. Transcript visibility is a
344
+ separate authorization input to plan stage 1, so it must be part of the
345
+ identity even when every other selector matches. Project reveal changes
346
+ the response shaping and is included so the admission covers both the
347
+ transient fact populations and their complete wire projection.
348
+ """
349
+ return scope, bool(transcripts_visible), bool(reveal_projects)
350
+
351
+
352
+ class _DiagnosisAdmission:
353
+ """One process-wide diagnosis build with exact-scope single-flight.
354
+
355
+ ``ThreadingHTTPServer`` admits one thread per tab/client. The diagnosis's
356
+ All-provider path can in turn launch an isolated Codex worker and retain
357
+ both providers' largest transient populations. A process-wide admission
358
+ slot bounds that process tree; the flight table prevents identical queued
359
+ callers from repeating the same read once admitted. Completed reports are
360
+ never retained, so this adds no stale report cache or periodic work.
361
+ """
362
+
363
+ def __init__(self) -> None:
364
+ self._changed = threading.Condition()
365
+ self._admission = threading.BoundedSemaphore(1)
366
+ self._flights: dict[tuple[Any, bool, bool], _DiagnosisFlight] = {}
367
+
368
+ def run(self, key: tuple[Any, bool, bool], build):
369
+ with self._changed:
370
+ flight = self._flights.get(key)
371
+ owner = flight is None
372
+ if owner:
373
+ flight = _DiagnosisFlight()
374
+ self._flights[key] = flight
375
+ flight.callers += 1
376
+ self._changed.notify_all()
377
+
378
+ if not owner:
379
+ flight.done.wait()
380
+ if flight.error is not None:
381
+ raise flight.error
382
+ return flight.result
383
+
384
+ try:
385
+ with self._admission:
386
+ result = build()
387
+ with self._changed:
388
+ flight.result = result
389
+ return result
390
+ except BaseException as exc:
391
+ with self._changed:
392
+ flight.error = exc
393
+ raise
394
+ finally:
395
+ with self._changed:
396
+ if self._flights.get(key) is flight:
397
+ del self._flights[key]
398
+ flight.done.set()
399
+ self._changed.notify_all()
400
+
401
+
402
+ _DIAGNOSIS_ADMISSION = _DiagnosisAdmission()
403
+
404
+
324
405
  def _cctally():
325
406
  """Resolve the current ``cctally`` module at call-time (spec §5.5)."""
326
407
  return sys.modules["cctally"]
@@ -589,6 +670,7 @@ _ensure_sibling_loaded("_cctally_dashboard_sources")
589
670
  from _cctally_dashboard_sources import (
590
671
  SourceCapabilityUnavailable,
591
672
  SourceResourceNotFound,
673
+ shutdown_codex_source_memory_worker,
592
674
  source_detail_lookup,
593
675
  )
594
676
  from _lib_dashboard_sources import dashboard_resource_key
@@ -1463,7 +1545,7 @@ from _cctally_dashboard_conversation import (
1463
1545
  _CONV_FIND_KINDS,
1464
1546
  _BadConversationFilter,
1465
1547
  _cached_file_sigs,
1466
- # query plumbing + eleven handler impls (the class delegators call these)
1548
+ # query plumbing + handler impls (the class delegators call these)
1467
1549
  _conversation_query_impl,
1468
1550
  _parse_search_kind_impl,
1469
1551
  _run_conversation_query_impl,
@@ -1475,6 +1557,8 @@ from _cctally_dashboard_conversation import (
1475
1557
  _handle_get_conversation_search_impl,
1476
1558
  _handle_get_conversation_payload_impl,
1477
1559
  _handle_get_conversation_outline_impl,
1560
+ _handle_get_conversation_outline_transfer_impl,
1561
+ _handle_delete_conversation_outline_transfer_impl,
1478
1562
  _handle_get_conversation_prompts_impl,
1479
1563
  _handle_get_conversation_export_impl,
1480
1564
  _handle_get_conversation_anon_map_impl,
@@ -1796,6 +1880,111 @@ def _dashboard_maybe_prune_retention() -> None:
1796
1880
  pass
1797
1881
 
1798
1882
 
1883
+ def _conversation_frontier_context():
1884
+ """Roots, hook guards and trust decisions for transcript fast-negatives.
1885
+
1886
+ This mirrors the main ingest frontier's evidence contract. It is kept at
1887
+ call time because tests and dev instances redirect every path after module
1888
+ import, and because Codex hook roots are provider-root dependent.
1889
+ """
1890
+ frontier_mod = _cctally()._load_sibling("_lib_ingest_frontier")
1891
+ claude_roots = tuple(_cctally_core._resolve_claude_projects_dirs())
1892
+ codex_homes = tuple(_cctally()._codex_home_roots())
1893
+ codex_roots = tuple(root / "sessions" for root in codex_homes)
1894
+ claude_guards = (_cctally_core.CLAUDE_SETTINGS_PATH,)
1895
+
1896
+ try:
1897
+ codex_hooks_mod = _cctally()._load_sibling("_lib_codex_hooks")
1898
+ hook_roots = codex_hooks_mod.codex_hook_roots(codex_homes)
1899
+ codex_guards = tuple(root.hooks_path for root in hook_roots)
1900
+ except Exception:
1901
+ codex_hooks_mod = None
1902
+ hook_roots = ()
1903
+ codex_guards = ()
1904
+
1905
+ def claude_trusted():
1906
+ try:
1907
+ setup_mod = _cctally()._load_sibling("_cctally_setup")
1908
+ settings = setup_mod._load_claude_settings()
1909
+ hooks = settings.get("hooks", {})
1910
+ return all(
1911
+ any(
1912
+ frontier_mod.is_dashboard_activity_claude_hook_handler(handler)
1913
+ for group in hooks.get(event, ())
1914
+ if isinstance(group, dict)
1915
+ for handler in group.get("hooks", ())
1916
+ )
1917
+ for event in _cctally().SETUP_HOOK_EVENTS
1918
+ )
1919
+ except Exception:
1920
+ return False
1921
+
1922
+ def codex_trusted():
1923
+ if codex_hooks_mod is None:
1924
+ return False
1925
+ try:
1926
+ trusted = bool(hook_roots)
1927
+ for hook_root in hook_roots:
1928
+ document = codex_hooks_mod._read_hooks_document(
1929
+ hook_root.hooks_path)
1930
+ hooks = document.get("hooks", {})
1931
+ for event in codex_hooks_mod.CODEX_HOOK_EVENTS:
1932
+ owned = sum(
1933
+ 1
1934
+ for group in hooks.get(event, ())
1935
+ if isinstance(group, dict)
1936
+ for handler in group.get("hooks", ())
1937
+ if codex_hooks_mod.is_dashboard_activity_codex_hook_handler(
1938
+ handler)
1939
+ )
1940
+ trusted = trusted and owned >= 1
1941
+ return trusted
1942
+ except Exception:
1943
+ return False
1944
+
1945
+ return frontier_mod, {
1946
+ "claude": (claude_roots, claude_guards, claude_trusted()),
1947
+ "codex": (codex_roots, codex_guards, codex_trusted()),
1948
+ }
1949
+
1950
+
1951
+ def _conversation_frontier_plans(conn):
1952
+ """Return `(frontier, cutoff, context, plans)` or None for safe full work."""
1953
+ try:
1954
+ # A lightweight fake connection used by the scheduling algebra tests
1955
+ # has no `execute`; production connections always do. The fallback is
1956
+ # the pre-#682 full pass, never a skipped sync.
1957
+ if not callable(getattr(conn, "execute", None)):
1958
+ return None
1959
+ frontier_mod, context = _conversation_frontier_context()
1960
+ app_dir = _cctally_core.APP_DIR
1961
+ frontier = getattr(_conversation_sync_pass, "_frontier", None)
1962
+ if frontier is None or frontier.app_dir != app_dir:
1963
+ frontier = frontier_mod.ConversationSyncFrontier(app_dir)
1964
+ _conversation_sync_pass._frontier = frontier
1965
+ cutoff = frontier.capture_cutoff()
1966
+ plans = {
1967
+ provider: frontier.plan_provider(
1968
+ provider, conn, roots=roots, guard_paths=guards)
1969
+ for provider, (roots, guards, _trusted) in context.items()
1970
+ }
1971
+ return frontier_mod, frontier, cutoff, context, plans
1972
+ except Exception:
1973
+ # Missing/malformed evidence is not a background-worker failure. It is
1974
+ # authorization for the original exhaustive pass.
1975
+ return None
1976
+
1977
+
1978
+ class _ConversationPassStatus(str):
1979
+ """Validated status string carrying fixed-size pass diagnostics."""
1980
+
1981
+ def __new__(cls, status, *, modes=None, files=None):
1982
+ obj = str.__new__(cls, status)
1983
+ obj.modes = dict(modes or {})
1984
+ obj.files = dict(files or {})
1985
+ return obj
1986
+
1987
+
1799
1988
  def _conversation_sync_pass() -> str:
1800
1989
  """One WHOLE transcript-ingest pass (#583 S4 / F5).
1801
1990
 
@@ -1830,11 +2019,65 @@ def _conversation_sync_pass() -> str:
1830
2019
  conn = open_conversations_db()
1831
2020
  except (OSError, sqlite3.DatabaseError) as exc:
1832
2021
  eprint(f"[conversations] background sync unavailable: {exc}")
1833
- return "store_unavailable"
2022
+ return _ConversationPassStatus("store_unavailable")
1834
2023
  status = "ok"
2024
+ modes = {"claude": "not_observed", "codex": "not_observed"}
2025
+ files = {"claude": 0, "codex": 0}
1835
2026
  try:
1836
- sync_claude_conversations(conn)
1837
- sync_codex_conversations(conn)
2027
+ planned = _conversation_frontier_plans(conn)
2028
+ results = {}
2029
+ if planned is None:
2030
+ modes = {"claude": "full", "codex": "full"}
2031
+ results["claude"] = sync_claude_conversations(conn)
2032
+ results["codex"] = sync_codex_conversations(conn)
2033
+ else:
2034
+ frontier_mod, frontier, cutoff, context, plans = planned
2035
+ modes = {
2036
+ provider: plans[provider].mode
2037
+ for provider in ("claude", "codex")
2038
+ }
2039
+ for provider, sync_fn in (
2040
+ ("claude", sync_claude_conversations),
2041
+ ("codex", sync_codex_conversations),
2042
+ ):
2043
+ plan = plans[provider]
2044
+ if plan.mode == "caught_up":
2045
+ results[provider] = None
2046
+ continue
2047
+ sync_kwargs = {}
2048
+ if plan.mode == "targeted":
2049
+ sync_kwargs["only_paths"] = set(plan.paths)
2050
+ elif plan.reason == "source_replaced":
2051
+ # Conversation ingesters deliberately size-skip ordinary
2052
+ # same-length files. A ticketed mtime change at the stored
2053
+ # size is therefore replacement evidence and needs the
2054
+ # provider's from-zero replay before it can be certified.
2055
+ sync_kwargs["rebuild"] = True
2056
+ results[provider] = sync_fn(conn, **sync_kwargs)
2057
+
2058
+ files = {
2059
+ provider: int(getattr(results.get(provider), "files_total", 0) or 0)
2060
+ for provider in ("claude", "codex")
2061
+ }
2062
+
2063
+ certifiable = all(
2064
+ frontier_mod.conversation_sync_certifiable(
2065
+ plans[provider].mode, results.get(provider),
2066
+ expected_paths=len(plans[provider].paths))
2067
+ for provider in ("claude", "codex")
2068
+ )
2069
+ if certifiable:
2070
+ for provider in ("claude", "codex"):
2071
+ roots, guards, trusted = context[provider]
2072
+ plan = plans[provider]
2073
+ if plan.mode == "full":
2074
+ frontier.seed_provider(
2075
+ provider, conn, roots=roots,
2076
+ guard_paths=guards, trusted=trusted, cutoff=cutoff)
2077
+ else:
2078
+ frontier.commit_provider(
2079
+ plan, conn, roots=roots, guard_paths=guards,
2080
+ trusted=trusted, cutoff=cutoff)
1838
2081
  except (OSError, sqlite3.DatabaseError) as exc:
1839
2082
  eprint(f"[conversations] background sync unavailable: {exc}")
1840
2083
  status = "store_unavailable"
@@ -1854,7 +2097,11 @@ def _conversation_sync_pass() -> str:
1854
2097
  except Exception: # noqa: BLE001
1855
2098
  pass
1856
2099
  _dashboard_maybe_prune_retention()
1857
- return status
2100
+ for provider in ("claude", "codex"):
2101
+ if provider in results:
2102
+ files[provider] = int(
2103
+ getattr(results.get(provider), "files_total", 0) or 0)
2104
+ return _ConversationPassStatus(status, modes=modes, files=files)
1858
2105
 
1859
2106
 
1860
2107
  def _conversation_sync_loop(
@@ -1907,6 +2154,12 @@ def _conversation_sync_loop(
1907
2154
  # interval that preceded it would shift the denominator by one
1908
2155
  # pass and publish a share with no upper bound.
1909
2156
  status=status,
2157
+ claude_mode=getattr(status, "modes", {}).get(
2158
+ "claude", "not_observed"),
2159
+ codex_mode=getattr(status, "modes", {}).get(
2160
+ "codex", "not_observed"),
2161
+ claude_files=getattr(status, "files", {}).get("claude", 0),
2162
+ codex_files=getattr(status, "files", {}).get("codex", 0),
1910
2163
  )
1911
2164
  deadline = _conversation_next_deadline(t0, interval, work)
1912
2165
  remaining = deadline - monotonic()
@@ -2584,6 +2837,71 @@ class _SnapshotRef:
2584
2837
  self._restamp_locked()
2585
2838
 
2586
2839
 
2840
+ _SSE_DELIVERY_MAX_ENTRIES = 4
2841
+ _SSE_DELIVERY_MAX_BYTES = 32 * 1024 * 1024
2842
+
2843
+
2844
+ class _SSEFrameCache:
2845
+ """One aggregate retained-frame owner for every delivery in an SSE hub.
2846
+
2847
+ Publications and per-subscriber fresh-clock seeds are distinct delivery
2848
+ objects, but their encoded frames share this ONE admission budget. Thus a
2849
+ reconnect storm can evict older variants, never multiply the 32 MiB cap by
2850
+ the number of subscribers.
2851
+ """
2852
+
2853
+ def __init__(self) -> None:
2854
+ self._cache: "OrderedDict[object, bytes]" = OrderedDict()
2855
+ self._sizes: dict[object, int] = {}
2856
+ self._bytes = 0
2857
+ self._evictions = 0
2858
+ self._fallbacks = 0
2859
+ self._lock = threading.Lock()
2860
+
2861
+ def stats(self) -> Mapping[str, int]:
2862
+ with self._lock:
2863
+ return {
2864
+ "estimatedBytes": int(self._bytes),
2865
+ "maxBytes": int(_SSE_DELIVERY_MAX_BYTES),
2866
+ "entryCount": len(self._cache),
2867
+ "maxEntries": int(_SSE_DELIVERY_MAX_ENTRIES),
2868
+ "evictionCount": int(self._evictions),
2869
+ "fallbackCount": int(self._fallbacks),
2870
+ }
2871
+
2872
+ def clear(self) -> None:
2873
+ with self._lock:
2874
+ self._cache.clear()
2875
+ self._sizes.clear()
2876
+ self._bytes = 0
2877
+
2878
+ def encoded(self, cache_key, project_fn) -> bytes:
2879
+ hit = self._cache.get(cache_key)
2880
+ if hit is not None:
2881
+ return hit
2882
+ with self._lock:
2883
+ hit = self._cache.get(cache_key)
2884
+ if hit is not None:
2885
+ return hit
2886
+ built = project_fn(cache_key[1])
2887
+ entry_bytes = retained_size_bytes(
2888
+ (cache_key, built), stop_after=_SSE_DELIVERY_MAX_BYTES)
2889
+ if entry_bytes > _SSE_DELIVERY_MAX_BYTES:
2890
+ self._fallbacks += 1
2891
+ return built
2892
+ while self._cache and (
2893
+ len(self._cache) >= _SSE_DELIVERY_MAX_ENTRIES
2894
+ or self._bytes + entry_bytes > _SSE_DELIVERY_MAX_BYTES
2895
+ ):
2896
+ old_key, _old_value = self._cache.popitem(last=False)
2897
+ self._bytes -= self._sizes.pop(old_key)
2898
+ self._evictions += 1
2899
+ self._cache[cache_key] = built
2900
+ self._sizes[cache_key] = entry_bytes
2901
+ self._bytes += entry_bytes
2902
+ return built
2903
+
2904
+
2587
2905
  class _SSEDelivery:
2588
2906
  """One publication, projected and encoded at most once per variant.
2589
2907
 
@@ -2612,14 +2930,19 @@ class _SSEDelivery:
2612
2930
  """
2613
2931
 
2614
2932
  __slots__ = ("snapshot", "pinned_now_utc", "pinned_monotonic",
2615
- "_cache", "_lock")
2933
+ "_frame_cache", "_cache_token")
2616
2934
 
2617
- def __init__(self, snapshot, pinned_now_utc, pinned_monotonic) -> None:
2935
+ def __init__(
2936
+ self, snapshot, pinned_now_utc, pinned_monotonic, *, frame_cache=None,
2937
+ ) -> None:
2618
2938
  self.snapshot = snapshot
2619
2939
  self.pinned_now_utc = pinned_now_utc
2620
2940
  self.pinned_monotonic = pinned_monotonic
2621
- self._cache: dict = {}
2622
- self._lock = threading.Lock()
2941
+ self._frame_cache = frame_cache or _SSEFrameCache()
2942
+ self._cache_token = object()
2943
+
2944
+ def cache_stats(self) -> Mapping[str, int]:
2945
+ return self._frame_cache.stats()
2623
2946
 
2624
2947
  def encoded(self, variant_key, project_fn) -> bytes:
2625
2948
  """Return complete SSE frame bytes for ``variant_key``, building once.
@@ -2633,16 +2956,8 @@ class _SSEDelivery:
2633
2956
  before the key is built. Those are different situations and conflating
2634
2957
  them either leaks or breaks the gate.
2635
2958
  """
2636
- hit = self._cache.get(variant_key)
2637
- if hit is not None:
2638
- return hit
2639
- with self._lock:
2640
- hit = self._cache.get(variant_key)
2641
- if hit is not None:
2642
- return hit
2643
- built = project_fn(variant_key)
2644
- self._cache[variant_key] = built
2645
- return built
2959
+ return self._frame_cache.encoded(
2960
+ (self._cache_token, variant_key), project_fn)
2646
2961
 
2647
2962
 
2648
2963
  # #583 S3 §5. A distinct slot for "no oauth_usage configuration at all", so it
@@ -2745,17 +3060,11 @@ def _delivery_is_shareable(snapshot) -> bool:
2745
3060
  def _drain_to_newest(q, first):
2746
3061
  """Return the newest delivery queued on ``q``, discarding older ones.
2747
3062
 
2748
- #583 S3 §5. ``SSEHub`` uses a four-slot queue and ``publish`` discards only
2749
- ONE oldest entry when full, so a client that falls behind holds a backlog
2750
- of up to four deliveries. Each delivery pins its clock at publication, so
2751
- replaying that backlog would render ages several publish periods stale — a
2752
- regression against the present behaviour, where each frame is projected at
2753
- consumption time and its ages are therefore current.
2754
-
2755
- The fix is on the CONSUMER side deliberately: ``SSEHub.publish`` is
2756
- governed by Preserve 4 and the A2 publication tests depend on its
2757
- behaviour, so it is not modified. Draining here is the latest-wins
2758
- behaviour the hub's own docstring already describes.
3063
+ #583 S3 §5 originally allowed a four-delivery backlog. #684 reduces the
3064
+ default queue to one shared delivery and also drains here, so an explicitly
3065
+ larger test/integration queue still preserves the same latest-wins rule.
3066
+ Each delivery pins its clock at publication; replaying any backlog would
3067
+ render ages several publish periods stale.
2759
3068
 
2760
3069
  ``first`` is the item the caller already took off the queue with its own
2761
3070
  blocking ``get``, so the ``queue.Empty`` keep-alive path stays where it is.
@@ -2780,16 +3089,19 @@ class SSEHub:
2780
3089
  #583 S3 §5: what the queues carry is a `_SSEDelivery` wrapping the
2781
3090
  published snapshot, not the snapshot itself, so one tick projects and
2782
3091
  encodes once per variant instead of once per connected client. The
2783
- queueing behaviour below — size, latest-wins discard, lock discipline — is
2784
- unchanged and is governed by Preserve 4.
3092
+ queueing behaviour below is latest-wins and non-blocking. #684 caps the
3093
+ default at one queued delivery per subscriber and releases every retained
3094
+ delivery during shutdown.
2785
3095
  """
2786
3096
 
2787
- def __init__(self, maxsize: int = 4) -> None:
3097
+ def __init__(self, maxsize: int = 1) -> None:
2788
3098
  import threading
2789
3099
  import queue as _queue
2790
3100
  self._lock = threading.Lock()
2791
3101
  self._queues: list[_queue.Queue] = []
2792
3102
  self._maxsize = maxsize
3103
+ self._closed = False
3104
+ self._frame_cache = _SSEFrameCache()
2793
3105
  # Held so we can send the current state to a newly-subscribed
2794
3106
  # client without waiting for the next sync tick.
2795
3107
  self._last: object | None = None
@@ -2798,6 +3110,8 @@ class SSEHub:
2798
3110
  import queue as _queue
2799
3111
  q = _queue.Queue(maxsize=self._maxsize)
2800
3112
  with self._lock:
3113
+ if self._closed:
3114
+ return q
2801
3115
  self._queues.append(q)
2802
3116
  if self._last is not None:
2803
3117
  # Seed the new subscriber so it renders immediately.
@@ -2809,6 +3123,7 @@ class SSEHub:
2809
3123
  snapshot=self._last.snapshot,
2810
3124
  pinned_now_utc=dt.datetime.now(dt.timezone.utc),
2811
3125
  pinned_monotonic=time.monotonic(),
3126
+ frame_cache=self._frame_cache,
2812
3127
  )
2813
3128
  try:
2814
3129
  q.put_nowait(seed)
@@ -2834,8 +3149,40 @@ class SSEHub:
2834
3149
  except ValueError:
2835
3150
  pass
2836
3151
 
3152
+ def close(self) -> None:
3153
+ """Release the last snapshot and every queued delivery at shutdown."""
3154
+ import queue as _queue
3155
+ with self._lock:
3156
+ self._closed = True
3157
+ self._last = None
3158
+ self._frame_cache.clear()
3159
+ for q in self._queues:
3160
+ while True:
3161
+ try:
3162
+ q.get_nowait()
3163
+ except _queue.Empty:
3164
+ break
3165
+ self._queues.clear()
3166
+
3167
+ def memory_stats(self) -> Mapping[str, int]:
3168
+ """Bounded delivery ownership; snapshots are shared across queues."""
3169
+ with self._lock:
3170
+ latest = self._last
3171
+ subscribers = len(self._queues)
3172
+ queued = sum(q.qsize() for q in self._queues)
3173
+ delivery = self._frame_cache.stats()
3174
+ return {
3175
+ **delivery,
3176
+ "subscriberCount": subscribers,
3177
+ "queuedDeliveryCount": queued,
3178
+ "maxQueuedPerSubscriber": self._maxsize,
3179
+ }
3180
+
2837
3181
  def publish(self, snapshot) -> None:
2838
3182
  import queue as _queue
3183
+ with self._lock:
3184
+ if self._closed:
3185
+ return
2839
3186
  # #583 S3 §5: wrap ONCE, outside the hub lock, so every queue and
2840
3187
  # `_last` share one projection cache for this tick. Built before the
2841
3188
  # lock because construction must not run under it.
@@ -2843,16 +3190,18 @@ class SSEHub:
2843
3190
  snapshot=snapshot,
2844
3191
  pinned_now_utc=dt.datetime.now(dt.timezone.utc),
2845
3192
  pinned_monotonic=time.monotonic(),
3193
+ frame_cache=self._frame_cache,
2846
3194
  )
2847
3195
  with self._lock:
3196
+ if self._closed:
3197
+ return
2848
3198
  self._last = delivery
2849
- # Latest-wins coalescing (#278 §2.6): every published snapshot is a
2850
- # COMPLETE state replacement, so a client only ever needs the
2851
- # newest. On a full queue drop the STALE queued frame and enqueue
2852
- # the newest, so a slow subscriber (e.g. one filled by A2's rapid
2853
- # partial republishes) still converges to the final hydrating=false
2854
- # frame instead of dropping it — and a lagging client jumps to the
2855
- # current state rather than replaying stale frames.
3199
+ # Latest-wins coalescing (#278 §2.6, tightened by #684): every
3200
+ # published snapshot is a COMPLETE state replacement, so a client
3201
+ # only ever needs the newest. Discard every queued delivery before
3202
+ # enqueuing the replacement. Doing this only after a four-slot queue
3203
+ # became full let each stalled client retain four full snapshots;
3204
+ # consumer-side draining fixed freshness but not retained memory.
2856
3205
  #
2857
3206
  # Held under the hub lock so concurrent producers (the sync tick +
2858
3207
  # the update-check thread) can't interleave a get/put on the same
@@ -2861,20 +3210,17 @@ class SSEHub:
2861
3210
  # consumer only ever get()s (never puts), so after we make room the
2862
3211
  # re-put cannot lose to it.
2863
3212
  for q in self._queues:
3213
+ while True:
3214
+ try:
3215
+ q.get_nowait()
3216
+ except _queue.Empty:
3217
+ break
2864
3218
  try:
2865
3219
  q.put_nowait(delivery)
2866
3220
  except _queue.Full:
2867
- try:
2868
- q.get_nowait() # discard the oldest, stale frame
2869
- except _queue.Empty:
2870
- pass
2871
- try:
2872
- q.put_nowait(delivery)
2873
- except _queue.Full:
2874
- # Defensive: a consumer racing between our get and put
2875
- # could only have removed items, so this is unreachable
2876
- # under the lock — but never raise out of publish().
2877
- pass
3221
+ # Defensive: only this publisher writes while holding the
3222
+ # hub lock; a consumer can remove but never add an item.
3223
+ pass
2878
3224
 
2879
3225
 
2880
3226
  def _format_url(host: str, port: int) -> str:
@@ -7053,6 +7399,119 @@ def _debug_tool_version() -> str:
7053
7399
  return "unknown"
7054
7400
 
7055
7401
 
7402
+ _DASHBOARD_PROCESS_MEMORY_CEILING_BYTES = 1536 * 1024 * 1024
7403
+
7404
+
7405
+ def _debug_memory_owner(stats, *, prefix: str = "") -> dict:
7406
+ """Normalize one owner's safe numeric counters for the debug endpoint."""
7407
+ source = dict(stats or {})
7408
+ def field(name, default=0):
7409
+ key = f"{prefix}{name}" if prefix else name[0].lower() + name[1:]
7410
+ return max(0, int(source.get(key, default) or 0))
7411
+ return {
7412
+ "estimatedBytes": field("EstimatedBytes"),
7413
+ "maxBytes": field("MaxBytes"),
7414
+ "entryCount": field("EntryCount"),
7415
+ "maxEntries": field("MaxEntries"),
7416
+ "evictionCount": field("EvictionCount"),
7417
+ "fallbackCount": field("FallbackCount"),
7418
+ }
7419
+
7420
+
7421
+ def _debug_retained_memory(hub, *, main_frontier_stats=None) -> dict:
7422
+ """Numeric retained-owner ceilings; never values, keys or source paths."""
7423
+ owners: dict[str, dict] = {}
7424
+ sources = sys.modules.get("_cctally_dashboard_sources")
7425
+ if sources is not None:
7426
+ source_stats = dict(
7427
+ sources.codex_source_accelerator_memory_stats())
7428
+ owners["codexSourceAccelerators"] = _debug_memory_owner(source_stats)
7429
+ owners["codexSourceAccelerators"].update({
7430
+ "measuredGeneration": int(
7431
+ source_stats.get("measuredGeneration", -1)),
7432
+ "currentGeneration": int(
7433
+ source_stats.get("currentGeneration", 0)),
7434
+ "measurementPending": int(
7435
+ source_stats.get("measurementPending", 1)),
7436
+ "measurementErrorCount": int(
7437
+ source_stats.get("measurementErrorCount", 0)),
7438
+ "workerAlive": int(source_stats.get("workerAlive", 0)),
7439
+ })
7440
+
7441
+ cctally = sys.modules.get("cctally")
7442
+ loader = getattr(cctally, "_load_sibling", None)
7443
+ if callable(loader):
7444
+ try:
7445
+ snapshot_stats = dict(
7446
+ loader("_lib_snapshot_cache").snapshot_accelerator_memory_stats())
7447
+ owners["snapshotAccelerators"] = _debug_memory_owner(snapshot_stats)
7448
+ owners["snapshotAccelerators"].update({
7449
+ "measuredGeneration": int(
7450
+ snapshot_stats.get("measuredGeneration", -1)),
7451
+ "currentGeneration": int(
7452
+ snapshot_stats.get("currentGeneration", 0)),
7453
+ "measurementPending": int(
7454
+ snapshot_stats.get("measurementPending", 1)),
7455
+ "measurementErrorCount": int(
7456
+ snapshot_stats.get("measurementErrorCount", 0)),
7457
+ "workerAlive": int(snapshot_stats.get("workerAlive", 0)),
7458
+ })
7459
+ except Exception:
7460
+ pass
7461
+ try:
7462
+ owners["claudeAssembly"] = _debug_memory_owner(
7463
+ loader("_lib_conversation_query").conversation_assembly_cache_stats())
7464
+ except Exception:
7465
+ pass
7466
+ try:
7467
+ codex_stats = loader(
7468
+ "_lib_codex_conversation_query").codex_conversation_cache_stats()
7469
+ owners["codexOutline"] = _debug_memory_owner(
7470
+ codex_stats, prefix="outline")
7471
+ owners["codexOutlineDerivation"] = _debug_memory_owner(
7472
+ codex_stats, prefix="derivation")
7473
+ except Exception:
7474
+ pass
7475
+ try:
7476
+ owners["outlineTransfers"] = _debug_memory_owner(
7477
+ loader("_cctally_dashboard_conversation").outline_transfer_cache_stats())
7478
+ except Exception:
7479
+ pass
7480
+
7481
+ sse_stats = hub.memory_stats() if hub is not None else {
7482
+ "estimatedBytes": 0, "maxBytes": _SSE_DELIVERY_MAX_BYTES,
7483
+ }
7484
+ owners["sseDelivery"] = _debug_memory_owner(sse_stats)
7485
+ owners["sseDelivery"].update({
7486
+ "subscriberCount": int(sse_stats.get("subscriberCount", 0) or 0),
7487
+ "queuedDeliveryCount": int(sse_stats.get("queuedDeliveryCount", 0) or 0),
7488
+ "maxQueuedPerSubscriber": int(
7489
+ sse_stats.get("maxQueuedPerSubscriber", 1) or 1),
7490
+ })
7491
+
7492
+ if callable(main_frontier_stats):
7493
+ try:
7494
+ stats = main_frontier_stats()
7495
+ if stats is not None:
7496
+ owners["mainIngestFrontier"] = _debug_memory_owner(stats)
7497
+ except Exception:
7498
+ pass
7499
+ conversation_frontier = getattr(_conversation_sync_pass, "_frontier", None)
7500
+ if conversation_frontier is not None:
7501
+ owners["conversationIngestFrontier"] = _debug_memory_owner(
7502
+ conversation_frontier.memory_stats())
7503
+
7504
+ return {
7505
+ "ownerEstimatedBytes": sum(
7506
+ row["estimatedBytes"] for row in owners.values()),
7507
+ "ownerCeilingBytes": sum(row["maxBytes"] for row in owners.values()),
7508
+ "processCeilingBytes": _DASHBOARD_PROCESS_MEMORY_CEILING_BYTES,
7509
+ "threadCount": threading.active_count(),
7510
+ "maxThreadCount": 64,
7511
+ "owners": owners,
7512
+ }
7513
+
7514
+
7056
7515
  # === Table-driven route dispatch (#279 S5 F5, spec §7) =====================
7057
7516
  # Ordered, first-match-wins tables — evaluated top-to-bottom so semantics are
7058
7517
  # if/elif-identical to the pre-S5 chains. Each entry is
@@ -7099,6 +7558,9 @@ _GET_ROUTES = (
7099
7558
  ("scope", "endpoint.conversations"), False),
7100
7559
  ("exact", "/api/conversation/search", "_handle_get_conversation_search",
7101
7560
  ("scope", "endpoint.conversation_search"), False),
7561
+ ("prefix", "/api/conversation/outline-transfer/",
7562
+ "_handle_get_conversation_outline_transfer",
7563
+ ("scope", "endpoint.conversation_outline_transfer"), True),
7102
7564
  ("prefix+suffix", ("/api/conversation/", "/payload"),
7103
7565
  "_handle_get_conversation_payload",
7104
7566
  ("phase", "endpoint.conversation_payload"), True),
@@ -7145,6 +7607,8 @@ _POST_ROUTES = (
7145
7607
  )
7146
7608
 
7147
7609
  _DELETE_ROUTES = (
7610
+ ("prefix", "/api/conversation/outline-transfer/",
7611
+ "_handle_delete_conversation_outline_transfer", None, True),
7148
7612
  ("prefix", "/api/share/presets/", "_handle_share_presets_delete", None, False),
7149
7613
  ("exact", "/api/share/history", "_handle_share_history_delete", None, False),
7150
7614
  )
@@ -7735,6 +8199,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
7735
8199
  if not self._require_debug_backend_allowed():
7736
8200
  return
7737
8201
  last = self._perf_gate().last_backend_perf()
8202
+ last_ingest = self._perf_gate().last_ingest_perf()
7738
8203
  dataset: dict = {}
7739
8204
  cache_state: dict = {}
7740
8205
  try:
@@ -7758,12 +8223,19 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
7758
8223
  perf = self._perf_gate()
7759
8224
  tick_state = _lib_tick_stats.snapshot()
7760
8225
  requested, applied = perf.pending_state()
8226
+ memory = _debug_retained_memory(
8227
+ type(self).hub,
8228
+ main_frontier_stats=getattr(
8229
+ type(self), "ingest_frontier_stats", None),
8230
+ )
7761
8231
  body = {
7762
8232
  "schemaVersion": 1,
7763
8233
  "version": _debug_tool_version(),
7764
8234
  "generated_at": (last or {}).get("generated_at"),
7765
8235
  "dataset": dataset,
7766
8236
  "phases": (last or {}).get("phases"),
8237
+ "ingest_phases": (last_ingest or {}).get("phases"),
8238
+ "ingest_generated_at": (last_ingest or {}).get("generated_at"),
7767
8239
  # #583 S1 §3.1 asked for the stored tree's instant beside
7768
8240
  # `phases`, so a GET after `--trace off` cannot present an old tree
7769
8241
  # as current — disabling tracing does not clear the stored tree.
@@ -7795,6 +8267,8 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
7795
8267
  },
7796
8268
  "cache_state": cache_state,
7797
8269
  "sources": sources,
8270
+ "memory": memory,
8271
+ "activity": self.snapshot_ref.activity(),
7798
8272
  # Additive, and named rather than folded into `cache_state`: a
7799
8273
  # stats fault is not cache state, and #496 S3 §8 exists because a
7800
8274
  # stats failure reported as a cache one sends the user to
@@ -8876,6 +9350,38 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
8876
9350
  ".json": "application/json; charset=utf-8",
8877
9351
  }.get(p.suffix.lower(), "application/octet-stream")
8878
9352
 
9353
+ @staticmethod
9354
+ def _is_hashed_static_asset(path: pathlib.Path) -> bool:
9355
+ """Whether Vite made ``path`` content-addressed and immutable.
9356
+
9357
+ Only files beneath the build's ``assets`` directory qualify. Root
9358
+ resources such as ``dashboard.html``, ``icons.svg`` and the favicon
9359
+ keep their mutable-name revalidation contract even if a future name
9360
+ happens to contain a dash.
9361
+ """
9362
+ return (
9363
+ path.parent.name == "assets"
9364
+ and re.fullmatch(
9365
+ r".+-[A-Za-z0-9_-]{8,}\.[A-Za-z0-9]+", path.name
9366
+ )
9367
+ is not None
9368
+ )
9369
+
9370
+ @staticmethod
9371
+ def _etag_matches(if_none_match: str | None, etag: str) -> bool:
9372
+ """Apply GET/HEAD weak comparison to an ``If-None-Match`` list."""
9373
+ if not if_none_match:
9374
+ return False
9375
+ for candidate in if_none_match.split(","):
9376
+ candidate = candidate.strip()
9377
+ if candidate == "*":
9378
+ return True
9379
+ if candidate.startswith("W/"):
9380
+ candidate = candidate[2:].lstrip()
9381
+ if candidate == etag:
9382
+ return True
9383
+ return False
9384
+
8879
9385
  def _serve_static_file(self, path: pathlib.Path, ctype: str) -> None:
8880
9386
  try:
8881
9387
  body = path.read_bytes()
@@ -8885,12 +9391,45 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
8885
9391
  except IsADirectoryError:
8886
9392
  self.send_error(404, "not found")
8887
9393
  return
9394
+
9395
+ compressible = (
9396
+ ctype.startswith("text/")
9397
+ or ctype.startswith("application/javascript")
9398
+ or ctype.startswith("image/svg+xml")
9399
+ )
9400
+ gzip_on = compressible and _accepts_gzip(
9401
+ self.headers.get("Accept-Encoding")
9402
+ )
9403
+ encoded = gzip.compress(body, compresslevel=6, mtime=0) if gzip_on else body
9404
+ etag = f'"{hashlib.sha256(encoded).hexdigest()}"'
9405
+ cache_control = (
9406
+ "public, max-age=31536000, immutable"
9407
+ if self._is_hashed_static_asset(path)
9408
+ else "no-cache"
9409
+ )
9410
+
9411
+ if self._etag_matches(self.headers.get("If-None-Match"), etag):
9412
+ self.send_response(304)
9413
+ self.send_header("ETag", etag)
9414
+ self.send_header("Cache-Control", cache_control)
9415
+ if compressible:
9416
+ self.send_header("Vary", "Accept-Encoding")
9417
+ if gzip_on:
9418
+ self.send_header("Content-Encoding", "gzip")
9419
+ self.end_headers()
9420
+ return
9421
+
8888
9422
  self.send_response(200)
8889
9423
  self.send_header("Content-Type", ctype)
8890
- self.send_header("Content-Length", str(len(body)))
8891
- self.send_header("Cache-Control", "no-cache")
9424
+ self.send_header("Content-Length", str(len(encoded)))
9425
+ self.send_header("Cache-Control", cache_control)
9426
+ self.send_header("ETag", etag)
9427
+ if compressible:
9428
+ self.send_header("Vary", "Accept-Encoding")
9429
+ if gzip_on:
9430
+ self.send_header("Content-Encoding", "gzip")
8892
9431
  self.end_headers()
8893
- self.wfile.write(body)
9432
+ self.wfile.write(encoded)
8894
9433
 
8895
9434
  def _serve_api_data(self) -> None:
8896
9435
  # #583 S3 §6/§7. TWO phases. Preparation may answer a JSON 500 because
@@ -9229,9 +9768,38 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
9229
9768
  # describes what actually ran rather than what was intended.
9230
9769
  # The predicate itself is untouched (D-E) — this composes it,
9231
9770
  # it does not change it.
9232
- report = sources.build_diagnosis(
9233
- scope, measured_at=now_utc,
9234
- transcripts_visible=self._transcripts_visible_to_request(),
9771
+ transcripts_visible = self._transcripts_visible_to_request()
9772
+ flight_key = _diagnosis_flight_key(
9773
+ scope, transcripts_visible, reveal,
9774
+ )
9775
+
9776
+ def _prepare_diagnosis():
9777
+ report = sources.build_diagnosis(
9778
+ scope,
9779
+ measured_at=now_utc,
9780
+ transcripts_visible=transcripts_visible,
9781
+ )
9782
+ scopes = {
9783
+ result.source: diagnosis._scope_for(
9784
+ sources, scope, result.source,
9785
+ )
9786
+ for result in report.results
9787
+ }
9788
+ body = diagnosis.diagnosis_to_wire(
9789
+ report,
9790
+ scopes=scopes,
9791
+ reveal_projects=reveal,
9792
+ )
9793
+ status = (
9794
+ 503
9795
+ if kernel.unreadable_store_is_terminal(report)
9796
+ else 200
9797
+ )
9798
+ return status, body
9799
+
9800
+ status, body = _DIAGNOSIS_ADMISSION.run(
9801
+ flight_key,
9802
+ _prepare_diagnosis,
9235
9803
  )
9236
9804
  except _DiagnosisSelectorError as exc:
9237
9805
  self._send_diagnosis_json(
@@ -9262,12 +9830,6 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
9262
9830
  status, {"error": exc.message, "code": exc.code})
9263
9831
  return
9264
9832
 
9265
- scopes = {result.source: diagnosis._scope_for(sources, scope,
9266
- result.source)
9267
- for result in report.results}
9268
- body = diagnosis.diagnosis_to_wire(report, scopes=scopes,
9269
- reveal_projects=reveal)
9270
- status = 503 if kernel.unreadable_store_is_terminal(report) else 200
9271
9833
  except Exception as exc: # noqa: BLE001
9272
9834
  self.log_error("/api/diagnosis failed before commit: %r", exc)
9273
9835
  self._send_diagnosis_json(500, {"error": "internal error"})
@@ -9480,6 +10042,12 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
9480
10042
  def _handle_get_conversation_outline(self, path: str) -> None:
9481
10043
  return _handle_get_conversation_outline_impl(self, path)
9482
10044
 
10045
+ def _handle_get_conversation_outline_transfer(self, path: str) -> None:
10046
+ return _handle_get_conversation_outline_transfer_impl(self, path)
10047
+
10048
+ def _handle_delete_conversation_outline_transfer(self, path: str) -> None:
10049
+ return _handle_delete_conversation_outline_transfer_impl(self, path)
10050
+
9483
10051
  def _handle_get_conversation_prompts(self, path: str) -> None:
9484
10052
  return _handle_get_conversation_prompts_impl(self, path)
9485
10053
 
@@ -10405,11 +10973,18 @@ def _dashboard_initial_snapshot_once(
10405
10973
  # Route _tui_build_snapshot through the cctally module (its re-export)
10406
10974
  # so ``monkeypatch.setitem(ns, "_tui_build_snapshot", spy)`` in tests
10407
10975
  # propagates — identical to the pre-change call form.
10408
- return c._tui_build_snapshot(
10976
+ snapshot = c._tui_build_snapshot(
10409
10977
  now_utc=pinned_now, skip_sync=True,
10410
10978
  display_tz_pref_override=display_tz_pref_override,
10411
10979
  precompute_envelope=True, runtime_bind=getattr(args, "host", None),
10412
10980
  )
10981
+ # Frozen mode has no background publisher, so this initial full build
10982
+ # is also its only retained snapshot. Submit it explicitly to the same
10983
+ # asynchronous owner verifier used by ordinary publisher ticks.
10984
+ c._load_sibling(
10985
+ "_lib_snapshot_cache").enforce_snapshot_accelerator_bounds(
10986
+ data_version="dashboard-no-sync-initial")
10987
+ return snapshot
10413
10988
 
10414
10989
  import time as _time
10415
10990
  now_utc = pinned_now or dt.datetime.now(dt.timezone.utc)
@@ -10702,10 +11277,20 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
10702
11277
  print(f"dashboard: pruned {_heal.pruned_files} orphaned cache file(s) "
10703
11278
  f"from removed sessions on startup", flush=True)
10704
11279
 
10705
- initial = _dashboard_initial_snapshot(
10706
- args, pinned_now=pinned_now,
10707
- display_tz_pref_override=display_tz_pref_override,
10708
- )
11280
+ # #709: background retained-size verifiers own fail-closed completion.
11281
+ # Bind both owners to the same publisher lock before the initial build so
11282
+ # --no-sync cannot strand an over-cap/error result waiting for a next tick.
11283
+ sync_lock = threading.Lock()
11284
+ source_memory_module = _cctally()._load_sibling(
11285
+ "_cctally_dashboard_sources")
11286
+ snapshot_memory_module = _cctally()._load_sibling("_lib_snapshot_cache")
11287
+ source_memory_module.set_codex_source_memory_completion_lock(sync_lock)
11288
+ snapshot_memory_module.set_snapshot_memory_completion_lock(sync_lock)
11289
+ with sync_lock:
11290
+ initial = _dashboard_initial_snapshot(
11291
+ args, pinned_now=pinned_now,
11292
+ display_tz_pref_override=display_tz_pref_override,
11293
+ )
10709
11294
  if args.no_sync:
10710
11295
  # No background refresher will run, so surfacing a ticking
10711
11296
  # "synced Ns ago" chip would be misleading. Clear the monotonic
@@ -10733,8 +11318,6 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
10733
11318
  # where nothing would drain that queue, so a manual request there waits on
10734
11319
  # a blocking acquire instead. The lock inside _run_sync_now is what
10735
11320
  # actually prevents overlap.
10736
- sync_lock = threading.Lock()
10737
-
10738
11321
  # Build the two variants up front. The locked variant is exposed on the
10739
11322
  # handler so /api/sync paths that already hold sync_lock (e.g. for
10740
11323
  # multi-step refresh-then-rebuild) can reuse the snapshot-publish body
@@ -10778,6 +11361,18 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
10778
11361
  DashboardHTTPHandler.run_sync_now_locked = staticmethod(
10779
11362
  lambda: _run_sync_now_locked(skip_sync=args.no_sync)
10780
11363
  )
11364
+ def _main_frontier_stats():
11365
+ periodic_owner = getattr(_run_sync_now, "_locked_owner", None)
11366
+ frontier = getattr(periodic_owner, "_ingest_frontier", None)
11367
+ if frontier is None:
11368
+ frontier = getattr(_run_sync_now_locked, "_ingest_frontier", None)
11369
+ return None if frontier is None else frontier.memory_stats()
11370
+
11371
+ # Static callback preserves the exact closure that owns the frontier. A
11372
+ # plain function assigned to the handler class is a descriptor and may be
11373
+ # rebound when the debug route reads it from a request instance.
11374
+ DashboardHTTPHandler.ingest_frontier_stats = staticmethod(
11375
+ _main_frontier_stats)
10781
11376
 
10782
11377
  # Background rebuilder — reuses the TUI's proven sync thread with a
10783
11378
  # small shim that delegates the sync body to _run_sync_now (so POST
@@ -10940,7 +11535,25 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
10940
11535
  if conversation_sync_thread is not None:
10941
11536
  conversation_sync_thread.join(timeout=2)
10942
11537
  update_check_stop.set()
11538
+ memory_shutdown_errors = []
11539
+ try:
11540
+ shutdown_codex_source_memory_worker()
11541
+ except Exception as exc: # noqa: BLE001 - finish server cleanup first
11542
+ memory_shutdown_errors.append(exc)
11543
+ try:
11544
+ _cctally()._load_sibling(
11545
+ "_lib_snapshot_cache").shutdown_snapshot_memory_worker()
11546
+ except Exception as exc: # noqa: BLE001 - finish server cleanup first
11547
+ memory_shutdown_errors.append(exc)
11548
+ source_memory_module.set_codex_source_memory_completion_lock(None)
11549
+ snapshot_memory_module.set_snapshot_memory_completion_lock(None)
10943
11550
  srv.shutdown()
10944
11551
  http_thread.join(timeout=2)
11552
+ hub.close()
11553
+ if memory_shutdown_errors:
11554
+ raise RuntimeError(
11555
+ "dashboard memory verifier shutdown failed: "
11556
+ + "; ".join(str(exc) for exc in memory_shutdown_errors)
11557
+ )
10945
11558
  print("dashboard: stopped", flush=True)
10946
11559
  return 0