cctally 1.116.1 → 1.117.2

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 (76) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +4 -6
  3. package/bin/_cctally_account.py +57 -3
  4. package/bin/_cctally_cache.py +5720 -885
  5. package/bin/_cctally_claude_attribution.py +997 -0
  6. package/bin/_cctally_completion.py +101 -28
  7. package/bin/_cctally_core.py +235 -12
  8. package/bin/_cctally_dashboard.py +263 -17
  9. package/bin/_cctally_dashboard_conversation.py +107 -21
  10. package/bin/_cctally_dashboard_envelope.py +44 -0
  11. package/bin/_cctally_db.py +928 -80
  12. package/bin/_cctally_diagnosis_sources.py +78 -13
  13. package/bin/_cctally_doctor.py +211 -86
  14. package/bin/_cctally_five_hour.py +446 -22
  15. package/bin/_cctally_forecast.py +302 -6
  16. package/bin/_cctally_journal.py +2418 -158
  17. package/bin/_cctally_milestone_history.py +379 -91
  18. package/bin/_cctally_oauth_samples.py +185 -7
  19. package/bin/_cctally_parser.py +38 -0
  20. package/bin/_cctally_project.py +34 -8
  21. package/bin/_cctally_quarantine.py +821 -343
  22. package/bin/_cctally_quota.py +69 -4
  23. package/bin/_cctally_quota_calibration.py +677 -3
  24. package/bin/_cctally_quota_model.py +927 -127
  25. package/bin/_cctally_record.py +922 -445
  26. package/bin/_cctally_rederive.py +119 -15
  27. package/bin/_cctally_statusline.py +1048 -176
  28. package/bin/_cctally_store.py +544 -110
  29. package/bin/_cctally_tui.py +455 -42
  30. package/bin/_cctally_update.py +77 -14
  31. package/bin/_lib_account_protocol.py +227 -19
  32. package/bin/_lib_claude_attribution.py +421 -0
  33. package/bin/_lib_clone_copy.py +30 -0
  34. package/bin/_lib_codex_conversation_query.py +45 -9
  35. package/bin/_lib_codex_conversation_watch.py +72 -13
  36. package/bin/_lib_codex_rollout_container.py +234 -19
  37. package/bin/_lib_codex_worker_lifecycle.py +374 -0
  38. package/bin/_lib_credit.py +22 -13
  39. package/bin/_lib_dashboard_sources.py +257 -18
  40. package/bin/_lib_detector_access_registry.py +1335 -166
  41. package/bin/_lib_doctor.py +276 -92
  42. package/bin/_lib_ingest_frontier.py +269 -36
  43. package/bin/_lib_journal.py +273 -22
  44. package/bin/_lib_jsonl.py +12 -0
  45. package/bin/_lib_meter_rate_change.py +72 -0
  46. package/bin/_lib_outcome_quarantine.py +917 -554
  47. package/bin/_lib_provider_activity.py +24 -6
  48. package/bin/_lib_quota_calibration.py +57 -3
  49. package/bin/_lib_quota_copy.py +7 -2
  50. package/bin/_lib_quota_lineage.py +613 -0
  51. package/bin/_lib_quota_model.py +464 -33
  52. package/bin/_lib_rate_change_delivery.py +16 -0
  53. package/bin/_lib_reclaim_planner.py +6 -4
  54. package/bin/_lib_rederive.py +2046 -21
  55. package/bin/_lib_selector_state.py +131 -3
  56. package/bin/_lib_snapshot_cache.py +8 -0
  57. package/bin/_lib_stale_proof.py +113 -4
  58. package/bin/_lib_statusline.py +72 -3
  59. package/bin/_lib_statusline_candidates.py +246 -42
  60. package/bin/_lib_tick_stats.py +10 -5
  61. package/bin/_lib_write_io.py +419 -6
  62. package/bin/cctally +28 -11
  63. package/dashboard/static/assets/{ConversationsView-Dflb_myA.js → ConversationsView-oqNsWzWV.js} +1 -1
  64. package/dashboard/static/assets/{DoctorModal-DFX5CfIs.js → DoctorModal-B0DnU9d9.js} +1 -1
  65. package/dashboard/static/assets/ModalRoot-CYua2wP_.js +1 -0
  66. package/dashboard/static/assets/{ProjectsDrillPanel-C3PJy1xx.js → ProjectsDrillPanel-DSxXQSa-.js} +1 -1
  67. package/dashboard/static/assets/{SourceDetailModal-BczsWKhD.js → SourceDetailModal-BleDpQqG.js} +1 -1
  68. package/dashboard/static/assets/{UpdateModal-Os-Lo5cs.js → UpdateModal-CMqaAkSK.js} +1 -1
  69. package/dashboard/static/assets/{index-CYZ3NLqF.js → index-BjQV-0-D.js} +3 -3
  70. package/dashboard/static/assets/index-D34eYhAc.css +1 -0
  71. package/dashboard/static/assets/{outlineNavigation-k-zIif6W.js → outlineNavigation-CO-VWBm7.js} +1 -1
  72. package/dashboard/static/assets/{useKeymap-CW7Up0Ay.js → useKeymap-CUgEnnua.js} +1 -1
  73. package/dashboard/static/dashboard.html +3 -3
  74. package/package.json +5 -1
  75. package/dashboard/static/assets/ModalRoot-HlxoagIa.js +0 -1
  76. package/dashboard/static/assets/index-BKITk1CT.css +0 -1
@@ -278,6 +278,7 @@ import secrets
278
278
  import shutil
279
279
  import signal as _signal
280
280
  import socket
281
+ import socketserver
281
282
  import select
282
283
  import sqlite3
283
284
  import sys
@@ -338,6 +339,21 @@ class _QuietThreadingHTTPServer(ThreadingHTTPServer):
338
339
  self._reject_workers: list[threading.Thread] = []
339
340
  super().__init__(*args, **kwargs)
340
341
 
342
+ def server_bind(self) -> None:
343
+ """Bind and record the bound address without a reverse-DNS lookup.
344
+
345
+ Replaces `http.server.HTTPServer.server_bind`, which sets `server_name`
346
+ to `socket.getfqdn(host)`: a PTR lookup that blocks for the resolver's
347
+ whole timeout where the resolver never answers it (35s measured on a
348
+ LAN runner), before the dashboard can print its URL or serve (#1021).
349
+ Nothing here reads `server_name` as a hostname, so it records the bound
350
+ address as `getsockname()` reports it.
351
+ """
352
+ socketserver.TCPServer.server_bind(self)
353
+ host, port = self.server_address[:2]
354
+ self.server_name = host
355
+ self.server_port = port
356
+
341
357
  def _reject_request(self, request: socket.socket) -> None:
342
358
  try:
343
359
  # Read only the bounded request headers so a close with unread
@@ -741,7 +757,7 @@ from _cctally_cache import (
741
757
  get_entries, iter_entries, iter_entries_with_id,
742
758
  open_cache_db as _raw_open_cache_db,
743
759
  open_conversations_db, sync_cache, sync_claude_conversations,
744
- sync_codex_conversations,
760
+ sync_codex_conversations, read_codex_startup_deferral_facts,
745
761
  # #780 read-route admission. Imported here so the conversation route module
746
762
  # reaches them the same way it reaches `open_conversations_db` — through
747
763
  # `sys.modules["_cctally_dashboard"]`, which is the seam its tests patch.
@@ -2615,7 +2631,9 @@ def _conversation_frontier_context():
2615
2631
  frontier_mod = _cctally()._load_sibling("_lib_ingest_frontier")
2616
2632
  claude_roots = tuple(_cctally_core._resolve_claude_projects_dirs())
2617
2633
  codex_homes = tuple(_cctally()._codex_home_roots())
2618
- codex_roots = tuple(root / "sessions" for root in codex_homes)
2634
+ # #970: native-home recognition, never a hard-coded `<home>/sessions`, so
2635
+ # an archive directory is certified evidence and a permitted location.
2636
+ codex_roots = frontier_mod.codex_frontier_roots(codex_homes)
2619
2637
  claude_guards = (_cctally_core.CLAUDE_SETTINGS_PATH,)
2620
2638
 
2621
2639
  try:
@@ -2689,15 +2707,121 @@ def _conversation_frontier_plans(conn):
2689
2707
 
2690
2708
 
2691
2709
  class _ConversationPassStatus(str):
2692
- """Validated status string carrying fixed-size pass diagnostics."""
2710
+ """Validated status string carrying fixed-size pass diagnostics.
2693
2711
 
2694
- def __new__(cls, status, *, modes=None, files=None):
2712
+ ``drain`` is the pass's evidence for writeIo's startup admission hold
2713
+ (#939 D5, `_conversation_drain_info`), or ``None``."""
2714
+
2715
+ def __new__(cls, status, *, modes=None, files=None, drain=None):
2695
2716
  obj = str.__new__(cls, status)
2696
2717
  obj.modes = dict(modes or {})
2697
2718
  obj.files = dict(files or {})
2719
+ obj.drain = drain
2698
2720
  return obj
2699
2721
 
2700
2722
 
2723
+ def _conversation_drain_info(conn, status, modes, plans, results,
2724
+ certifiable) -> dict:
2725
+ """What one ``ok`` pass tells writeIo's admission hold (#939 D5).
2726
+
2727
+ Per provider: its census and residual (``None`` for ``caught_up``, which
2728
+ carries the previous one forward), plus an empty ``"pass"`` status entry
2729
+ (an error pass's is non-empty: `_conversation_failed_drain`). For Codex,
2730
+ its unpublished deferrals and, while the hold is placed, the stored facts
2731
+ of every deferral the evaluator tracks, read at the pass's end. The pass
2732
+ is census-complete only when its status is ``ok``, both censuses are
2733
+ complete, the facts could be read and no detached Codex worker is in
2734
+ progress at its end.
2735
+ """
2736
+ import _lib_ingest_frontier as frontier_lib
2737
+ providers = {"pass": {"mode": "full", "residual": ()}}
2738
+ census = status == "ok"
2739
+ for provider in ("claude", "codex"):
2740
+ mode = modes.get(provider, "not_observed")
2741
+ stats = results.get(provider)
2742
+ plan = plans.get(provider) if plans else None
2743
+ expected = len(plan.paths) if plan is not None else 0
2744
+ census = census and frontier_lib.conversation_census_complete(
2745
+ mode, stats, expected_paths=expected)
2746
+ providers[provider] = {
2747
+ "mode": mode,
2748
+ "residual": (None if mode == "caught_up"
2749
+ else _lib_write_io.conversation_residual(stats)),
2750
+ }
2751
+ codex_mode = modes.get("codex")
2752
+ deferrals = (None if codex_mode == "caught_up" or "codex" not in results
2753
+ else _lib_write_io.unpublished_deferrals(results.get("codex")))
2754
+ facts = {}
2755
+ if conn is not None and _lib_write_io.admission_held():
2756
+ entries = dict(_lib_write_io.startup_watch())
2757
+ for path, (identity, _covered, keys) in (deferrals or {}).items():
2758
+ prior = entries.get(path)
2759
+ entries[path] = (identity,
2760
+ frozenset(keys) | (prior[1] if prior else frozenset()))
2761
+ entries = {path: entry for path, entry in entries.items()
2762
+ if entry[0] is not None}
2763
+ if entries:
2764
+ try:
2765
+ facts = read_codex_startup_deferral_facts(conn, entries)
2766
+ except Exception as exc: # noqa: BLE001 — missing facts keep the hold
2767
+ eprint(f"[conversations] startup facts unavailable: {exc}")
2768
+ census = False
2769
+ if census:
2770
+ census = not _codex_worker_in_progress()
2771
+ return {"census_complete": bool(census), "certifiable": bool(certifiable),
2772
+ "providers": providers, "deferrals": deferrals, "facts": facts}
2773
+
2774
+
2775
+ def _codex_worker_in_progress() -> bool:
2776
+ """Whether a detached Codex worker is requested or running (#939 D5); an
2777
+ unknown worker state counts as one (it keeps the hold)."""
2778
+ import _lib_codex_worker_lifecycle as lifecycle
2779
+ try:
2780
+ return bool(lifecycle.any_in_progress(
2781
+ _cctally_core.APP_DIR, now=time.time()))
2782
+ except Exception: # noqa: BLE001 — unknown worker state: keep the hold
2783
+ return True
2784
+
2785
+
2786
+ def _connection_changes(conn) -> "int | None":
2787
+ """``conn.total_changes``: rows changed on the connection since it was
2788
+ created, including the store open's own migrations, imports and replays
2789
+ (#939 R9-01) and changes later rolled back; None when it cannot be read
2790
+ (unknown reads as changed). A double without the attribute changes
2791
+ nothing."""
2792
+ try:
2793
+ return int(getattr(conn, "total_changes", 0) or 0)
2794
+ except Exception: # noqa: BLE001
2795
+ return None
2796
+
2797
+
2798
+ def _conversation_failed_drain(status: str, error_name: str, *,
2799
+ changed_rows: bool) -> dict:
2800
+ """What a pass that ended in ``error`` or ``store_unavailable`` tells
2801
+ writeIo's admission hold (#939 D5).
2802
+
2803
+ A failure can commit work before it propagates (D1's prepared prefix, a
2804
+ Codex publication unit), and no residual can prove that work was not
2805
+ catch-up progress (#939 R7-01, R8-01). So a failing pass that changed any
2806
+ row on its own connection since its creation, the open's migrations
2807
+ included (``changed_rows``, R9-01), and a failed open, which leaves no
2808
+ connection to ask (R10-01), are incomplete censuses: they never anchor a
2809
+ settle. A pass that opened and changed nothing can repeat for the dashboard's whole life, so it is
2810
+ census-complete (unless a detached Codex worker is in progress) with its
2811
+ status and exception class as its residual: an identical failure settles,
2812
+ and a later ``ok`` pass, whose status entry is empty, changes the digest.
2813
+ It is never certifiable, so it never releases as drained. The provider
2814
+ residuals and deferrals carry forward."""
2815
+ return {"census_complete": (not changed_rows
2816
+ and not _codex_worker_in_progress()),
2817
+ "certifiable": False,
2818
+ "providers": {"pass": {
2819
+ "mode": "full",
2820
+ "residual": (("status", status),
2821
+ ("exception", error_name))}},
2822
+ "deferrals": None, "facts": {}}
2823
+
2824
+
2701
2825
  def _conversation_sync_pass() -> str:
2702
2826
  """One WHOLE transcript-ingest pass (#583 S4 / F5).
2703
2827
 
@@ -2742,17 +2866,37 @@ def _conversation_sync_pass() -> str:
2742
2866
  conn = open_conversations_db()
2743
2867
  except (OSError, sqlite3.DatabaseError) as exc:
2744
2868
  eprint(f"[conversations] background sync unavailable: {exc}")
2745
- return _ConversationPassStatus("store_unavailable")
2869
+ # The open can commit work before it fails (a resumable migration's
2870
+ # chunks), and with no connection to ask nothing proves it did not:
2871
+ # file metadata cannot (a reused WAL keeps its size, mtimes are
2872
+ # coarse), so a failed open is inconclusive, read as changed
2873
+ # (#939 R9-01, R10-01).
2874
+ return _ConversationPassStatus(
2875
+ "store_unavailable",
2876
+ drain=_conversation_failed_drain(
2877
+ "store_unavailable", type(exc).__name__, changed_rows=True))
2746
2878
  status = "ok"
2879
+ error_name = None
2880
+ # Rows changed on this pass's connection, from its creation (#939 R8-01,
2881
+ # R9-01): the open's migrations and imports run on it too.
2882
+ changed_rows = True
2747
2883
  modes = {"claude": "not_observed", "codex": "not_observed"}
2748
2884
  files = {"claude": 0, "codex": 0}
2885
+ results = {}
2886
+ plans = None
2887
+ certifiable = False
2888
+ drain = None
2749
2889
  try:
2750
2890
  planned = _conversation_frontier_plans(conn)
2751
- results = {}
2752
2891
  if planned is None:
2753
2892
  modes = {"claude": "full", "codex": "full"}
2754
2893
  results["claude"] = sync_claude_conversations(conn)
2755
2894
  results["codex"] = sync_codex_conversations(conn)
2895
+ import _lib_ingest_frontier as frontier_lib
2896
+ certifiable = all(
2897
+ frontier_lib.conversation_sync_certifiable(
2898
+ "full", results.get(provider))
2899
+ for provider in ("claude", "codex"))
2756
2900
  else:
2757
2901
  frontier_mod, frontier, cutoff, context, plans = planned
2758
2902
  modes = {
@@ -2801,9 +2945,18 @@ def _conversation_sync_pass() -> str:
2801
2945
  frontier.commit_provider(
2802
2946
  plan, conn, roots=roots, guard_paths=guards,
2803
2947
  trusted=trusted, cutoff=cutoff)
2948
+ try:
2949
+ drain = _conversation_drain_info(
2950
+ conn, status, modes, plans, results, certifiable)
2951
+ except Exception as exc: # noqa: BLE001 — the hold's evidence is
2952
+ # diagnostic: it must never fail a pass that ingested. A missing
2953
+ # record is read as a pass that did not cover its census.
2954
+ eprint(f"[conversations] startup drain evidence unavailable: {exc}")
2955
+ drain = None
2804
2956
  except (OSError, sqlite3.DatabaseError) as exc:
2805
2957
  eprint(f"[conversations] background sync unavailable: {exc}")
2806
2958
  status = "store_unavailable"
2959
+ error_name = type(exc).__name__
2807
2960
  except Exception as exc: # noqa: BLE001
2808
2961
  # Transcript parsing/normalization is deliberately outside the core
2809
2962
  # freshness loop. Keep this worker alive so a later clean tick can
@@ -2814,7 +2967,10 @@ def _conversation_sync_pass() -> str:
2814
2967
  f"{type(exc).__name__}: {exc}"
2815
2968
  )
2816
2969
  status = "error"
2970
+ error_name = type(exc).__name__
2817
2971
  finally:
2972
+ changes = _connection_changes(conn)
2973
+ changed_rows = changes is None or changes > 0
2818
2974
  try:
2819
2975
  conn.close()
2820
2976
  except Exception: # noqa: BLE001
@@ -2824,7 +2980,18 @@ def _conversation_sync_pass() -> str:
2824
2980
  if provider in results:
2825
2981
  files[provider] = int(
2826
2982
  getattr(results.get(provider), "files_total", 0) or 0)
2827
- return _ConversationPassStatus(status, modes=modes, files=files)
2983
+ if status != "ok":
2984
+ # An error or an unavailable store ended the pass: its status is its
2985
+ # residual, so a failure that repeats identically settles (#939 D5).
2986
+ # An error pass is never drained.
2987
+ drain = _conversation_failed_drain(status, error_name or "Exception",
2988
+ changed_rows=changed_rows)
2989
+ elif drain is None:
2990
+ # An ok pass whose evidence could not be built: not census-complete.
2991
+ drain = {"census_complete": False, "certifiable": False,
2992
+ "providers": {}, "deferrals": None, "facts": {}}
2993
+ return _ConversationPassStatus(status, modes=modes, files=files,
2994
+ drain=drain)
2828
2995
 
2829
2996
 
2830
2997
  def _conversation_sync_loop(
@@ -2847,12 +3014,31 @@ def _conversation_sync_loop(
2847
3014
  The previous fixed `wait(interval)` prevented literal 100% duty for finite
2848
3015
  work but provided no scale-independent ceiling below it: a 30 s pass ran
2849
3016
  30-on/5-off, about 86% duty, and nothing bounded that as the store grew.
3017
+
3018
+ #939 D5: every completed pass is fed to writeIo's startup admission hold,
3019
+ whose verdict ("drained" or "settled") releases it; the loop's end
3020
+ releases it in a ``finally`` ("loopExit"), so a dead loop never holds
3021
+ admission. An exception from the evaluator is logged once and read as no
3022
+ verdict (the hold stays), never as the end of ingest.
2850
3023
  """
3024
+ try:
3025
+ _conversation_sync_loop_body(
3026
+ stop=stop, interval=interval, run_iteration=run_iteration,
3027
+ monotonic=monotonic, thread_time_ns=thread_time_ns, wait=wait,
3028
+ record=record)
3029
+ finally:
3030
+ _lib_write_io.release_admission("loopExit")
3031
+
3032
+
3033
+ def _conversation_sync_loop_body(
3034
+ *, stop, interval, run_iteration, monotonic, thread_time_ns, wait, record,
3035
+ ) -> None:
2851
3036
  if thread_time_ns is None:
2852
3037
  thread_time_ns = time.thread_time_ns
2853
3038
  if wait is None:
2854
3039
  wait = stop.wait
2855
3040
  seq = 0
3041
+ evaluator_failed = False
2856
3042
  while not stop.is_set():
2857
3043
  t0 = monotonic()
2858
3044
  cpu0 = thread_time_ns()
@@ -2866,6 +3052,20 @@ def _conversation_sync_loop(
2866
3052
  work = max(0.0, monotonic() - t0)
2867
3053
  cpu_ns = max(0, thread_time_ns() - cpu0)
2868
3054
  seq += 1
3055
+ try:
3056
+ verdict = _lib_write_io.observe_conversation_pass(
3057
+ getattr(status, "drain", None), started_ns=int(t0 * 1e9),
3058
+ seq=seq)
3059
+ if verdict is not None:
3060
+ _lib_write_io.release_admission(verdict)
3061
+ except Exception as exc: # noqa: BLE001 — an evaluator bug must not
3062
+ # stop ingest: the verdict is read as None (the hold stays, fail
3063
+ # closed like every other missing-evidence path) and the loop
3064
+ # keeps running. Logged once per loop.
3065
+ if not evaluator_failed:
3066
+ evaluator_failed = True
3067
+ eprint("[conversations] writeIo startup evaluator failed: "
3068
+ f"{type(exc).__name__}: {exc}")
2869
3069
  if record is not None:
2870
3070
  record(
2871
3071
  seq=seq,
@@ -9236,6 +9436,15 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
9236
9436
  "conversation_sync": [
9237
9437
  r.as_wire() for r in tick_state.conversation_records
9238
9438
  ],
9439
+ # #939 D5: the records' `started_ns`/`ended_ns` clock (the
9440
+ # process monotonic clock) anchored to wall time in this
9441
+ # response, so a harness can place its own judgements before
9442
+ # or after a pass. One pair, not a field per record: the ring
9443
+ # sits at the frozen 65,536-byte state budget.
9444
+ "conversation_sync_clock": {
9445
+ "monotonic_ns": time.monotonic_ns(),
9446
+ "unix_ns": time.time_ns(),
9447
+ },
9239
9448
  # #780: the maintenance phases, published beside the sync ring
9240
9449
  # rather than inside it. A reclaim that ran for its whole budget
9241
9450
  # used to be indistinguishable from a fast one, because both
@@ -11335,12 +11544,20 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
11335
11544
  assembly runs inside one transaction per DB. 400 malformed key/source;
11336
11545
  404 ``{code: "unknown_key", reason}`` for keys that don't resolve.
11337
11546
 
11338
- ``?account=<account_key|unattributed>`` (#416, Codex only) focuses the
11339
- cycle on one account: its own crossings, its own spend. ABSENT is the
11340
- shipped merged response and is byte-identical — the client sends the
11341
- qualifier only under account focus, exactly as `/api/source/` does. The
11547
+ ``?account=<account_key|unattributed>`` (#416 Codex; #836 Claude)
11548
+ focuses the cycle on one account: its own crossings, its own spend.
11549
+ ABSENT is the shipped merged response and is byte-identical — the
11550
+ client sends the qualifier only under account focus (Codex) or for the
11551
+ displayed account (Claude), exactly as `/api/source/` does. The
11342
11552
  vendor-wide ``*`` sentinel is rejected here (unlike `/api/source/`,
11343
11553
  where it labels an alert row): it names no cycle to render.
11554
+
11555
+ Claude (#836 spec 5.6) accepts the qualifier only while Claude is
11556
+ decorated (more than one REAL account), checked in the same read
11557
+ transaction as the assembly: 400 on an undecorated store, 404
11558
+ ``unknown_key`` for an unknown or non-Claude account or a key outside
11559
+ that account's scoped index, and never a retry against the merged
11560
+ index.
11344
11561
  """
11345
11562
  import re as _re
11346
11563
  import urllib.parse as _urlparse
@@ -11367,11 +11584,6 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
11367
11584
  ):
11368
11585
  self._send_milestones_json(400, {"error": "invalid account"})
11369
11586
  return
11370
- if source != "codex":
11371
- # Claude weeks are not account-partitioned on this route yet;
11372
- # accepting the qualifier silently would be a privacy lie.
11373
- self._send_milestones_json(400, {"error": "invalid account"})
11374
- return
11375
11587
  account_key = raw_account[0]
11376
11588
  c = sys.modules["cctally"]
11377
11589
  try:
@@ -11383,12 +11595,38 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
11383
11595
  self._send_milestones_json(400, {"error": "invalid cycle key"})
11384
11596
  return
11385
11597
  conn = open_db()
11598
+ rejected = False
11599
+ detail = None
11386
11600
  try:
11387
11601
  conn.execute("BEGIN")
11388
- detail = c.build_claude_week_detail(conn, key)
11602
+ if account_key is None:
11603
+ detail = c.build_claude_week_detail(conn, key)
11604
+ else:
11605
+ # #836 spec 5.6. Only a DECORATED store partitions
11606
+ # Claude history by account. On an undecorated one the
11607
+ # qualifier is refused, never ignored: answering it with
11608
+ # the merged week would claim an account scope the
11609
+ # payload does not have, which is the privacy lie the
11610
+ # earlier blanket rejection guarded against. Checked
11611
+ # inside this transaction so the gate and the assembly
11612
+ # read one state.
11613
+ import _cctally_account
11614
+ if not _cctally_account.provider_is_decorated(
11615
+ conn, "claude"):
11616
+ rejected = True
11617
+ elif account_key == "unattributed" or conn.execute(
11618
+ "SELECT 1 FROM accounts WHERE account_key = ? "
11619
+ "AND provider = 'claude'",
11620
+ (account_key,)).fetchone() is not None:
11621
+ detail = c.build_claude_week_detail(
11622
+ conn, key, account_key=account_key,
11623
+ now_utc=_command_as_of())
11389
11624
  conn.commit()
11390
11625
  finally:
11391
11626
  conn.close()
11627
+ if rejected:
11628
+ self._send_milestones_json(400, {"error": "invalid account"})
11629
+ return
11392
11630
  if detail is None:
11393
11631
  self._send_milestones_json(404, {
11394
11632
  "error": "unknown week", "code": "unknown_key",
@@ -12539,6 +12777,11 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
12539
12777
  _lib_write_io.register_local_instance(
12540
12778
  instance_id=write_instance_id, host=args.host,
12541
12779
  port=_resolve_dashboard_port(args.port))
12780
+ # #939 D5: hold warm admission through the startup catch-up. Placed before
12781
+ # the main sync thread can publish a non-cold snapshot, and only when the
12782
+ # conversation loop (which releases it) will run.
12783
+ if not args.no_sync:
12784
+ _lib_write_io.hold_admission()
12542
12785
  sync_thread = (
12543
12786
  None if args.no_sync
12544
12787
  else _DashboardSyncThread(
@@ -12689,6 +12932,9 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
12689
12932
  conversation_sync_stop.set()
12690
12933
  if conversation_sync_thread is not None:
12691
12934
  conversation_sync_thread.join(timeout=2)
12935
+ # #939 D5: the loop's own `finally` releases the hold; a loop still
12936
+ # finishing a pass after the bounded join must not leave it placed.
12937
+ _lib_write_io.release_admission("loopExit")
12692
12938
  update_check_stop.set()
12693
12939
  memory_shutdown_errors = []
12694
12940
  try:
@@ -46,6 +46,8 @@ import base64
46
46
  import collections
47
47
  import dataclasses
48
48
  import hashlib
49
+ import os
50
+ import pathlib
49
51
  import secrets
50
52
  import select
51
53
  import threading
@@ -444,17 +446,86 @@ def _cached_file_sigs(conn, paths):
444
446
  return out
445
447
 
446
448
 
447
- #: The cursor columns a Codex live-tail signature is computed from (#968 5.9).
449
+ #: The cursor columns a Codex live-tail signature is computed from (#968 5.9;
450
+ #: #970 appends ``container_path``, the accepted location).
448
451
  _CODEX_WATCH_SIG_COLUMNS = (
449
452
  "path, size_bytes, container_kind, container_size_bytes, mtime_ns, "
450
- "device_id, inode, pending_container_fingerprint")
453
+ "device_id, inode, pending_container_fingerprint, container_path")
451
454
 
452
455
 
453
456
  def _codex_watch_kernel():
454
457
  return sys.modules["cctally"]._load_sibling("_lib_codex_conversation_watch")
455
458
 
456
459
 
457
- def _codex_acknowledged_sig(row):
460
+ def _codex_watch_locations():
461
+ """The configured Codex container locations (#970), for one batch."""
462
+ return tuple(_rc.CodexLocations(root.walk_root, root.archive_root)
463
+ for root in _codex_provider_roots())
464
+
465
+
466
+ def _codex_lazy_locations():
467
+ """A memoized locations getter: resolved on first need, at most once.
468
+
469
+ A home without archived containers never needs it, so its signatures
470
+ cost exactly what they cost before #970.
471
+ """
472
+ memo: list = []
473
+
474
+ def get():
475
+ if not memo:
476
+ memo.append(_codex_watch_locations())
477
+ return memo[0]
478
+
479
+ return get
480
+
481
+
482
+ def _codex_sig_location(container_path, logical, locations) -> str:
483
+ """The location a stored container path names (#970 A8).
484
+
485
+ NULL is the logical path (dated). A container whose directory is the
486
+ rollout's archive directory — canonically, when the spellings differ —
487
+ is ``"archive"``; anything else (the dated twin, an uncontained stored
488
+ path) is ``"dated"``. This is exactly how selection names a location, so
489
+ a committed signature equals the observation of the same container.
490
+ """
491
+ if not container_path:
492
+ return _rc.DATED
493
+ parent = pathlib.Path(container_path).parent
494
+ if parent == pathlib.Path(logical).parent:
495
+ return _rc.DATED
496
+ archive_dir = _rc.archive_dir_for(logical, locations())
497
+ if archive_dir is None:
498
+ return _rc.DATED
499
+ if parent == archive_dir:
500
+ return _rc.ARCHIVE
501
+ try:
502
+ same = os.path.realpath(parent) == os.path.realpath(archive_dir)
503
+ except (OSError, ValueError):
504
+ return _rc.DATED
505
+ return _rc.ARCHIVE if same else _rc.DATED
506
+
507
+
508
+ def _codex_observe_watch_sig(path, locations=None):
509
+ """The live probe: the selected container's signature, archive included.
510
+
511
+ The dated containers are tried first through the leaf alone, so a dated
512
+ rollout costs exactly today's probe; only when neither exists, and the
513
+ path is a dated rollout of a native home, are its archive candidates
514
+ observed (#970 A8), the signature then carrying ``"archive"``.
515
+ """
516
+ sig = _rc.observe_watch_sig(path)
517
+ if sig is not None:
518
+ return sig
519
+ if _rc.archive_name_date(pathlib.Path(str(path)).name) is None:
520
+ return None
521
+ archive_dir = _rc.archive_dir_for(
522
+ path, (locations or _codex_watch_locations)())
523
+ if archive_dir is None:
524
+ return None
525
+ return _rc.observe_watch_sig(path, archive_dir=archive_dir)
526
+
527
+
528
+ def _codex_acknowledged_sig(row, locations=None):
458
529
  """The live-tail signature one ``codex_conversation_source_files`` row
459
530
  acknowledges (#968 spec 5.9).
460
531
 
@@ -463,15 +534,20 @@ def _codex_acknowledged_sig(row):
463
534
  it, and rescheduling it on every poll is the non-convergence I9 forbids.
464
535
  Otherwise the accepted container's signature: ``("plain", size)`` for
465
536
  plain, which is today's committed size, or the container identity for a
466
- zstd container.
537
+ zstd container. #970: either carries the container's location exactly as
538
+ the observation does (``"archive"`` appended only outside the dated
539
+ location), derived from the recorded container path.
467
540
  """
468
- _path, size, kind, csize, mtime_ns, device, inode, pending = row
541
+ path, size, kind, csize, mtime_ns, device, inode, pending, stored = row
542
+ locations = locations or _codex_lazy_locations()
469
543
  if pending:
470
544
  fingerprint = _rc.ContainerFingerprint.from_json(pending)
471
545
  if fingerprint is not None:
472
- return fingerprint.watch_sig()
546
+ return fingerprint.watch_sig(
547
+ _codex_sig_location(fingerprint.path, path, locations))
473
548
  return _codex_watch_kernel().committed_watch_sig(
474
- kind, size, csize, mtime_ns, device, inode)
549
+ kind, size, csize, mtime_ns, device, inode,
550
+ location=_codex_sig_location(stored, path, locations))
475
551
 
476
552
 
477
553
  def _codex_cached_file_sigs(conn, paths):
@@ -494,8 +570,9 @@ def _codex_cached_file_sigs(conn, paths):
494
570
  f"WHERE path IN ({placeholders})", list(paths)).fetchall()
495
571
  except sqlite3.OperationalError:
496
572
  return out
573
+ locations = _codex_lazy_locations()
497
574
  for row in rows:
498
- out[row[0]] = _codex_acknowledged_sig(row)
575
+ out[row[0]] = _codex_acknowledged_sig(row, locations)
499
576
  return out
500
577
 
501
578
 
@@ -511,7 +588,8 @@ def _codex_all_committed_sizes(conn):
511
588
  "FROM codex_conversation_source_files").fetchall()
512
589
  except sqlite3.OperationalError:
513
590
  return {}
514
- return {row[0]: _codex_acknowledged_sig(row) for row in rows}
591
+ locations = _codex_lazy_locations()
592
+ return {row[0]: _codex_acknowledged_sig(row, locations) for row in rows}
515
593
 
516
594
 
517
595
  def _codex_seal_token(conn):
@@ -586,6 +664,9 @@ def _codex_watch_probes(conn):
586
664
  deferred: dict = {} # path -> (pre-ingest signature, seal token)
587
665
  accounting_wait: dict = {} # path -> observed container awaiting accounting
588
666
  rearmed: set = set()
667
+ # #970: resolved once per stream, and only when an archived container
668
+ # is actually met.
669
+ locations = _codex_lazy_locations()
589
670
 
590
671
  def accounting_accepted(path, sig):
591
672
  try:
@@ -595,7 +676,8 @@ def _codex_watch_probes(conn):
595
676
  "WHERE path=? AND container_state='accepted'", (path,)).fetchone()
596
677
  except sqlite3.Error:
597
678
  return False
598
- return row is not None and _codex_acknowledged_sig(row) == sig
679
+ return (row is not None
680
+ and _codex_acknowledged_sig(row, locations) == sig)
599
681
 
600
682
  def executor_owns_observations(paths):
601
683
  # Publication-unit planning can encounter the same pending container
@@ -620,12 +702,13 @@ def _codex_watch_probes(conn):
620
702
  before = observed.get(path)
621
703
  if (fingerprint is None or before is None
622
704
  or before[0] != _rc.ZSTD
623
- or fingerprint.watch_sig() != before):
705
+ or fingerprint.watch_sig(_codex_sig_location(
706
+ fingerprint.path, path, locations)) != before):
624
707
  return False
625
708
  return True
626
709
 
627
710
  def stat_fn(path):
628
- sig = _rc.observe_watch_sig(path)
711
+ sig = _codex_observe_watch_sig(path, locations)
629
712
  observed[path] = sig
630
713
  waiting = accounting_wait.get(path)
631
714
  if waiting is not None:
@@ -741,11 +824,13 @@ def _codex_classified_paths(conn, paths):
741
824
 
742
825
 
743
826
  def _codex_walk_root_for_conversation(conn, conversation_key):
744
- """The configured ``walk_root`` of the conversation's OWN provider root (the
745
- frontier's scope — §5.4). Resolves the conversation's ``source_root_key`` via
746
- its thread facts, then matches it to a currently-configured provider root.
747
- ``None`` when the conversation has no thread row or its root is no longer
748
- configured (the frontier is then skipped — DB re-resolve alone still runs)."""
827
+ """The container locations of the conversation's OWN provider root (the
828
+ frontier's scope — §5.4): its configured ``walk_root`` and, for a native
829
+ home, its ``archive_root`` (#970), as ``_rc.CodexLocations``. Resolves the
830
+ conversation's ``source_root_key`` via its thread facts, then matches it to
831
+ a currently-configured provider root. ``None`` when the conversation has no
832
+ thread row or its root is no longer configured (the frontier is then
833
+ skipped — DB re-resolve alone still runs)."""
749
834
  cq = _conversation_query_impl_codex()
750
835
  thread = cq._thread_facts(conn, conversation_key)
751
836
  if thread is None:
@@ -753,7 +838,7 @@ def _codex_walk_root_for_conversation(conn, conversation_key):
753
838
  source_root_key = thread[3]
754
839
  for root in _codex_provider_roots():
755
840
  if root.source_root_key == source_root_key:
756
- return str(root.walk_root)
841
+ return _rc.CodexLocations(root.walk_root, root.archive_root)
757
842
  return None
758
843
 
759
844
 
@@ -1748,11 +1833,12 @@ def _make_codex_discovery_step(handler, conn, conversation_key, cq_codex):
1748
1833
  frontier is then skipped — the DB re-resolve of layer 1 still runs). The
1749
1834
  frontier is constructed ONCE per SSE connection so its directory index,
1750
1835
  pending-candidate set, and rotation cursors accumulate across cycles."""
1751
- walk_root = _codex_walk_root_for_conversation(conn, conversation_key)
1752
- if walk_root is None:
1836
+ locations = _codex_walk_root_for_conversation(conn, conversation_key)
1837
+ if locations is None:
1753
1838
  return None
1754
1839
  fw = sys.modules["cctally"]._load_sibling("_lib_codex_conversation_watch")
1755
- frontier = fw.CodexChildFrontier(walk_root)
1840
+ frontier = fw.CodexChildFrontier(
1841
+ str(locations.walk_root), archive_root=locations.archive_root)
1756
1842
 
1757
1843
  def _discovery(files, seen):
1758
1844
  # Diff brand-new *.jsonl against every tracked Codex file; the same