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
@@ -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"]
@@ -581,6 +662,7 @@ from _cctally_dashboard_envelope import (
581
662
  _envelope_rows_project_budget,
582
663
  _ENVELOPE_AXIS_MAPPERS,
583
664
  _build_alerts_envelope_array,
665
+ _build_meter_rate_change_array,
584
666
  _model_breakdowns_to_models,
585
667
  )
586
668
 
@@ -588,6 +670,7 @@ _ensure_sibling_loaded("_cctally_dashboard_sources")
588
670
  from _cctally_dashboard_sources import (
589
671
  SourceCapabilityUnavailable,
590
672
  SourceResourceNotFound,
673
+ shutdown_codex_source_memory_worker,
591
674
  source_detail_lookup,
592
675
  )
593
676
  from _lib_dashboard_sources import dashboard_resource_key
@@ -1462,7 +1545,7 @@ from _cctally_dashboard_conversation import (
1462
1545
  _CONV_FIND_KINDS,
1463
1546
  _BadConversationFilter,
1464
1547
  _cached_file_sigs,
1465
- # query plumbing + eleven handler impls (the class delegators call these)
1548
+ # query plumbing + handler impls (the class delegators call these)
1466
1549
  _conversation_query_impl,
1467
1550
  _parse_search_kind_impl,
1468
1551
  _run_conversation_query_impl,
@@ -1474,6 +1557,8 @@ from _cctally_dashboard_conversation import (
1474
1557
  _handle_get_conversation_search_impl,
1475
1558
  _handle_get_conversation_payload_impl,
1476
1559
  _handle_get_conversation_outline_impl,
1560
+ _handle_get_conversation_outline_transfer_impl,
1561
+ _handle_delete_conversation_outline_transfer_impl,
1477
1562
  _handle_get_conversation_prompts_impl,
1478
1563
  _handle_get_conversation_export_impl,
1479
1564
  _handle_get_conversation_anon_map_impl,
@@ -1795,6 +1880,111 @@ def _dashboard_maybe_prune_retention() -> None:
1795
1880
  pass
1796
1881
 
1797
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
+
1798
1988
  def _conversation_sync_pass() -> str:
1799
1989
  """One WHOLE transcript-ingest pass (#583 S4 / F5).
1800
1990
 
@@ -1829,11 +2019,65 @@ def _conversation_sync_pass() -> str:
1829
2019
  conn = open_conversations_db()
1830
2020
  except (OSError, sqlite3.DatabaseError) as exc:
1831
2021
  eprint(f"[conversations] background sync unavailable: {exc}")
1832
- return "store_unavailable"
2022
+ return _ConversationPassStatus("store_unavailable")
1833
2023
  status = "ok"
2024
+ modes = {"claude": "not_observed", "codex": "not_observed"}
2025
+ files = {"claude": 0, "codex": 0}
1834
2026
  try:
1835
- sync_claude_conversations(conn)
1836
- 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)
1837
2081
  except (OSError, sqlite3.DatabaseError) as exc:
1838
2082
  eprint(f"[conversations] background sync unavailable: {exc}")
1839
2083
  status = "store_unavailable"
@@ -1853,7 +2097,11 @@ def _conversation_sync_pass() -> str:
1853
2097
  except Exception: # noqa: BLE001
1854
2098
  pass
1855
2099
  _dashboard_maybe_prune_retention()
1856
- 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)
1857
2105
 
1858
2106
 
1859
2107
  def _conversation_sync_loop(
@@ -1906,6 +2154,12 @@ def _conversation_sync_loop(
1906
2154
  # interval that preceded it would shift the denominator by one
1907
2155
  # pass and publish a share with no upper bound.
1908
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),
1909
2163
  )
1910
2164
  deadline = _conversation_next_deadline(t0, interval, work)
1911
2165
  remaining = deadline - monotonic()
@@ -2583,6 +2837,71 @@ class _SnapshotRef:
2583
2837
  self._restamp_locked()
2584
2838
 
2585
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
+
2586
2905
  class _SSEDelivery:
2587
2906
  """One publication, projected and encoded at most once per variant.
2588
2907
 
@@ -2611,14 +2930,19 @@ class _SSEDelivery:
2611
2930
  """
2612
2931
 
2613
2932
  __slots__ = ("snapshot", "pinned_now_utc", "pinned_monotonic",
2614
- "_cache", "_lock")
2933
+ "_frame_cache", "_cache_token")
2615
2934
 
2616
- 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:
2617
2938
  self.snapshot = snapshot
2618
2939
  self.pinned_now_utc = pinned_now_utc
2619
2940
  self.pinned_monotonic = pinned_monotonic
2620
- self._cache: dict = {}
2621
- 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()
2622
2946
 
2623
2947
  def encoded(self, variant_key, project_fn) -> bytes:
2624
2948
  """Return complete SSE frame bytes for ``variant_key``, building once.
@@ -2632,16 +2956,8 @@ class _SSEDelivery:
2632
2956
  before the key is built. Those are different situations and conflating
2633
2957
  them either leaks or breaks the gate.
2634
2958
  """
2635
- hit = self._cache.get(variant_key)
2636
- if hit is not None:
2637
- return hit
2638
- with self._lock:
2639
- hit = self._cache.get(variant_key)
2640
- if hit is not None:
2641
- return hit
2642
- built = project_fn(variant_key)
2643
- self._cache[variant_key] = built
2644
- return built
2959
+ return self._frame_cache.encoded(
2960
+ (self._cache_token, variant_key), project_fn)
2645
2961
 
2646
2962
 
2647
2963
  # #583 S3 §5. A distinct slot for "no oauth_usage configuration at all", so it
@@ -2744,17 +3060,11 @@ def _delivery_is_shareable(snapshot) -> bool:
2744
3060
  def _drain_to_newest(q, first):
2745
3061
  """Return the newest delivery queued on ``q``, discarding older ones.
2746
3062
 
2747
- #583 S3 §5. ``SSEHub`` uses a four-slot queue and ``publish`` discards only
2748
- ONE oldest entry when full, so a client that falls behind holds a backlog
2749
- of up to four deliveries. Each delivery pins its clock at publication, so
2750
- replaying that backlog would render ages several publish periods stale — a
2751
- regression against the present behaviour, where each frame is projected at
2752
- consumption time and its ages are therefore current.
2753
-
2754
- The fix is on the CONSUMER side deliberately: ``SSEHub.publish`` is
2755
- governed by Preserve 4 and the A2 publication tests depend on its
2756
- behaviour, so it is not modified. Draining here is the latest-wins
2757
- 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.
2758
3068
 
2759
3069
  ``first`` is the item the caller already took off the queue with its own
2760
3070
  blocking ``get``, so the ``queue.Empty`` keep-alive path stays where it is.
@@ -2779,16 +3089,19 @@ class SSEHub:
2779
3089
  #583 S3 §5: what the queues carry is a `_SSEDelivery` wrapping the
2780
3090
  published snapshot, not the snapshot itself, so one tick projects and
2781
3091
  encodes once per variant instead of once per connected client. The
2782
- queueing behaviour below — size, latest-wins discard, lock discipline — is
2783
- 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.
2784
3095
  """
2785
3096
 
2786
- def __init__(self, maxsize: int = 4) -> None:
3097
+ def __init__(self, maxsize: int = 1) -> None:
2787
3098
  import threading
2788
3099
  import queue as _queue
2789
3100
  self._lock = threading.Lock()
2790
3101
  self._queues: list[_queue.Queue] = []
2791
3102
  self._maxsize = maxsize
3103
+ self._closed = False
3104
+ self._frame_cache = _SSEFrameCache()
2792
3105
  # Held so we can send the current state to a newly-subscribed
2793
3106
  # client without waiting for the next sync tick.
2794
3107
  self._last: object | None = None
@@ -2797,6 +3110,8 @@ class SSEHub:
2797
3110
  import queue as _queue
2798
3111
  q = _queue.Queue(maxsize=self._maxsize)
2799
3112
  with self._lock:
3113
+ if self._closed:
3114
+ return q
2800
3115
  self._queues.append(q)
2801
3116
  if self._last is not None:
2802
3117
  # Seed the new subscriber so it renders immediately.
@@ -2808,6 +3123,7 @@ class SSEHub:
2808
3123
  snapshot=self._last.snapshot,
2809
3124
  pinned_now_utc=dt.datetime.now(dt.timezone.utc),
2810
3125
  pinned_monotonic=time.monotonic(),
3126
+ frame_cache=self._frame_cache,
2811
3127
  )
2812
3128
  try:
2813
3129
  q.put_nowait(seed)
@@ -2833,8 +3149,40 @@ class SSEHub:
2833
3149
  except ValueError:
2834
3150
  pass
2835
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
+
2836
3181
  def publish(self, snapshot) -> None:
2837
3182
  import queue as _queue
3183
+ with self._lock:
3184
+ if self._closed:
3185
+ return
2838
3186
  # #583 S3 §5: wrap ONCE, outside the hub lock, so every queue and
2839
3187
  # `_last` share one projection cache for this tick. Built before the
2840
3188
  # lock because construction must not run under it.
@@ -2842,16 +3190,18 @@ class SSEHub:
2842
3190
  snapshot=snapshot,
2843
3191
  pinned_now_utc=dt.datetime.now(dt.timezone.utc),
2844
3192
  pinned_monotonic=time.monotonic(),
3193
+ frame_cache=self._frame_cache,
2845
3194
  )
2846
3195
  with self._lock:
3196
+ if self._closed:
3197
+ return
2847
3198
  self._last = delivery
2848
- # Latest-wins coalescing (#278 §2.6): every published snapshot is a
2849
- # COMPLETE state replacement, so a client only ever needs the
2850
- # newest. On a full queue drop the STALE queued frame and enqueue
2851
- # the newest, so a slow subscriber (e.g. one filled by A2's rapid
2852
- # partial republishes) still converges to the final hydrating=false
2853
- # frame instead of dropping it — and a lagging client jumps to the
2854
- # 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.
2855
3205
  #
2856
3206
  # Held under the hub lock so concurrent producers (the sync tick +
2857
3207
  # the update-check thread) can't interleave a get/put on the same
@@ -2860,20 +3210,17 @@ class SSEHub:
2860
3210
  # consumer only ever get()s (never puts), so after we make room the
2861
3211
  # re-put cannot lose to it.
2862
3212
  for q in self._queues:
3213
+ while True:
3214
+ try:
3215
+ q.get_nowait()
3216
+ except _queue.Empty:
3217
+ break
2863
3218
  try:
2864
3219
  q.put_nowait(delivery)
2865
3220
  except _queue.Full:
2866
- try:
2867
- q.get_nowait() # discard the oldest, stale frame
2868
- except _queue.Empty:
2869
- pass
2870
- try:
2871
- q.put_nowait(delivery)
2872
- except _queue.Full:
2873
- # Defensive: a consumer racing between our get and put
2874
- # could only have removed items, so this is unreachable
2875
- # under the lock — but never raise out of publish().
2876
- pass
3221
+ # Defensive: only this publisher writes while holding the
3222
+ # hub lock; a consumer can remove but never add an item.
3223
+ pass
2877
3224
 
2878
3225
 
2879
3226
  def _format_url(host: str, port: int) -> str:
@@ -7052,6 +7399,119 @@ def _debug_tool_version() -> str:
7052
7399
  return "unknown"
7053
7400
 
7054
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
+
7055
7515
  # === Table-driven route dispatch (#279 S5 F5, spec §7) =====================
7056
7516
  # Ordered, first-match-wins tables — evaluated top-to-bottom so semantics are
7057
7517
  # if/elif-identical to the pre-S5 chains. Each entry is
@@ -7098,6 +7558,9 @@ _GET_ROUTES = (
7098
7558
  ("scope", "endpoint.conversations"), False),
7099
7559
  ("exact", "/api/conversation/search", "_handle_get_conversation_search",
7100
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),
7101
7564
  ("prefix+suffix", ("/api/conversation/", "/payload"),
7102
7565
  "_handle_get_conversation_payload",
7103
7566
  ("phase", "endpoint.conversation_payload"), True),
@@ -7144,6 +7607,8 @@ _POST_ROUTES = (
7144
7607
  )
7145
7608
 
7146
7609
  _DELETE_ROUTES = (
7610
+ ("prefix", "/api/conversation/outline-transfer/",
7611
+ "_handle_delete_conversation_outline_transfer", None, True),
7147
7612
  ("prefix", "/api/share/presets/", "_handle_share_presets_delete", None, False),
7148
7613
  ("exact", "/api/share/history", "_handle_share_history_delete", None, False),
7149
7614
  )
@@ -7734,6 +8199,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
7734
8199
  if not self._require_debug_backend_allowed():
7735
8200
  return
7736
8201
  last = self._perf_gate().last_backend_perf()
8202
+ last_ingest = self._perf_gate().last_ingest_perf()
7737
8203
  dataset: dict = {}
7738
8204
  cache_state: dict = {}
7739
8205
  try:
@@ -7757,12 +8223,19 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
7757
8223
  perf = self._perf_gate()
7758
8224
  tick_state = _lib_tick_stats.snapshot()
7759
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
+ )
7760
8231
  body = {
7761
8232
  "schemaVersion": 1,
7762
8233
  "version": _debug_tool_version(),
7763
8234
  "generated_at": (last or {}).get("generated_at"),
7764
8235
  "dataset": dataset,
7765
8236
  "phases": (last or {}).get("phases"),
8237
+ "ingest_phases": (last_ingest or {}).get("phases"),
8238
+ "ingest_generated_at": (last_ingest or {}).get("generated_at"),
7766
8239
  # #583 S1 §3.1 asked for the stored tree's instant beside
7767
8240
  # `phases`, so a GET after `--trace off` cannot present an old tree
7768
8241
  # as current — disabling tracing does not clear the stored tree.
@@ -7794,6 +8267,8 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
7794
8267
  },
7795
8268
  "cache_state": cache_state,
7796
8269
  "sources": sources,
8270
+ "memory": memory,
8271
+ "activity": self.snapshot_ref.activity(),
7797
8272
  # Additive, and named rather than folded into `cache_state`: a
7798
8273
  # stats fault is not cache state, and #496 S3 §8 exists because a
7799
8274
  # stats failure reported as a cache one sends the user to
@@ -8282,6 +8757,16 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
8282
8757
  merged_alerts["projected_enabled"] = (
8283
8758
  alerts_in["projected_enabled"]
8284
8759
  )
8760
+ # #661 S2 section 6.2's PUSH gate. Assigned VERBATIM, like
8761
+ # every sibling here: the boolean rule lives in
8762
+ # `_get_alerts_config`, which runs against the merged block
8763
+ # below, so a `bool()` here would coerce "yes" to True and
8764
+ # destroy the evidence before the canonical validator could
8765
+ # refuse it.
8766
+ if "rate_change_enabled" in alerts_in:
8767
+ merged_alerts["rate_change_enabled"] = (
8768
+ alerts_in["rate_change_enabled"]
8769
+ )
8285
8770
  if "notifier" in alerts_in:
8286
8771
  merged_alerts["notifier"] = alerts_in["notifier"]
8287
8772
  merged["alerts"] = merged_alerts
@@ -8865,6 +9350,38 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
8865
9350
  ".json": "application/json; charset=utf-8",
8866
9351
  }.get(p.suffix.lower(), "application/octet-stream")
8867
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
+
8868
9385
  def _serve_static_file(self, path: pathlib.Path, ctype: str) -> None:
8869
9386
  try:
8870
9387
  body = path.read_bytes()
@@ -8874,12 +9391,45 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
8874
9391
  except IsADirectoryError:
8875
9392
  self.send_error(404, "not found")
8876
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
+
8877
9422
  self.send_response(200)
8878
9423
  self.send_header("Content-Type", ctype)
8879
- self.send_header("Content-Length", str(len(body)))
8880
- 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")
8881
9431
  self.end_headers()
8882
- self.wfile.write(body)
9432
+ self.wfile.write(encoded)
8883
9433
 
8884
9434
  def _serve_api_data(self) -> None:
8885
9435
  # #583 S3 §6/§7. TWO phases. Preparation may answer a JSON 500 because
@@ -9218,9 +9768,38 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
9218
9768
  # describes what actually ran rather than what was intended.
9219
9769
  # The predicate itself is untouched (D-E) — this composes it,
9220
9770
  # it does not change it.
9221
- report = sources.build_diagnosis(
9222
- scope, measured_at=now_utc,
9223
- 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,
9224
9803
  )
9225
9804
  except _DiagnosisSelectorError as exc:
9226
9805
  self._send_diagnosis_json(
@@ -9251,12 +9830,6 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
9251
9830
  status, {"error": exc.message, "code": exc.code})
9252
9831
  return
9253
9832
 
9254
- scopes = {result.source: diagnosis._scope_for(sources, scope,
9255
- result.source)
9256
- for result in report.results}
9257
- body = diagnosis.diagnosis_to_wire(report, scopes=scopes,
9258
- reveal_projects=reveal)
9259
- status = 503 if kernel.unreadable_store_is_terminal(report) else 200
9260
9833
  except Exception as exc: # noqa: BLE001
9261
9834
  self.log_error("/api/diagnosis failed before commit: %r", exc)
9262
9835
  self._send_diagnosis_json(500, {"error": "internal error"})
@@ -9469,6 +10042,12 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
9469
10042
  def _handle_get_conversation_outline(self, path: str) -> None:
9470
10043
  return _handle_get_conversation_outline_impl(self, path)
9471
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
+
9472
10051
  def _handle_get_conversation_prompts(self, path: str) -> None:
9473
10052
  return _handle_get_conversation_prompts_impl(self, path)
9474
10053
 
@@ -10394,11 +10973,18 @@ def _dashboard_initial_snapshot_once(
10394
10973
  # Route _tui_build_snapshot through the cctally module (its re-export)
10395
10974
  # so ``monkeypatch.setitem(ns, "_tui_build_snapshot", spy)`` in tests
10396
10975
  # propagates — identical to the pre-change call form.
10397
- return c._tui_build_snapshot(
10976
+ snapshot = c._tui_build_snapshot(
10398
10977
  now_utc=pinned_now, skip_sync=True,
10399
10978
  display_tz_pref_override=display_tz_pref_override,
10400
10979
  precompute_envelope=True, runtime_bind=getattr(args, "host", None),
10401
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
10402
10988
 
10403
10989
  import time as _time
10404
10990
  now_utc = pinned_now or dt.datetime.now(dt.timezone.utc)
@@ -10691,10 +11277,20 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
10691
11277
  print(f"dashboard: pruned {_heal.pruned_files} orphaned cache file(s) "
10692
11278
  f"from removed sessions on startup", flush=True)
10693
11279
 
10694
- initial = _dashboard_initial_snapshot(
10695
- args, pinned_now=pinned_now,
10696
- display_tz_pref_override=display_tz_pref_override,
10697
- )
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
+ )
10698
11294
  if args.no_sync:
10699
11295
  # No background refresher will run, so surfacing a ticking
10700
11296
  # "synced Ns ago" chip would be misleading. Clear the monotonic
@@ -10722,8 +11318,6 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
10722
11318
  # where nothing would drain that queue, so a manual request there waits on
10723
11319
  # a blocking acquire instead. The lock inside _run_sync_now is what
10724
11320
  # actually prevents overlap.
10725
- sync_lock = threading.Lock()
10726
-
10727
11321
  # Build the two variants up front. The locked variant is exposed on the
10728
11322
  # handler so /api/sync paths that already hold sync_lock (e.g. for
10729
11323
  # multi-step refresh-then-rebuild) can reuse the snapshot-publish body
@@ -10767,6 +11361,18 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
10767
11361
  DashboardHTTPHandler.run_sync_now_locked = staticmethod(
10768
11362
  lambda: _run_sync_now_locked(skip_sync=args.no_sync)
10769
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)
10770
11376
 
10771
11377
  # Background rebuilder — reuses the TUI's proven sync thread with a
10772
11378
  # small shim that delegates the sync body to _run_sync_now (so POST
@@ -10929,7 +11535,25 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
10929
11535
  if conversation_sync_thread is not None:
10930
11536
  conversation_sync_thread.join(timeout=2)
10931
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)
10932
11550
  srv.shutdown()
10933
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
+ )
10934
11558
  print("dashboard: stopped", flush=True)
10935
11559
  return 0