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.
Files changed (97) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +4 -6
  3. package/bin/_cctally_account.py +32 -8
  4. package/bin/_cctally_cache.py +6035 -722
  5. package/bin/_cctally_completion.py +1287 -0
  6. package/bin/_cctally_config.py +37 -0
  7. package/bin/_cctally_core.py +400 -2
  8. package/bin/_cctally_dashboard.py +98 -20
  9. package/bin/_cctally_dashboard_conversation.py +292 -23
  10. package/bin/_cctally_dashboard_envelope.py +16 -3
  11. package/bin/_cctally_dashboard_perf.py +128 -9
  12. package/bin/_cctally_dashboard_sources.py +285 -213
  13. package/bin/_cctally_db.py +1177 -23
  14. package/bin/_cctally_diagnosis_sources.py +22 -4
  15. package/bin/_cctally_doctor.py +572 -45
  16. package/bin/_cctally_five_hour.py +84 -33
  17. package/bin/_cctally_forecast.py +108 -15
  18. package/bin/_cctally_journal.py +3950 -116
  19. package/bin/_cctally_journal_repair.py +101 -12
  20. package/bin/_cctally_milestone_history.py +82 -9
  21. package/bin/_cctally_parser.py +42 -0
  22. package/bin/_cctally_percent_breakdown.py +3 -2
  23. package/bin/_cctally_project.py +8 -2
  24. package/bin/_cctally_quarantine.py +652 -0
  25. package/bin/_cctally_quota.py +227 -37
  26. package/bin/_cctally_quota_model.py +360 -83
  27. package/bin/_cctally_record.py +691 -149
  28. package/bin/_cctally_rederive.py +320 -41
  29. package/bin/_cctally_refresh.py +84 -13
  30. package/bin/_cctally_source_analytics.py +83 -17
  31. package/bin/_cctally_statusline.py +6154 -517
  32. package/bin/_cctally_store.py +367 -53
  33. package/bin/_cctally_transcript.py +8 -7
  34. package/bin/_cctally_tui.py +61 -56
  35. package/bin/_cctally_weekrefs.py +128 -21
  36. package/bin/_lib_account_binding.py +152 -0
  37. package/bin/_lib_account_protocol.py +386 -0
  38. package/bin/_lib_aggregators.py +4 -0
  39. package/bin/_lib_cache_coverage.py +13 -1
  40. package/bin/_lib_cache_report.py +35 -6
  41. package/bin/_lib_cache_writer_lock.py +19 -3
  42. package/bin/_lib_clone_copy.py +142 -0
  43. package/bin/_lib_codex_conversation.py +32 -0
  44. package/bin/_lib_codex_conversation_query.py +297 -49
  45. package/bin/_lib_codex_conversation_watch.py +59 -14
  46. package/bin/_lib_codex_drain_admission.py +214 -0
  47. package/bin/_lib_codex_metadata.py +18 -1
  48. package/bin/_lib_codex_publication_barrier.py +277 -0
  49. package/bin/_lib_codex_rollout_container.py +502 -0
  50. package/bin/_lib_conversation_dispatch.py +3 -0
  51. package/bin/_lib_conversation_query.py +106 -59
  52. package/bin/_lib_conversation_retention.py +1605 -640
  53. package/bin/_lib_cost_provenance.py +53 -2
  54. package/bin/_lib_detector_access_registry.py +3817 -0
  55. package/bin/_lib_doctor.py +714 -69
  56. package/bin/_lib_five_hour.py +126 -0
  57. package/bin/_lib_fmt.py +11 -0
  58. package/bin/_lib_ingest_frontier.py +414 -20
  59. package/bin/_lib_journal.py +141 -10
  60. package/bin/_lib_jsonl.py +5 -4
  61. package/bin/_lib_legacy_spool.py +126 -0
  62. package/bin/_lib_legacy_writer_evidence.py +136 -0
  63. package/bin/_lib_merge_gate.py +231 -86
  64. package/bin/_lib_meter_rate_change.py +10 -0
  65. package/bin/_lib_outcome_quarantine.py +690 -0
  66. package/bin/_lib_perf.py +27 -1
  67. package/bin/_lib_pricing.py +199 -9
  68. package/bin/_lib_pricing_check.py +20 -4
  69. package/bin/_lib_quarantine.py +154 -0
  70. package/bin/_lib_quota_calibration.py +53 -8
  71. package/bin/_lib_quota_copy.py +2 -1
  72. package/bin/_lib_quota_model.py +288 -46
  73. package/bin/_lib_reclaim_planner.py +792 -0
  74. package/bin/_lib_rederive.py +319 -82
  75. package/bin/_lib_segment_summary.py +35 -2
  76. package/bin/_lib_snapshot_cache.py +21 -0
  77. package/bin/_lib_sqlite_close.py +81 -0
  78. package/bin/_lib_statusline_candidates.py +292 -23
  79. package/bin/_lib_subscription_weeks.py +100 -17
  80. package/bin/_lib_tick_stats.py +217 -17
  81. package/bin/_lib_wal_checkpoint.py +1077 -0
  82. package/bin/_lib_write_budget.py +483 -0
  83. package/bin/_lib_write_io.py +703 -0
  84. package/bin/cctally +31 -1
  85. package/dashboard/static/assets/{ConversationsView-CEZ-BUyi.js → ConversationsView-Bc8LCOAZ.js} +1 -1
  86. package/dashboard/static/assets/{DoctorModal-CE1oH_Zy.js → DoctorModal-D--qVBwe.js} +1 -1
  87. package/dashboard/static/assets/ModalRoot-DLPgyAM_.js +1 -0
  88. package/dashboard/static/assets/{ProjectsDrillPanel-B3DZoCeD.js → ProjectsDrillPanel-z53a4v4M.js} +1 -1
  89. package/dashboard/static/assets/{SourceDetailModal-CHp9EgLP.js → SourceDetailModal-Bw0T8W-W.js} +1 -1
  90. package/dashboard/static/assets/{UpdateModal-5iKST6jo.js → UpdateModal-D5AKx4Yw.js} +1 -1
  91. package/dashboard/static/assets/index-BKITk1CT.css +1 -0
  92. package/dashboard/static/assets/{index-Cm0VTxJC.js → index-BXDOzSng.js} +3 -3
  93. package/dashboard/static/assets/{outlineNavigation-DkXGL8J6.js → outlineNavigation-JObY7cUY.js} +1 -1
  94. package/dashboard/static/dashboard.html +2 -2
  95. package/package.json +19 -1
  96. package/dashboard/static/assets/ModalRoot-D5Y8IFUG.js +0 -1
  97. 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 size
47
- exceeds that cursor (an incomplete-``session_meta`` tail completing without
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=default_file_size,
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: size_bytes}`` committed
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 ``*.jsonl`` files in ``pending`` BEFORE
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 path.endswith(".jsonl"):
271
- if path not in self._known and path not in self._pending:
272
- self._pending[path] = 0 # register BEFORE first ingest
273
- ingest.add(path) # first ingest attempt now
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
- size = self._file_size(p) # 1 fs op
290
- if size is None:
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 committed is None or size > committed:
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 _schema_holds_threads_table(conn, "main"):
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")