cctally 1.113.0 → 1.115.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 +47 -0
- package/README.md +4 -6
- package/bin/_cctally_account.py +32 -8
- package/bin/_cctally_cache.py +6035 -722
- package/bin/_cctally_completion.py +1287 -0
- package/bin/_cctally_config.py +37 -0
- package/bin/_cctally_core.py +400 -2
- package/bin/_cctally_dashboard.py +98 -20
- package/bin/_cctally_dashboard_conversation.py +292 -23
- package/bin/_cctally_dashboard_envelope.py +16 -3
- package/bin/_cctally_dashboard_perf.py +128 -9
- package/bin/_cctally_dashboard_sources.py +285 -213
- package/bin/_cctally_db.py +1177 -23
- package/bin/_cctally_diagnosis_sources.py +22 -4
- package/bin/_cctally_doctor.py +572 -45
- package/bin/_cctally_five_hour.py +84 -33
- package/bin/_cctally_forecast.py +108 -15
- package/bin/_cctally_journal.py +3950 -116
- package/bin/_cctally_journal_repair.py +101 -12
- package/bin/_cctally_milestone_history.py +82 -9
- package/bin/_cctally_parser.py +42 -0
- package/bin/_cctally_percent_breakdown.py +3 -2
- package/bin/_cctally_project.py +8 -2
- package/bin/_cctally_quarantine.py +652 -0
- package/bin/_cctally_quota.py +227 -37
- package/bin/_cctally_quota_model.py +360 -83
- package/bin/_cctally_record.py +691 -149
- package/bin/_cctally_rederive.py +320 -41
- package/bin/_cctally_refresh.py +84 -13
- package/bin/_cctally_source_analytics.py +83 -17
- package/bin/_cctally_statusline.py +6154 -517
- package/bin/_cctally_store.py +367 -53
- package/bin/_cctally_transcript.py +8 -7
- package/bin/_cctally_tui.py +61 -56
- package/bin/_cctally_weekrefs.py +128 -21
- package/bin/_lib_account_binding.py +152 -0
- package/bin/_lib_account_protocol.py +386 -0
- package/bin/_lib_aggregators.py +4 -0
- package/bin/_lib_cache_coverage.py +13 -1
- package/bin/_lib_cache_report.py +35 -6
- package/bin/_lib_cache_writer_lock.py +19 -3
- package/bin/_lib_clone_copy.py +142 -0
- package/bin/_lib_codex_conversation.py +32 -0
- package/bin/_lib_codex_conversation_query.py +297 -49
- package/bin/_lib_codex_conversation_watch.py +59 -14
- package/bin/_lib_codex_drain_admission.py +214 -0
- package/bin/_lib_codex_metadata.py +18 -1
- package/bin/_lib_codex_publication_barrier.py +277 -0
- package/bin/_lib_codex_rollout_container.py +502 -0
- package/bin/_lib_conversation_dispatch.py +3 -0
- package/bin/_lib_conversation_query.py +106 -59
- package/bin/_lib_conversation_retention.py +1605 -640
- package/bin/_lib_cost_provenance.py +53 -2
- package/bin/_lib_detector_access_registry.py +3817 -0
- package/bin/_lib_doctor.py +714 -69
- package/bin/_lib_five_hour.py +126 -0
- package/bin/_lib_fmt.py +11 -0
- package/bin/_lib_ingest_frontier.py +414 -20
- package/bin/_lib_journal.py +141 -10
- package/bin/_lib_jsonl.py +5 -4
- package/bin/_lib_legacy_spool.py +126 -0
- package/bin/_lib_legacy_writer_evidence.py +136 -0
- package/bin/_lib_merge_gate.py +231 -86
- package/bin/_lib_meter_rate_change.py +10 -0
- package/bin/_lib_outcome_quarantine.py +690 -0
- package/bin/_lib_perf.py +27 -1
- package/bin/_lib_pricing.py +199 -9
- package/bin/_lib_pricing_check.py +20 -4
- package/bin/_lib_quarantine.py +154 -0
- package/bin/_lib_quota_calibration.py +53 -8
- package/bin/_lib_quota_copy.py +2 -1
- package/bin/_lib_quota_model.py +288 -46
- package/bin/_lib_reclaim_planner.py +792 -0
- package/bin/_lib_rederive.py +319 -82
- package/bin/_lib_segment_summary.py +35 -2
- package/bin/_lib_snapshot_cache.py +21 -0
- package/bin/_lib_sqlite_close.py +81 -0
- package/bin/_lib_statusline_candidates.py +292 -23
- package/bin/_lib_subscription_weeks.py +100 -17
- package/bin/_lib_tick_stats.py +217 -17
- package/bin/_lib_wal_checkpoint.py +1077 -0
- package/bin/_lib_write_budget.py +483 -0
- package/bin/_lib_write_io.py +703 -0
- package/bin/cctally +31 -1
- package/dashboard/static/assets/{ConversationsView-CEZ-BUyi.js → ConversationsView-Bc8LCOAZ.js} +1 -1
- package/dashboard/static/assets/{DoctorModal-CE1oH_Zy.js → DoctorModal-D--qVBwe.js} +1 -1
- package/dashboard/static/assets/ModalRoot-DLPgyAM_.js +1 -0
- package/dashboard/static/assets/{ProjectsDrillPanel-B3DZoCeD.js → ProjectsDrillPanel-z53a4v4M.js} +1 -1
- package/dashboard/static/assets/{SourceDetailModal-CHp9EgLP.js → SourceDetailModal-Bw0T8W-W.js} +1 -1
- package/dashboard/static/assets/{UpdateModal-5iKST6jo.js → UpdateModal-D5AKx4Yw.js} +1 -1
- package/dashboard/static/assets/index-BKITk1CT.css +1 -0
- package/dashboard/static/assets/{index-Cm0VTxJC.js → index-BXDOzSng.js} +3 -3
- package/dashboard/static/assets/{outlineNavigation-DkXGL8J6.js → outlineNavigation-JObY7cUY.js} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +19 -1
- package/dashboard/static/assets/ModalRoot-D5Y8IFUG.js +0 -1
- package/dashboard/static/assets/index-Dt4tqf43.css +0 -1
|
@@ -43,15 +43,16 @@ All six §5.4 bullets are contract:
|
|
|
43
43
|
its first ingest attempt (so a discovered file is never lost even if its
|
|
44
44
|
first ingest is dirty/lock-contended and leaves no cursor row). A path is
|
|
45
45
|
retried while unclassified: with no committed cursor it is surfaced every
|
|
46
|
-
time it is visited; with a committed cursor it is surfaced only when its
|
|
47
|
-
|
|
48
|
-
any directory-mtime change). A path leaves the set when the driver ``reap``s
|
|
46
|
+
time it is visited; with a committed cursor it is surfaced only when its
|
|
47
|
+
signature advances beyond that cursor (an incomplete-``session_meta`` tail
|
|
48
|
+
completing without any directory-mtime change). A path leaves the set when the driver ``reap``s
|
|
49
49
|
it (it gained a thread row — child or non-child), when it vanishes, or on a
|
|
50
50
|
bounded per-candidate expiry (handed to the next full sync).
|
|
51
51
|
"""
|
|
52
52
|
from __future__ import annotations
|
|
53
53
|
|
|
54
54
|
import os
|
|
55
|
+
import _lib_codex_rollout_container as _rc
|
|
55
56
|
|
|
56
57
|
|
|
57
58
|
# ── production-default injected callables ─────────────────────────────────────
|
|
@@ -101,6 +102,48 @@ def default_file_size(filepath):
|
|
|
101
102
|
return st.st_size
|
|
102
103
|
|
|
103
104
|
|
|
105
|
+
def default_file_sig(filepath):
|
|
106
|
+
"""The selected container's live-tail signature (#968 spec 5.9).
|
|
107
|
+
|
|
108
|
+
Plain keeps today's size (as ``("plain", size)``); a compressed-only
|
|
109
|
+
rollout reports its container fingerprint instead of vanishing.
|
|
110
|
+
"""
|
|
111
|
+
return _rc.observe_watch_sig(filepath)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _as_sig(value):
|
|
115
|
+
if value is None or isinstance(value, tuple):
|
|
116
|
+
return value
|
|
117
|
+
return (_rc.PLAIN, int(value))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def codex_sig_advanced(observed, committed) -> bool:
|
|
121
|
+
"""Whether an observed signature is ahead of the committed one.
|
|
122
|
+
|
|
123
|
+
Plain grows (append-only, size decides); any other change of a compressed
|
|
124
|
+
container, or of the container kind, is a change. A bare int on either
|
|
125
|
+
side is a legacy plain size.
|
|
126
|
+
"""
|
|
127
|
+
obs, com = _as_sig(observed), _as_sig(committed)
|
|
128
|
+
if obs is None:
|
|
129
|
+
return False
|
|
130
|
+
if com is None:
|
|
131
|
+
return True
|
|
132
|
+
if obs[0] == com[0] == _rc.PLAIN:
|
|
133
|
+
return obs[1] > com[1]
|
|
134
|
+
return obs != com
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def committed_watch_sig(kind, size_bytes, container_size_bytes, mtime_ns,
|
|
138
|
+
device, inode):
|
|
139
|
+
"""The signature a cursor row's ACCEPTED container would observe: the
|
|
140
|
+
leaf's ``ContainerFingerprint.watch_sig`` shape, device included."""
|
|
141
|
+
if kind in (None, _rc.PLAIN):
|
|
142
|
+
return (_rc.PLAIN, int(size_bytes or 0))
|
|
143
|
+
return (_rc.ZSTD, int(container_size_bytes or 0), int(mtime_ns or 0),
|
|
144
|
+
int(device or 0), int(inode or 0))
|
|
145
|
+
|
|
146
|
+
|
|
104
147
|
_SENTINEL = object()
|
|
105
148
|
|
|
106
149
|
|
|
@@ -118,7 +161,7 @@ class CodexChildFrontier:
|
|
|
118
161
|
pending_expiry_cycles: int = 50,
|
|
119
162
|
scandir_fn=default_scandir,
|
|
120
163
|
dir_mtime_fn=default_dir_mtime,
|
|
121
|
-
file_size_fn=
|
|
164
|
+
file_size_fn=default_file_sig,
|
|
122
165
|
clock_fn=None,
|
|
123
166
|
):
|
|
124
167
|
self._walk_root = str(walk_root)
|
|
@@ -175,7 +218,7 @@ class CodexChildFrontier:
|
|
|
175
218
|
|
|
176
219
|
``known_paths`` — every path already tracked in ``codex_session_files``,
|
|
177
220
|
so a newly enumerated file already known to some table is not
|
|
178
|
-
re-registered. ``committed_sizes`` — ``{path:
|
|
221
|
+
re-registered. ``committed_sizes`` — ``{path: signature}`` committed
|
|
179
222
|
cursors, for the pending-growth check.
|
|
180
223
|
|
|
181
224
|
Returns the set of candidate paths to targeted-ingest this cycle (new
|
|
@@ -251,8 +294,8 @@ class CodexChildFrontier:
|
|
|
251
294
|
mtime as the dir's baseline (so a create landing during the enumeration
|
|
252
295
|
is re-examined next rotation), skips symlinked descendants (the
|
|
253
296
|
``follow_symlinks=False`` boundary), adds real subdirectories to the
|
|
254
|
-
frontier, and registers brand-new
|
|
255
|
-
their first ingest — surfacing each for that first ingest attempt in the
|
|
297
|
+
frontier, and registers brand-new logical rollout files in ``pending``
|
|
298
|
+
BEFORE their first ingest — surfacing each for that first ingest attempt in the
|
|
256
299
|
SAME cycle it is discovered (the pending sweep then owns the retries).
|
|
257
300
|
Returns True iff it consumed a filesystem op."""
|
|
258
301
|
if not self._enum_queue:
|
|
@@ -267,10 +310,12 @@ class CodexChildFrontier:
|
|
|
267
310
|
if path not in self._dir_set:
|
|
268
311
|
self._dir_set.add(path)
|
|
269
312
|
self._dirs.append(path) # new subdir joins frontier
|
|
270
|
-
elif
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
313
|
+
elif _rc.is_container_name(name):
|
|
314
|
+
# Both spellings enumerate one logical identity.
|
|
315
|
+
logical = os.fspath(_rc.logical_path(path))
|
|
316
|
+
if logical not in self._known and logical not in self._pending:
|
|
317
|
+
self._pending[logical] = 0 # register BEFORE first ingest
|
|
318
|
+
ingest.add(logical) # first ingest attempt now
|
|
274
319
|
return True
|
|
275
320
|
|
|
276
321
|
def _op_pending(self, ingest: set) -> bool:
|
|
@@ -286,12 +331,12 @@ class CodexChildFrontier:
|
|
|
286
331
|
p = keys[idx]
|
|
287
332
|
if p not in self._pending: # reaped/dropped mid-cycle
|
|
288
333
|
continue
|
|
289
|
-
|
|
290
|
-
if
|
|
334
|
+
sig = self._file_size(p) # 1 signature probe
|
|
335
|
+
if sig is None:
|
|
291
336
|
self._pending.pop(p, None) # vanished → drop
|
|
292
337
|
else:
|
|
293
338
|
committed = self._committed.get(p)
|
|
294
|
-
if
|
|
339
|
+
if codex_sig_advanced(sig, committed):
|
|
295
340
|
ingest.add(p)
|
|
296
341
|
return True
|
|
297
342
|
return False
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Waiting requests and the drain-admission gate (#968 spec 5.7, 5.12).
|
|
2
|
+
|
|
3
|
+
A Codex hook never waits for a lock. When it meets cache contention it
|
|
4
|
+
publishes a uniquely named, immutable request record under the data
|
|
5
|
+
directory (outside every SQLite store, because a contended hook cannot write
|
|
6
|
+
cache.db). Drain workers — the prune executor and the container-verification
|
|
7
|
+
executor — admit one identity at a time through ONE global gate and yield
|
|
8
|
+
while any request is fresh:
|
|
9
|
+
|
|
10
|
+
1. check for fresh requests;
|
|
11
|
+
2. acquire the drain gate (non-blocking), then check again;
|
|
12
|
+
3. try the store's complete lock domain without blocking, then check again;
|
|
13
|
+
4. a fresh request at any check releases everything acquired and yields;
|
|
14
|
+
5. otherwise the caller executes one identity, commits, releases every store
|
|
15
|
+
lock and the gate (``finish``), and starts again at step 1.
|
|
16
|
+
|
|
17
|
+
Requests end only by expiry: 60 s after publication on a cross-process
|
|
18
|
+
monotonic clock. A worker observing a request, or restarting, never renews it;
|
|
19
|
+
a wall-clock jump cannot extend it (the wall age only shortens freshness, and
|
|
20
|
+
a NEGATIVE wall age — an mtime ahead of the wall clock after a backward step —
|
|
21
|
+
counts as fresh, bounded by the monotonic lifetime); a malformed record or one
|
|
22
|
+
from an incompatible clock is expired and removed. Stdlib only; no cctally
|
|
23
|
+
imports.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import fcntl
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import pathlib
|
|
31
|
+
import time
|
|
32
|
+
import uuid
|
|
33
|
+
from typing import Callable, NamedTuple, Optional
|
|
34
|
+
|
|
35
|
+
REQUEST_DIR_NAME = "codex-drain-requests"
|
|
36
|
+
REQUEST_TTL_NS = 60_000_000_000
|
|
37
|
+
WALL_GUARD_SECONDS = 120.0
|
|
38
|
+
GATE_FILE_NAME = "codex-drain-admission.lock"
|
|
39
|
+
POLL_SECONDS = 5.0
|
|
40
|
+
_RECORD_VERSION = 1
|
|
41
|
+
|
|
42
|
+
# Module attributes so tests can drive a deterministic schedule.
|
|
43
|
+
_monotonic_ns: Callable[[], int] = time.monotonic_ns
|
|
44
|
+
_sleep: Callable[[float], None] = time.sleep
|
|
45
|
+
#: Post-release seam: called by ``finish`` after the store locks AND the gate
|
|
46
|
+
#: are released. Production leaves it None; the fairness test synchronizes on
|
|
47
|
+
#: it instead of sleeping (review P2-7).
|
|
48
|
+
_after_finish: Optional[Callable[[], None]] = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _request_dir(app_dir) -> pathlib.Path:
|
|
52
|
+
return pathlib.Path(app_dir) / REQUEST_DIR_NAME
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def publish_waiting_request(app_dir, *, root_keys) -> "str | None":
|
|
56
|
+
"""Atomically publish one immutable request; return its id or None.
|
|
57
|
+
|
|
58
|
+
``None`` is a protocol failure the hook records; it is never fairness
|
|
59
|
+
evidence and never raises.
|
|
60
|
+
"""
|
|
61
|
+
try:
|
|
62
|
+
directory = _request_dir(app_dir)
|
|
63
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
mono = int(_monotonic_ns())
|
|
65
|
+
request_id = f"{mono}-{os.getpid()}-{uuid.uuid4().hex}"
|
|
66
|
+
final = directory / f"req-{request_id}.json"
|
|
67
|
+
tmp = directory / f".tmp-{request_id}"
|
|
68
|
+
payload = json.dumps({
|
|
69
|
+
"v": _RECORD_VERSION, "id": request_id,
|
|
70
|
+
"roots": sorted(str(k) for k in root_keys), "mono_ns": mono,
|
|
71
|
+
}, separators=(",", ":"))
|
|
72
|
+
with open(tmp, "x", encoding="utf-8") as fh:
|
|
73
|
+
fh.write(payload)
|
|
74
|
+
fh.flush()
|
|
75
|
+
os.fsync(fh.fileno())
|
|
76
|
+
os.rename(tmp, final)
|
|
77
|
+
return request_id
|
|
78
|
+
except OSError:
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _is_fresh(path: pathlib.Path, now_mono: int, now_wall: float) -> bool:
|
|
83
|
+
try:
|
|
84
|
+
record = json.loads(path.read_text(encoding="utf-8"))
|
|
85
|
+
mono = int(record["mono_ns"])
|
|
86
|
+
if record.get("v") != _RECORD_VERSION:
|
|
87
|
+
return False
|
|
88
|
+
wall_age = now_wall - path.stat().st_mtime
|
|
89
|
+
except (OSError, ValueError, TypeError, KeyError):
|
|
90
|
+
return False
|
|
91
|
+
age = now_mono - mono
|
|
92
|
+
# A negative monotonic age is an incompatible clock: expired. A negative
|
|
93
|
+
# WALL age is only an mtime ahead of the wall clock, which is not evidence
|
|
94
|
+
# of staleness: it stays fresh, and the monotonic bound still ends it
|
|
95
|
+
# within the single 60 s lifetime (review P3-5).
|
|
96
|
+
return 0 <= age < REQUEST_TTL_NS and wall_age < WALL_GUARD_SECONDS
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def fresh_requests(app_dir) -> "list[str]":
|
|
100
|
+
"""Ids of fresh requests; expired or malformed records are removed one by one."""
|
|
101
|
+
directory = _request_dir(app_dir)
|
|
102
|
+
try:
|
|
103
|
+
entries = sorted(directory.iterdir())
|
|
104
|
+
except OSError:
|
|
105
|
+
return []
|
|
106
|
+
now_mono = int(_monotonic_ns())
|
|
107
|
+
now_wall = time.time()
|
|
108
|
+
fresh: list[str] = []
|
|
109
|
+
for path in entries:
|
|
110
|
+
name = path.name
|
|
111
|
+
if name.startswith(".tmp-"):
|
|
112
|
+
try:
|
|
113
|
+
if now_wall - path.stat().st_mtime >= WALL_GUARD_SECONDS:
|
|
114
|
+
path.unlink()
|
|
115
|
+
except OSError:
|
|
116
|
+
pass
|
|
117
|
+
continue
|
|
118
|
+
if not (name.startswith("req-") and name.endswith(".json")):
|
|
119
|
+
continue
|
|
120
|
+
if _is_fresh(path, now_mono, now_wall):
|
|
121
|
+
fresh.append(name[len("req-"):-len(".json")])
|
|
122
|
+
else:
|
|
123
|
+
try:
|
|
124
|
+
path.unlink()
|
|
125
|
+
except OSError:
|
|
126
|
+
pass
|
|
127
|
+
return fresh
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class DrainGate:
|
|
131
|
+
"""The one global, non-blocking drain-admission flock."""
|
|
132
|
+
|
|
133
|
+
def __init__(self, app_dir):
|
|
134
|
+
self._path = pathlib.Path(app_dir) / GATE_FILE_NAME
|
|
135
|
+
self._fd: "int | None" = None
|
|
136
|
+
|
|
137
|
+
def try_acquire(self) -> bool:
|
|
138
|
+
if self._fd is not None:
|
|
139
|
+
return True
|
|
140
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
fd = os.open(self._path, os.O_RDWR | os.O_CREAT, 0o600)
|
|
142
|
+
try:
|
|
143
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
144
|
+
except OSError:
|
|
145
|
+
os.close(fd)
|
|
146
|
+
return False
|
|
147
|
+
self._fd = fd
|
|
148
|
+
return True
|
|
149
|
+
|
|
150
|
+
def release(self) -> None:
|
|
151
|
+
if self._fd is None:
|
|
152
|
+
return
|
|
153
|
+
try:
|
|
154
|
+
fcntl.flock(self._fd, fcntl.LOCK_UN)
|
|
155
|
+
finally:
|
|
156
|
+
os.close(self._fd)
|
|
157
|
+
self._fd = None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class Admission(NamedTuple):
|
|
161
|
+
admitted: bool
|
|
162
|
+
store_locks: object
|
|
163
|
+
gate: "DrainGate | None"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def admit(app_dir, try_store_locks: Callable[[], object],
|
|
167
|
+
release_store_locks: Callable[[object], None], *,
|
|
168
|
+
on_check: Optional[Callable[[str], None]] = None) -> Admission:
|
|
169
|
+
"""Steps 1-4 of the protocol. ``try_store_locks`` must never block and
|
|
170
|
+
returns a token, or None on contention."""
|
|
171
|
+
def check(stage: str) -> bool:
|
|
172
|
+
if on_check is not None:
|
|
173
|
+
on_check(stage)
|
|
174
|
+
return bool(fresh_requests(app_dir))
|
|
175
|
+
|
|
176
|
+
if check("before-gate"):
|
|
177
|
+
return Admission(False, None, None)
|
|
178
|
+
gate = DrainGate(app_dir)
|
|
179
|
+
if not gate.try_acquire():
|
|
180
|
+
return Admission(False, None, None)
|
|
181
|
+
if check("after-gate"):
|
|
182
|
+
gate.release()
|
|
183
|
+
return Admission(False, None, None)
|
|
184
|
+
token = try_store_locks()
|
|
185
|
+
if token is None:
|
|
186
|
+
gate.release()
|
|
187
|
+
return Admission(False, None, None)
|
|
188
|
+
if check("after-store-locks"):
|
|
189
|
+
release_store_locks(token)
|
|
190
|
+
gate.release()
|
|
191
|
+
return Admission(False, None, None)
|
|
192
|
+
return Admission(True, token, gate)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def finish(admission: Admission,
|
|
196
|
+
release_store_locks: Callable[[object], None]) -> None:
|
|
197
|
+
"""Step 5: release every store lock, then the gate."""
|
|
198
|
+
try:
|
|
199
|
+
if admission.store_locks is not None:
|
|
200
|
+
release_store_locks(admission.store_locks)
|
|
201
|
+
finally:
|
|
202
|
+
if admission.gate is not None:
|
|
203
|
+
admission.gate.release()
|
|
204
|
+
if _after_finish is not None:
|
|
205
|
+
_after_finish()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def wait_until_no_fresh_requests(
|
|
209
|
+
app_dir, *, should_continue: Optional[Callable[[], bool]] = None) -> None:
|
|
210
|
+
"""Sleep in bounded steps until no request is fresh (requests expire)."""
|
|
211
|
+
while fresh_requests(app_dir):
|
|
212
|
+
if should_continue is not None and not should_continue():
|
|
213
|
+
return
|
|
214
|
+
_sleep(POLL_SECONDS)
|
|
@@ -26,6 +26,8 @@ import sqlite3
|
|
|
26
26
|
import threading
|
|
27
27
|
from collections import OrderedDict
|
|
28
28
|
|
|
29
|
+
from _lib_codex_publication_barrier import LAST_GOOD_THREADS_VIEW
|
|
30
|
+
|
|
29
31
|
|
|
30
32
|
#: The physical name of the raw threads table. ``resolve_codex_threads_table``
|
|
31
33
|
#: returns it qualified by whichever schema actually holds it.
|
|
@@ -185,6 +187,8 @@ def _schema_holds_threads_table(conn: sqlite3.Connection, schema: str) -> bool:
|
|
|
185
187
|
def resolve_codex_threads_table(conn: sqlite3.Connection) -> str:
|
|
186
188
|
"""The RAW threads table reference for this connection.
|
|
187
189
|
|
|
190
|
+
A conversations connection carrying the #968 last-good view resolves to it.
|
|
191
|
+
|
|
188
192
|
A bare cache connection (``open_cache_db()``) holds the table in its own
|
|
189
193
|
``main`` schema; a conversations connection
|
|
190
194
|
(``open_conversations_db``/``open_conversations_db_readonly``) reaches it
|
|
@@ -222,8 +226,21 @@ def resolve_codex_threads_table(conn: sqlite3.Connection) -> str:
|
|
|
222
226
|
_threads_table_resolution_cache.move_to_end(conn)
|
|
223
227
|
return cached
|
|
224
228
|
|
|
229
|
+
# One metadata SELECT preserves last-good priority without adding a
|
|
230
|
+
# fifth query to the bounded accounting/metadata-health read contracts.
|
|
231
|
+
schema = conn.execute(
|
|
232
|
+
"SELECT CASE "
|
|
233
|
+
"WHEN EXISTS (SELECT 1 FROM sqlite_temp_master "
|
|
234
|
+
"WHERE type='view' AND name=?) THEN 'temp' "
|
|
235
|
+
"WHEN EXISTS (SELECT 1 FROM main.sqlite_master "
|
|
236
|
+
"WHERE type='table' AND name=?) THEN 'main' END",
|
|
237
|
+
(LAST_GOOD_THREADS_VIEW, CODEX_THREADS_TABLE),
|
|
238
|
+
).fetchone()[0]
|
|
225
239
|
resolved = None
|
|
226
|
-
if
|
|
240
|
+
if schema == "temp":
|
|
241
|
+
# #968 spec 5.11: unpublished metadata never overrides last-good.
|
|
242
|
+
resolved = f"temp.{LAST_GOOD_THREADS_VIEW}"
|
|
243
|
+
elif schema == "main":
|
|
227
244
|
resolved = f"main.{CODEX_THREADS_TABLE}"
|
|
228
245
|
else:
|
|
229
246
|
attached = tuple(conn.execute("PRAGMA database_list"))
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Pure decisions of the #968 conversation publication barrier (spec 5.11).
|
|
2
|
+
|
|
3
|
+
No I/O and no SQLite. `_cctally_cache` owns the durable cache.db state (the
|
|
4
|
+
membership index, readiness targets, population, certificates and repair
|
|
5
|
+
queue) and the conversation pass owns the publication records; both call these
|
|
6
|
+
functions with plain values, so the readiness rule, the thread-metadata rule
|
|
7
|
+
and the certificate format are stated exactly once.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from typing import Iterable, NamedTuple, Optional
|
|
13
|
+
|
|
14
|
+
CERTIFICATE_VERSION = 1
|
|
15
|
+
|
|
16
|
+
READY = "ready"
|
|
17
|
+
REQUIRED = "required"
|
|
18
|
+
SUSPENDED = "suspended"
|
|
19
|
+
|
|
20
|
+
#: The pending hold (5.11 U6 table, first row): the candidate stays a
|
|
21
|
+
#: dependency of the next revision; pending work is never an exclusion.
|
|
22
|
+
PENDING_HOLD = "verification-pending"
|
|
23
|
+
#: Holds that suspend the contributor's current-generation readiness
|
|
24
|
+
#: obligation (U6 table, second row). Retained accepted membership is kept;
|
|
25
|
+
#: "excluded" never means deleting rows or forgetting membership.
|
|
26
|
+
#: `container-absent` is an accounted identity whose container is gone (an
|
|
27
|
+
#: orphan awaiting the off-hook prune, or one #485 refuses to prune).
|
|
28
|
+
SUSPENSION_HOLDS = frozenset({
|
|
29
|
+
"decoder-unavailable", "decode-error", "verification-unverified",
|
|
30
|
+
"container-absent",
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
#: The twelve source columns of a `codex_conversation_threads` row, in column
|
|
34
|
+
#: order. The two timestamp columns are bookkeeping, carried separately.
|
|
35
|
+
THREAD_FIELDS = (
|
|
36
|
+
"conversation_key", "source_root_key", "native_thread_id",
|
|
37
|
+
"root_thread_id", "parent_thread_id", "source_path", "cwd", "git_json",
|
|
38
|
+
"source_kind", "thread_source_json", "model_provider", "context_window",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
#: The generation token of a membership row that records a key a suspended
|
|
42
|
+
#: contributor of unknown membership (no watermark) is KNOWN to feed, from its
|
|
43
|
+
#: retained rows (review N2). Never equal to a real generation token, so it
|
|
44
|
+
#: never feeds a READY contributor; it is such an exclusion's effective
|
|
45
|
+
#: generation, and the row carries the key's retained thread as metadata when
|
|
46
|
+
#: that row names this contributor (5.11 U6 "using retained accepted facts
|
|
47
|
+
#: where they exist"). The rows are deleted when the membership is proven.
|
|
48
|
+
RETAINED_GENERATION = "retained"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Target(NamedTuple):
|
|
52
|
+
"""One contributor's readiness target (a `codex_barrier_targets` row)."""
|
|
53
|
+
identity: str
|
|
54
|
+
source_path: str
|
|
55
|
+
generation: str
|
|
56
|
+
#: Offset after the last newline-terminated line of the captured target,
|
|
57
|
+
#: set once accounting reached that target; None while it has not.
|
|
58
|
+
complete_line_end: Optional[int]
|
|
59
|
+
hold: Optional[str]
|
|
60
|
+
#: Generation and decoded offset through which the production parser has
|
|
61
|
+
#: recorded this identity's membership from offset 0.
|
|
62
|
+
watermark_generation: Optional[str]
|
|
63
|
+
watermark_offset: Optional[int]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TargetVerdict(NamedTuple):
|
|
67
|
+
state: str
|
|
68
|
+
reason: Optional[str]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def classify_target(target: Target) -> TargetVerdict:
|
|
72
|
+
"""Readiness of one contributor (5.11 "The readiness target", U6).
|
|
73
|
+
|
|
74
|
+
A watermark of another generation proves nothing, whatever its offset
|
|
75
|
+
(968-SR-012: a G0 cursor never satisfies G1's target). A suspension is
|
|
76
|
+
SUSPENDED whether or not its membership was ever proven (U6: "degraded
|
|
77
|
+
contributors degrade; they do not freeze", review N2): one of unknown
|
|
78
|
+
membership is an exclusion that blocks no key, and its keys keep the
|
|
79
|
+
thread its retained facts supply (``decide_seal``).
|
|
80
|
+
"""
|
|
81
|
+
if target.hold in SUSPENSION_HOLDS:
|
|
82
|
+
return TargetVerdict(SUSPENDED, target.hold)
|
|
83
|
+
if target.hold == PENDING_HOLD:
|
|
84
|
+
return TargetVerdict(REQUIRED, PENDING_HOLD)
|
|
85
|
+
if target.hold is not None:
|
|
86
|
+
raise ValueError(f"unknown barrier hold {target.hold!r}")
|
|
87
|
+
if (target.watermark_generation != target.generation
|
|
88
|
+
or target.watermark_offset is None):
|
|
89
|
+
return TargetVerdict(REQUIRED, "membership-unproven")
|
|
90
|
+
if target.complete_line_end is None:
|
|
91
|
+
return TargetVerdict(REQUIRED, "accounting-incomplete")
|
|
92
|
+
if target.watermark_offset < target.complete_line_end:
|
|
93
|
+
return TargetVerdict(REQUIRED, "membership-unproven")
|
|
94
|
+
return TargetVerdict(READY, None)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class MemberFact(NamedTuple):
|
|
98
|
+
"""One `codex_barrier_membership` row."""
|
|
99
|
+
identity: str
|
|
100
|
+
generation: str
|
|
101
|
+
source_path: str
|
|
102
|
+
metadata_json: Optional[str]
|
|
103
|
+
metadata_offset: Optional[int]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def metadata_from_thread(thread) -> str:
|
|
107
|
+
"""The generation-bound metadata one `session_meta` supplies (JSON).
|
|
108
|
+
|
|
109
|
+
``ensure_ascii`` escapes a lone surrogate (#845's undecodable cwd), and
|
|
110
|
+
``json.loads`` restores the identical string.
|
|
111
|
+
"""
|
|
112
|
+
return json.dumps({name: getattr(thread, name) for name in THREAD_FIELDS},
|
|
113
|
+
sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def metadata_from_row(row: dict) -> str:
|
|
117
|
+
"""``metadata_from_thread``'s JSON for a retained thread row (a dict)."""
|
|
118
|
+
return json.dumps({name: row.get(name) for name in THREAD_FIELDS},
|
|
119
|
+
sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def select_thread(candidates: Iterable[MemberFact], *,
|
|
123
|
+
retained: Optional[dict] = None) -> Optional[dict]:
|
|
124
|
+
"""The conversation's thread metadata.
|
|
125
|
+
|
|
126
|
+
Review P2-2: when the retained thread row (the entry snapshot, else the
|
|
127
|
+
current accounting row) equals the metadata some candidate supplies, it
|
|
128
|
+
is kept, so certifying a legacy conversation changes nothing visible.
|
|
129
|
+
Measured on the frozen 2026-10-14 baseline: of 243 multi-file keys the
|
|
130
|
+
stored row equals the maximum-path choice for 236 and another
|
|
131
|
+
contributor's metadata for 7. Otherwise (a new publication, or a retained
|
|
132
|
+
row no contributor supplies) the rule is what a complete walk writes
|
|
133
|
+
LAST: a full walk visits a root's rollouts in logical-path order and a
|
|
134
|
+
file's records in offset order, so the last writer is the maximum of
|
|
135
|
+
``(source_path, metadata_offset)``, independent of the order in which
|
|
136
|
+
healing reads the files.
|
|
137
|
+
"""
|
|
138
|
+
facts = [f for f in candidates
|
|
139
|
+
if f.metadata_json is not None and f.metadata_offset is not None]
|
|
140
|
+
if retained is not None:
|
|
141
|
+
want = {name: retained.get(name) for name in THREAD_FIELDS}
|
|
142
|
+
for fact in facts:
|
|
143
|
+
meta = json.loads(fact.metadata_json)
|
|
144
|
+
if {name: meta.get(name) for name in THREAD_FIELDS} == want:
|
|
145
|
+
return meta
|
|
146
|
+
best = None
|
|
147
|
+
for fact in facts:
|
|
148
|
+
rank = (fact.source_path, int(fact.metadata_offset))
|
|
149
|
+
if best is None or rank > best[0]:
|
|
150
|
+
best = (rank, fact.metadata_json)
|
|
151
|
+
return None if best is None else json.loads(best[1])
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class SealDecision(NamedTuple):
|
|
155
|
+
sealable: bool
|
|
156
|
+
blocking: tuple
|
|
157
|
+
certificate: Optional[dict]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def decide_seal(conversation_key: str, revision: int,
|
|
161
|
+
targets: Iterable[Target], members: Iterable[MemberFact], *,
|
|
162
|
+
timestamps: "tuple[str | None, str | None] | None" = None,
|
|
163
|
+
retained_thread: Optional[dict] = None,
|
|
164
|
+
) -> SealDecision:
|
|
165
|
+
"""Seal a certificate for one conversation, or name what blocks it.
|
|
166
|
+
|
|
167
|
+
``targets`` holds every identity with any membership row for the key
|
|
168
|
+
(``RETAINED_GENERATION`` rows included); ``members`` those rows. A
|
|
169
|
+
REQUIRED contributor blocks (the transient pending hold included); a
|
|
170
|
+
SUSPENDED one is an explicit exclusion whose retained accepted facts
|
|
171
|
+
still supply metadata (U6): its watermark generation's rows when its
|
|
172
|
+
membership was proven, else its ``RETAINED_GENERATION`` rows (review N2).
|
|
173
|
+
``retained_thread`` is the key's retained thread row: preferred when a
|
|
174
|
+
candidate supplies it (P2-2), and kept when an exclusion leaves no
|
|
175
|
+
metadata, so a seal never retracts a published thread to None because of
|
|
176
|
+
an excluded contributor (P1-1, N2).
|
|
177
|
+
"""
|
|
178
|
+
by_identity = {t.identity: t for t in targets}
|
|
179
|
+
verdicts = {i: classify_target(t) for i, t in by_identity.items()}
|
|
180
|
+
blocking = tuple(sorted((i, v.reason) for i, v in verdicts.items()
|
|
181
|
+
if v.state == REQUIRED))
|
|
182
|
+
if blocking:
|
|
183
|
+
return SealDecision(False, blocking, None)
|
|
184
|
+
facts = list(members)
|
|
185
|
+
contributors, exclusions, candidates = [], [], []
|
|
186
|
+
for identity in sorted(by_identity):
|
|
187
|
+
target, verdict = by_identity[identity], verdicts[identity]
|
|
188
|
+
if verdict.state == READY:
|
|
189
|
+
contributors.append({
|
|
190
|
+
"identity": identity, "path": target.source_path,
|
|
191
|
+
"generation": target.generation,
|
|
192
|
+
"completeLineEnd": int(target.complete_line_end)})
|
|
193
|
+
effective = target.generation
|
|
194
|
+
else:
|
|
195
|
+
exclusions.append({
|
|
196
|
+
"identity": identity, "path": target.source_path,
|
|
197
|
+
"reason": verdict.reason,
|
|
198
|
+
"acceptedGeneration": target.watermark_generation})
|
|
199
|
+
effective = (target.watermark_generation
|
|
200
|
+
if target.watermark_generation is not None
|
|
201
|
+
else RETAINED_GENERATION)
|
|
202
|
+
candidates.extend(f for f in facts if f.identity == identity
|
|
203
|
+
and f.generation == effective)
|
|
204
|
+
thread = select_thread(candidates, retained=retained_thread)
|
|
205
|
+
if thread is None and retained_thread is not None and exclusions:
|
|
206
|
+
# U6 / reviews P1-1, N2: an unreadable contributor never changes an
|
|
207
|
+
# existing publication on its own, and it never freezes the key.
|
|
208
|
+
# When no readable contributor and no retained fact supplies a
|
|
209
|
+
# thread, the retained one is kept. Thread None is sealed only when
|
|
210
|
+
# every contributor is readable and none supplies metadata, or when
|
|
211
|
+
# there is no retained thread to retract.
|
|
212
|
+
thread = {name: retained_thread.get(name) for name in THREAD_FIELDS}
|
|
213
|
+
if thread is not None and timestamps is not None:
|
|
214
|
+
thread = dict(thread, firstSeenUtc=timestamps[0],
|
|
215
|
+
lastSeenUtc=timestamps[1])
|
|
216
|
+
return SealDecision(True, (), {
|
|
217
|
+
"version": CERTIFICATE_VERSION,
|
|
218
|
+
"conversationKey": conversation_key,
|
|
219
|
+
"revision": int(revision),
|
|
220
|
+
"contributors": contributors,
|
|
221
|
+
"exclusions": exclusions,
|
|
222
|
+
"thread": thread,
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def certificate_json(cert: dict) -> str:
|
|
227
|
+
return json.dumps(cert, sort_keys=True, separators=(",", ":"),
|
|
228
|
+
ensure_ascii=True)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def parse_certificate(text) -> Optional[dict]:
|
|
232
|
+
try:
|
|
233
|
+
cert = json.loads(text)
|
|
234
|
+
except (TypeError, ValueError):
|
|
235
|
+
return None
|
|
236
|
+
if not isinstance(cert, dict) or cert.get("version") != CERTIFICATE_VERSION:
|
|
237
|
+
return None
|
|
238
|
+
return cert
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def certificate_covers(cert: dict, source_path: str,
|
|
242
|
+
generation: str) -> Optional[int]:
|
|
243
|
+
"""The certified complete-line end for this file at this generation."""
|
|
244
|
+
for contributor in cert.get("contributors", ()):
|
|
245
|
+
if (contributor.get("path") == source_path
|
|
246
|
+
and contributor.get("generation") == generation):
|
|
247
|
+
return int(contributor["completeLineEnd"])
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def certificate_excludes(cert: dict, source_path: str) -> bool:
|
|
252
|
+
return any(e.get("path") == source_path for e in cert.get("exclusions", ()))
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def publication_advances(published_revision, candidate_revision) -> bool:
|
|
256
|
+
"""Monotonic publication: only a strictly newer revision may publish."""
|
|
257
|
+
return int(candidate_revision) > int(published_revision or 0)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
#: cache.db ``cache_meta``: a fresh token written by every seal batch that
|
|
261
|
+
#: sealed anything (a token, not a counter: a deleted cache.db must never
|
|
262
|
+
#: repeat a value a conversations store has already acknowledged).
|
|
263
|
+
SEAL_SEQ_KEY = "codex_barrier_seal_seq"
|
|
264
|
+
#: conversations.db ``cache_meta``: the seal token whose certificates a
|
|
265
|
+
#: complete publication phase has considered.
|
|
266
|
+
PUBLISHED_SEQ_KEY = "codex_publication_seal_seq"
|
|
267
|
+
#: The TEMP view every conversations connection carries (5.11 "Reader
|
|
268
|
+
#: admission: last-good"); ``codex_conversation_threads`` shadows to it.
|
|
269
|
+
LAST_GOOD_THREADS_VIEW = "codex_last_good_threads"
|
|
270
|
+
#: Certificates newer than their publication, or never published. Run on a
|
|
271
|
+
#: conversations connection with cache.db attached as ``cache_db``.
|
|
272
|
+
BACKLOG_CERTIFICATES_SQL = (
|
|
273
|
+
"SELECT c.conversation_key, c.revision, c.certificate_json "
|
|
274
|
+
"FROM cache_db.codex_barrier_certificates c "
|
|
275
|
+
"LEFT JOIN main.codex_conversation_publications p "
|
|
276
|
+
"ON p.conversation_key = c.conversation_key "
|
|
277
|
+
"WHERE p.conversation_key IS NULL OR c.revision > p.published_revision")
|