cctally 1.116.0 → 1.117.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.
- package/CHANGELOG.md +36 -0
- package/README.md +4 -6
- package/bin/_cctally_account.py +57 -3
- package/bin/_cctally_cache.py +5720 -885
- package/bin/_cctally_claude_attribution.py +997 -0
- package/bin/_cctally_completion.py +101 -28
- package/bin/_cctally_core.py +235 -12
- package/bin/_cctally_dashboard.py +224 -7
- package/bin/_cctally_dashboard_conversation.py +107 -21
- package/bin/_cctally_dashboard_envelope.py +34 -0
- package/bin/_cctally_db.py +928 -80
- package/bin/_cctally_diagnosis_sources.py +78 -13
- package/bin/_cctally_doctor.py +211 -86
- package/bin/_cctally_five_hour.py +389 -18
- package/bin/_cctally_forecast.py +30 -6
- package/bin/_cctally_journal.py +2418 -158
- package/bin/_cctally_oauth_samples.py +185 -7
- package/bin/_cctally_parser.py +38 -0
- package/bin/_cctally_project.py +34 -8
- package/bin/_cctally_quarantine.py +821 -343
- package/bin/_cctally_quota.py +69 -4
- package/bin/_cctally_quota_calibration.py +677 -3
- package/bin/_cctally_quota_model.py +927 -127
- package/bin/_cctally_record.py +922 -445
- package/bin/_cctally_rederive.py +119 -15
- package/bin/_cctally_statusline.py +1048 -176
- package/bin/_cctally_store.py +544 -110
- package/bin/_cctally_tui.py +93 -9
- package/bin/_cctally_update.py +77 -14
- package/bin/_lib_account_protocol.py +227 -19
- package/bin/_lib_claude_attribution.py +421 -0
- package/bin/_lib_clone_copy.py +30 -0
- package/bin/_lib_codex_conversation_query.py +45 -9
- package/bin/_lib_codex_conversation_watch.py +72 -13
- package/bin/_lib_codex_rollout_container.py +234 -19
- package/bin/_lib_codex_worker_lifecycle.py +374 -0
- package/bin/_lib_credit.py +22 -13
- package/bin/_lib_dashboard_sources.py +163 -10
- package/bin/_lib_detector_access_registry.py +1335 -166
- package/bin/_lib_doctor.py +276 -92
- package/bin/_lib_ingest_frontier.py +269 -36
- package/bin/_lib_journal.py +273 -22
- package/bin/_lib_jsonl.py +12 -0
- package/bin/_lib_meter_rate_change.py +72 -0
- package/bin/_lib_outcome_quarantine.py +917 -554
- package/bin/_lib_provider_activity.py +24 -6
- package/bin/_lib_quota_calibration.py +57 -3
- package/bin/_lib_quota_copy.py +7 -2
- package/bin/_lib_quota_lineage.py +613 -0
- package/bin/_lib_quota_model.py +435 -22
- package/bin/_lib_rate_change_delivery.py +16 -0
- package/bin/_lib_reclaim_planner.py +6 -4
- package/bin/_lib_rederive.py +2046 -21
- package/bin/_lib_selector_state.py +131 -3
- package/bin/_lib_stale_proof.py +113 -4
- package/bin/_lib_statusline.py +72 -3
- package/bin/_lib_statusline_candidates.py +246 -42
- package/bin/_lib_write_io.py +418 -5
- package/bin/cctally +24 -11
- package/dashboard/static/assets/{ConversationsView-Dflb_myA.js → ConversationsView-Dcfn8T6f.js} +1 -1
- package/dashboard/static/assets/{DoctorModal-DFX5CfIs.js → DoctorModal-BqmXbnhn.js} +1 -1
- package/dashboard/static/assets/{ModalRoot-HlxoagIa.js → ModalRoot-Ck3WJK2V.js} +1 -1
- package/dashboard/static/assets/{ProjectsDrillPanel-C3PJy1xx.js → ProjectsDrillPanel-B-HZkDnT.js} +1 -1
- package/dashboard/static/assets/{SourceDetailModal-BczsWKhD.js → SourceDetailModal-QhJkHB7i.js} +1 -1
- package/dashboard/static/assets/{UpdateModal-Os-Lo5cs.js → UpdateModal-CkXEIQcK.js} +1 -1
- package/dashboard/static/assets/index-D34eYhAc.css +1 -0
- package/dashboard/static/assets/{index-CYZ3NLqF.js → index-HKS1s56o.js} +3 -3
- package/dashboard/static/assets/{outlineNavigation-k-zIif6W.js → outlineNavigation-BNGaNPCp.js} +1 -1
- package/dashboard/static/assets/{useKeymap-CW7Up0Ay.js → useKeymap-CUgEnnua.js} +1 -1
- package/dashboard/static/dashboard.html +3 -3
- package/package.json +5 -1
- 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
|
-
|
|
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.
|
|
2711
|
+
|
|
2712
|
+
``drain`` is the pass's evidence for writeIo's startup admission hold
|
|
2713
|
+
(#939 D5, `_conversation_drain_info`), or ``None``."""
|
|
2693
2714
|
|
|
2694
|
-
def __new__(cls, status, *, modes=None, files=None):
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -12539,6 +12748,11 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
|
12539
12748
|
_lib_write_io.register_local_instance(
|
|
12540
12749
|
instance_id=write_instance_id, host=args.host,
|
|
12541
12750
|
port=_resolve_dashboard_port(args.port))
|
|
12751
|
+
# #939 D5: hold warm admission through the startup catch-up. Placed before
|
|
12752
|
+
# the main sync thread can publish a non-cold snapshot, and only when the
|
|
12753
|
+
# conversation loop (which releases it) will run.
|
|
12754
|
+
if not args.no_sync:
|
|
12755
|
+
_lib_write_io.hold_admission()
|
|
12542
12756
|
sync_thread = (
|
|
12543
12757
|
None if args.no_sync
|
|
12544
12758
|
else _DashboardSyncThread(
|
|
@@ -12689,6 +12903,9 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
|
12689
12903
|
conversation_sync_stop.set()
|
|
12690
12904
|
if conversation_sync_thread is not None:
|
|
12691
12905
|
conversation_sync_thread.join(timeout=2)
|
|
12906
|
+
# #939 D5: the loop's own `finally` releases the hold; a loop still
|
|
12907
|
+
# finishing a pass after the bounded join must not leave it placed.
|
|
12908
|
+
_lib_write_io.release_admission("loopExit")
|
|
12692
12909
|
update_check_stop.set()
|
|
12693
12910
|
memory_shutdown_errors = []
|
|
12694
12911
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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 =
|
|
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
|
|
745
|
-
frontier's scope — §5.4)
|
|
746
|
-
its
|
|
747
|
-
``
|
|
748
|
-
configured
|
|
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
|
|
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
|
-
|
|
1752
|
-
if
|
|
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(
|
|
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
|
|
@@ -987,6 +987,34 @@ def _source_bundle_to_envelope(bundle: object | None) -> dict:
|
|
|
987
987
|
METER_RATE_CHANGE_LIMIT = 20
|
|
988
988
|
|
|
989
989
|
|
|
990
|
+
def _rate_change_supersessions(conn: sqlite3.Connection) -> dict:
|
|
991
|
+
"""Wire `supersession` objects by transition identity (#1015 spec 5.7).
|
|
992
|
+
|
|
993
|
+
Read through `read_rate_change_supersessions` on the build's own stats
|
|
994
|
+
connection, so both the explicit `meter_rate_change_supersessions` rows
|
|
995
|
+
and the supersessions derived from a transition's lineage before any
|
|
996
|
+
sweep journals one are covered. `superseded_at` is the explicit event's
|
|
997
|
+
sweep clock, or null for a derived-only state. Unreadable facts publish
|
|
998
|
+
no supersession — the pre-#1015 row — rather than emptying the array: the
|
|
999
|
+
marker and delivery paths refuse on their own when the facts cannot be
|
|
1000
|
+
verified.
|
|
1001
|
+
"""
|
|
1002
|
+
try:
|
|
1003
|
+
qcal = sys.modules["cctally"]._load_sibling(
|
|
1004
|
+
"_cctally_quota_calibration")
|
|
1005
|
+
state = qcal.read_rate_change_supersessions(conn)
|
|
1006
|
+
except Exception: # noqa: BLE001
|
|
1007
|
+
return {}
|
|
1008
|
+
return {
|
|
1009
|
+
tuple(identity): {
|
|
1010
|
+
"reason": record.reason,
|
|
1011
|
+
"superseded_at": record.superseded_at,
|
|
1012
|
+
"corrections": list(record.corrections),
|
|
1013
|
+
}
|
|
1014
|
+
for identity, record in state.by_identity.items()
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
|
|
990
1018
|
def _build_meter_rate_change_array(
|
|
991
1019
|
conn: sqlite3.Connection, limit: int = METER_RATE_CHANGE_LIMIT,
|
|
992
1020
|
) -> list:
|
|
@@ -1046,6 +1074,7 @@ def _build_meter_rate_change_array(
|
|
|
1046
1074
|
# background worker. An empty array renders as "no change recorded",
|
|
1047
1075
|
# which is the truth on that store, rather than emptying the envelope.
|
|
1048
1076
|
return []
|
|
1077
|
+
supersessions = _rate_change_supersessions(conn) if rows else {}
|
|
1049
1078
|
out: list[dict] = []
|
|
1050
1079
|
for (provider, account_key, effective_from, previous, new, severity,
|
|
1051
1080
|
detected_at, created_at, status, causes, provenance,
|
|
@@ -1082,6 +1111,11 @@ def _build_meter_rate_change_array(
|
|
|
1082
1111
|
"composition_provenance": _decode_evidence_array(provenance),
|
|
1083
1112
|
"baseline_withheld_days": (
|
|
1084
1113
|
None if baseline_days is None else int(baseline_days)),
|
|
1114
|
+
# #1015 spec 5.7 (U2): published always and nulled, so the client
|
|
1115
|
+
# can tell an ordinary row (null) from one an older server sent
|
|
1116
|
+
# (absent). The row and its historical values are kept.
|
|
1117
|
+
"supersession": supersessions.get(
|
|
1118
|
+
(str(provider), str(account_key), str(effective_from))),
|
|
1085
1119
|
}
|
|
1086
1120
|
entry.update(account_fields(str(provider), account_key))
|
|
1087
1121
|
out.append(entry)
|