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.
- package/CHANGELOG.md +47 -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 +263 -17
- package/bin/_cctally_dashboard_conversation.py +107 -21
- package/bin/_cctally_dashboard_envelope.py +44 -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 +446 -22
- package/bin/_cctally_forecast.py +302 -6
- package/bin/_cctally_journal.py +2418 -158
- package/bin/_cctally_milestone_history.py +379 -91
- 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 +455 -42
- 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 +257 -18
- 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 +464 -33
- 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_snapshot_cache.py +8 -0
- 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_tick_stats.py +10 -5
- package/bin/_lib_write_io.py +419 -6
- package/bin/cctally +28 -11
- package/dashboard/static/assets/{ConversationsView-Dflb_myA.js → ConversationsView-oqNsWzWV.js} +1 -1
- package/dashboard/static/assets/{DoctorModal-DFX5CfIs.js → DoctorModal-B0DnU9d9.js} +1 -1
- package/dashboard/static/assets/ModalRoot-CYua2wP_.js +1 -0
- package/dashboard/static/assets/{ProjectsDrillPanel-C3PJy1xx.js → ProjectsDrillPanel-DSxXQSa-.js} +1 -1
- package/dashboard/static/assets/{SourceDetailModal-BczsWKhD.js → SourceDetailModal-BleDpQqG.js} +1 -1
- package/dashboard/static/assets/{UpdateModal-Os-Lo5cs.js → UpdateModal-CMqaAkSK.js} +1 -1
- package/dashboard/static/assets/{index-CYZ3NLqF.js → index-BjQV-0-D.js} +3 -3
- package/dashboard/static/assets/index-D34eYhAc.css +1 -0
- package/dashboard/static/assets/{outlineNavigation-k-zIif6W.js → outlineNavigation-CO-VWBm7.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/ModalRoot-HlxoagIa.js +0 -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.
|
|
2693
2711
|
|
|
2694
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
|
11339
|
-
cycle on one account: its own crossings, its own spend.
|
|
11340
|
-
shipped merged response and is byte-identical — the
|
|
11341
|
-
qualifier only under account focus
|
|
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
|
-
|
|
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
|
|
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
|