cctally 1.83.0 → 1.84.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 +34 -0
- package/README.md +2 -4
- package/bin/_cctally_account.py +144 -11
- package/bin/_cctally_alerts.py +3 -1
- package/bin/_cctally_cache.py +1396 -33
- package/bin/_cctally_dashboard.py +46 -2
- package/bin/_cctally_dashboard_conversation.py +16 -7
- package/bin/_cctally_dashboard_envelope.py +12 -0
- package/bin/_cctally_dashboard_share.py +58 -5
- package/bin/_cctally_dashboard_sources.py +641 -38
- package/bin/_cctally_db.py +173 -7
- package/bin/_cctally_doctor.py +112 -27
- package/bin/_cctally_journal.py +761 -74
- package/bin/_cctally_milestone_history.py +73 -16
- package/bin/_cctally_quota.py +313 -15
- package/bin/_cctally_source_analytics.py +4 -1
- package/bin/_cctally_tui.py +7 -3
- package/bin/_lib_doctor.py +67 -2
- package/bin/_lib_journal.py +48 -0
- package/bin/_lib_jsonl.py +119 -0
- package/bin/_lib_quota.py +222 -13
- package/bin/_lib_rederive.py +12 -0
- package/bin/_lib_source_analytics.py +13 -0
- package/bin/_lib_source_identity.py +24 -0
- package/dashboard/static/assets/index-DlVVJeS4.js +92 -0
- package/dashboard/static/assets/index-OYBkyglj.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +1 -1
- package/dashboard/static/assets/index-3bgCMVHb.js +0 -92
- package/dashboard/static/assets/index-D27EIHEI.css +0 -1
package/bin/_cctally_cache.py
CHANGED
|
@@ -98,15 +98,19 @@ Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md
|
|
|
98
98
|
from __future__ import annotations
|
|
99
99
|
|
|
100
100
|
import argparse
|
|
101
|
+
import contextlib
|
|
101
102
|
import datetime as dt
|
|
102
103
|
import fcntl
|
|
103
104
|
import json
|
|
104
105
|
import os
|
|
105
106
|
import pathlib
|
|
106
107
|
import select
|
|
108
|
+
import shutil
|
|
107
109
|
import signal
|
|
108
110
|
import sqlite3
|
|
111
|
+
import subprocess
|
|
109
112
|
import sys
|
|
113
|
+
import tempfile
|
|
110
114
|
import time
|
|
111
115
|
from dataclasses import asdict, dataclass, field
|
|
112
116
|
from typing import Any, Callable, Iterator, NamedTuple
|
|
@@ -124,6 +128,9 @@ def _cctally():
|
|
|
124
128
|
import _cctally_core
|
|
125
129
|
from _cctally_core import eprint
|
|
126
130
|
from _lib_source_identity import source_root_key
|
|
131
|
+
# #416 spec §4.2: the pure tolerance-anchored reset kernel. `_lib_quota` imports
|
|
132
|
+
# only `_lib_accounts` (a stdlib leaf), so binding it here is circular-safe.
|
|
133
|
+
import _lib_quota
|
|
127
134
|
|
|
128
135
|
|
|
129
136
|
# Module-level back-ref shims for the three out-of-scope JSONL/project
|
|
@@ -339,6 +346,24 @@ _SESSION_ENTRY_HEAD = """INSERT INTO session_entries
|
|
|
339
346
|
# and that backstop must not be silently converted into an update.
|
|
340
347
|
SESSION_ENTRY_UPSERT_SQL = _SESSION_ENTRY_HEAD + _SESSION_ENTRY_SET + _SESSION_ENTRY_GUARD
|
|
341
348
|
|
|
349
|
+
# The replay-only physical-key conflict is enrichment, not winner selection.
|
|
350
|
+
# Unlike the first (msg_id, req_id) clause, it must never replace the retained
|
|
351
|
+
# event's timestamp/model/tokens/usage/speed/raw-cost or its first account
|
|
352
|
+
# stamp. Only the newly-derived TTL split and mutation signal may land.
|
|
353
|
+
_SESSION_ENTRY_REWALK_PHYSICAL_SET = """
|
|
354
|
+
cache_create_1h_tokens = excluded.cache_create_1h_tokens,
|
|
355
|
+
cache_create_5m_tokens = excluded.cache_create_5m_tokens,
|
|
356
|
+
mutation_seq = excluded.mutation_seq,
|
|
357
|
+
mutation_min_ts = MIN(COALESCE(session_entries.mutation_min_ts,
|
|
358
|
+
session_entries.timestamp_utc),
|
|
359
|
+
excluded.timestamp_utc)"""
|
|
360
|
+
|
|
361
|
+
_SESSION_ENTRY_REWALK_PHYSICAL_GUARD = """
|
|
362
|
+
WHERE excluded.cache_create_1h_tokens IS NOT NULL
|
|
363
|
+
AND excluded.cache_create_5m_tokens IS NOT NULL
|
|
364
|
+
AND session_entries.cache_create_1h_tokens IS NULL
|
|
365
|
+
AND session_entries.cache_create_5m_tokens IS NULL"""
|
|
366
|
+
|
|
342
367
|
# Re-walk only (#195 migration 030): rows are NOT wiped first, so a row the
|
|
343
368
|
# partial dedup index does not cover (NULL msg_id and/or req_id) collides on
|
|
344
369
|
# idx_entries_physical instead. SQLite does not route that through the first
|
|
@@ -349,7 +374,8 @@ SESSION_ENTRY_UPSERT_SQL_REWALK = (
|
|
|
349
374
|
+ """
|
|
350
375
|
ON CONFLICT(source_path, line_offset)
|
|
351
376
|
DO UPDATE SET"""
|
|
352
|
-
+
|
|
377
|
+
+ _SESSION_ENTRY_REWALK_PHYSICAL_SET
|
|
378
|
+
+ _SESSION_ENTRY_REWALK_PHYSICAL_GUARD)
|
|
353
379
|
|
|
354
380
|
|
|
355
381
|
def _conv_row_tuple(m, path_str):
|
|
@@ -812,6 +838,310 @@ def _canonical_codex_path(path: pathlib.Path) -> pathlib.Path:
|
|
|
812
838
|
return path.absolute()
|
|
813
839
|
|
|
814
840
|
|
|
841
|
+
# ── #416: the durable Codex attribution map ──────────────────────────────────
|
|
842
|
+
# Attribution is DECIDED ONCE at first ingest of a byte range and thereafter
|
|
843
|
+
# only replayed. The live auth.json is an input to that decision, never a source
|
|
844
|
+
# consulted at rebuild time — which is exactly what made a `cache-sync --rebuild`
|
|
845
|
+
# re-stamp seven months of history with whoever happened to be logged in
|
|
846
|
+
# (spec §1.1). See docs/accounts-gotchas.md.
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
@dataclass(frozen=True)
|
|
850
|
+
class CodexFileAccountDecision:
|
|
851
|
+
"""One durable attribution decision covering a byte range.
|
|
852
|
+
|
|
853
|
+
``account_key is None`` is the stably-absent SENTINEL decision (no auth /
|
|
854
|
+
api-key mode), which is emphatically NOT the same as "no decision": a torn
|
|
855
|
+
auth read records nothing at all (spec §3.6). Callers must therefore test
|
|
856
|
+
the decision object for ``None``, never its ``account_key``.
|
|
857
|
+
"""
|
|
858
|
+
|
|
859
|
+
account_key: "str | None"
|
|
860
|
+
incarnation: int
|
|
861
|
+
from_offset: int
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def codex_file_identity(discovered: CodexDiscoveredFile) -> str:
|
|
865
|
+
"""The durable identity of one discovered rollout (spec §3.2).
|
|
866
|
+
|
|
867
|
+
Derived from ``(source_root_key, canonical physical path)`` — NOT from
|
|
868
|
+
``source_path``, which retains the first configured candidate spelling and
|
|
869
|
+
therefore changes when ``$CODEX_HOME`` roots are reordered or a symlink is
|
|
870
|
+
respelled.
|
|
871
|
+
"""
|
|
872
|
+
from _lib_source_identity import codex_file_key
|
|
873
|
+
return codex_file_key(
|
|
874
|
+
discovered.source_root_key, str(discovered.physical_path))
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def codex_file_incarnation(conn: sqlite3.Connection, file_identity: str) -> int:
|
|
878
|
+
"""This file's current incarnation, defaulting to 1 for an unseen file."""
|
|
879
|
+
row = conn.execute(
|
|
880
|
+
"SELECT incarnation FROM codex_file_incarnations WHERE file_identity = ?",
|
|
881
|
+
(file_identity,),
|
|
882
|
+
).fetchone()
|
|
883
|
+
return 1 if row is None else int(row[0])
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
# NOTE (#416 review M1): there is deliberately NO `bump_codex_file_incarnation`.
|
|
887
|
+
# The plan sketched an increment helper, but the walk resolves the next
|
|
888
|
+
# incarnation itself (`base_incarnation + 1` on a genuine truncation) and
|
|
889
|
+
# persists it through the MAX-set `set_codex_file_incarnation` below, because
|
|
890
|
+
# that write lives inside the per-file batch transaction the ingest may roll
|
|
891
|
+
# back and retry — replaying an increment would double-bump. The increment
|
|
892
|
+
# helper shipped with no production caller at all; it is not resurrected.
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def record_codex_file_account(
|
|
896
|
+
conn: sqlite3.Connection,
|
|
897
|
+
*,
|
|
898
|
+
file_identity: str,
|
|
899
|
+
incarnation: int,
|
|
900
|
+
from_offset: int,
|
|
901
|
+
root_scope: str,
|
|
902
|
+
account_key: "str | None",
|
|
903
|
+
decided_at_utc: str,
|
|
904
|
+
) -> None:
|
|
905
|
+
"""Materialize one attribution decision into the cache map.
|
|
906
|
+
|
|
907
|
+
Idempotent by the ``(file_identity, incarnation, from_offset)`` primary key
|
|
908
|
+
so a crash-replay of the same journaled decision converges rather than
|
|
909
|
+
duplicating or raising. A decision is never REWRITTEN — a genuine correction
|
|
910
|
+
is expressed as a new range decision (spec §3.5), so the conflict path
|
|
911
|
+
deliberately preserves the existing row.
|
|
912
|
+
"""
|
|
913
|
+
conn.execute(
|
|
914
|
+
"INSERT INTO codex_file_accounts "
|
|
915
|
+
"(file_identity, incarnation, from_offset, root_scope, account_key, "
|
|
916
|
+
" decided_at_utc) "
|
|
917
|
+
"VALUES (?,?,?,?,?,?) "
|
|
918
|
+
"ON CONFLICT(file_identity, incarnation, from_offset) DO NOTHING",
|
|
919
|
+
(file_identity, incarnation, from_offset, root_scope, account_key,
|
|
920
|
+
decided_at_utc),
|
|
921
|
+
)
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def set_codex_file_incarnation(
|
|
925
|
+
conn: sqlite3.Connection, file_identity: str, incarnation: int,
|
|
926
|
+
*, at_utc: "str | None" = None,
|
|
927
|
+
) -> None:
|
|
928
|
+
"""Idempotently record ``file_identity``'s incarnation as at least
|
|
929
|
+
``incarnation``.
|
|
930
|
+
|
|
931
|
+
The absolute (MAX) form rather than an increment, because this runs inside
|
|
932
|
+
the per-file batch transaction that the ingest may roll back and retry —
|
|
933
|
+
replaying an increment would double-bump, replaying a MAX-set converges.
|
|
934
|
+
"""
|
|
935
|
+
conn.execute(
|
|
936
|
+
"INSERT INTO codex_file_incarnations (file_identity, incarnation, updated_at_utc) "
|
|
937
|
+
"VALUES (?,?,?) "
|
|
938
|
+
"ON CONFLICT(file_identity) DO UPDATE SET "
|
|
939
|
+
" incarnation = MAX(codex_file_incarnations.incarnation, excluded.incarnation), "
|
|
940
|
+
" updated_at_utc = excluded.updated_at_utc",
|
|
941
|
+
(file_identity, incarnation, at_utc),
|
|
942
|
+
)
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
def load_codex_file_account_ranges(
|
|
946
|
+
conn: sqlite3.Connection, file_identity: str, incarnation: int,
|
|
947
|
+
) -> "list[tuple[int, str | None]]":
|
|
948
|
+
"""This incarnation's decided ranges as ``[(from_offset, account_key), …]``
|
|
949
|
+
ascending — the whole per-file map in one read, so the ingest can stamp each
|
|
950
|
+
parsed row by ITS OWN offset without a query per row."""
|
|
951
|
+
return [
|
|
952
|
+
(int(row[0]), row[1])
|
|
953
|
+
for row in conn.execute(
|
|
954
|
+
"SELECT from_offset, account_key FROM codex_file_accounts "
|
|
955
|
+
"WHERE file_identity = ? AND incarnation = ? ORDER BY from_offset ASC",
|
|
956
|
+
(file_identity, incarnation),
|
|
957
|
+
)
|
|
958
|
+
]
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def codex_account_for_offset(
|
|
962
|
+
ranges: "list[tuple[int, str | None]]", offset: int,
|
|
963
|
+
) -> "tuple[bool, str | None]":
|
|
964
|
+
"""``(covered, account_key)`` for ``offset`` against ascending ``ranges``.
|
|
965
|
+
|
|
966
|
+
``covered`` is the load-bearing half: a ``(True, None)`` result is the
|
|
967
|
+
stably-absent sentinel DECISION, while ``(False, None)`` means no decision
|
|
968
|
+
covers these bytes at all. Narrowest containing interval wins.
|
|
969
|
+
"""
|
|
970
|
+
covered = False
|
|
971
|
+
key: "str | None" = None
|
|
972
|
+
for from_offset, account_key in ranges:
|
|
973
|
+
if from_offset > offset:
|
|
974
|
+
break
|
|
975
|
+
covered, key = True, account_key
|
|
976
|
+
return covered, key
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def resolve_codex_file_account(
|
|
980
|
+
conn: sqlite3.Connection, file_identity: str, *, incarnation: int, offset: int,
|
|
981
|
+
) -> "CodexFileAccountDecision | None":
|
|
982
|
+
"""The decision covering ``offset`` of this incarnation, or ``None``.
|
|
983
|
+
|
|
984
|
+
Interval precedence (spec §3.2): the newest incarnation wins — expressed
|
|
985
|
+
here by resolving at exactly the caller's current incarnation, so an older
|
|
986
|
+
incarnation's ranges can never cover reused offsets — and within an
|
|
987
|
+
incarnation the NARROWEST containing interval wins, i.e. the greatest
|
|
988
|
+
``from_offset`` that is still ``<= offset``.
|
|
989
|
+
"""
|
|
990
|
+
row = conn.execute(
|
|
991
|
+
"SELECT account_key, incarnation, from_offset FROM codex_file_accounts "
|
|
992
|
+
"WHERE file_identity = ? AND incarnation = ? AND from_offset <= ? "
|
|
993
|
+
"ORDER BY from_offset DESC LIMIT 1",
|
|
994
|
+
(file_identity, incarnation, offset),
|
|
995
|
+
).fetchone()
|
|
996
|
+
if row is None:
|
|
997
|
+
return None
|
|
998
|
+
return CodexFileAccountDecision(
|
|
999
|
+
account_key=row[0], incarnation=int(row[1]), from_offset=int(row[2]))
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
# --------------------------------------------------------------------------
|
|
1003
|
+
# #416 spec §4.1/§4.2 — the canonical reset anchor, resolved at INGEST.
|
|
1004
|
+
#
|
|
1005
|
+
# Read-time canonicalization is wrong here (review F7): the dashboard loads at
|
|
1006
|
+
# most 35 days / 1,000 observations and the loader applies those bounds in SQL,
|
|
1007
|
+
# BEFORE any Python canonicalization, so a read-time "first sight wins" anchor
|
|
1008
|
+
# over a truncated population picks a different first member and the dashboard
|
|
1009
|
+
# and the CLI disagree about window identity. Resolving at ingest over the
|
|
1010
|
+
# complete population and STORING the answer makes every read subset-independent
|
|
1011
|
+
# by construction, bounded or not. The raw provider value is retained unchanged
|
|
1012
|
+
# beside it as evidence.
|
|
1013
|
+
#
|
|
1014
|
+
# Unlike the `window_minutes` snap (a pure per-row function, correctly applied
|
|
1015
|
+
# on the read path), the anchor is population-dependent — which is exactly why
|
|
1016
|
+
# the two live on opposite sides of the ingest boundary.
|
|
1017
|
+
# --------------------------------------------------------------------------
|
|
1018
|
+
|
|
1019
|
+
def _parse_anchor_iso(value: object) -> "dt.datetime | None":
|
|
1020
|
+
if not isinstance(value, str) or not value.strip():
|
|
1021
|
+
return None
|
|
1022
|
+
text = value.strip()
|
|
1023
|
+
if text.endswith("Z"):
|
|
1024
|
+
text = text[:-1] + "+00:00"
|
|
1025
|
+
try:
|
|
1026
|
+
parsed = dt.datetime.fromisoformat(text)
|
|
1027
|
+
except ValueError:
|
|
1028
|
+
return None
|
|
1029
|
+
if parsed.tzinfo is None:
|
|
1030
|
+
return parsed.replace(tzinfo=dt.timezone.utc)
|
|
1031
|
+
return parsed.astimezone(dt.timezone.utc)
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
class CodexResetAnchorResolver:
|
|
1035
|
+
"""Resolve one Codex quota observation's canonical reset anchor.
|
|
1036
|
+
|
|
1037
|
+
Memoised per anchor GROUP for the lifetime of one ingest, and seeded lazily
|
|
1038
|
+
from whatever anchors that group already carries in cache.db — so an
|
|
1039
|
+
incremental sync joins the cluster a previous sync established rather than
|
|
1040
|
+
starting a fresh one (spec §4.2: "first sight wins and the anchor never
|
|
1041
|
+
moves").
|
|
1042
|
+
|
|
1043
|
+
The group is the canonical identity MINUS the reset and MINUS the account.
|
|
1044
|
+
The account is excluded deliberately: ``_physical_window_key`` excludes it
|
|
1045
|
+
too, so that an unidentified observation can be adopted by a same-window
|
|
1046
|
+
identified account — an account-scoped anchor would give the two halves of
|
|
1047
|
+
one physical window different anchors and defeat that adoption.
|
|
1048
|
+
|
|
1049
|
+
``window_minutes`` enters the group SNAPPED, and the DB seed enumerates the
|
|
1050
|
+
raw spellings that snap onto it, so the stray ``10081`` weekly window shares
|
|
1051
|
+
an anchor with its ``10080`` siblings instead of anchoring separately and
|
|
1052
|
+
re-fragmenting after the read-path snap.
|
|
1053
|
+
"""
|
|
1054
|
+
|
|
1055
|
+
def __init__(self, conn: sqlite3.Connection):
|
|
1056
|
+
self._conn = conn
|
|
1057
|
+
self._groups: "dict[tuple[str, str, str, object], _lib_quota.ResetAnchorIndex]" = {}
|
|
1058
|
+
self._seed_failed = False
|
|
1059
|
+
|
|
1060
|
+
@staticmethod
|
|
1061
|
+
def group_key(
|
|
1062
|
+
source_root_key: str, observed_slot: str, logical_limit_key: str,
|
|
1063
|
+
window_minutes: object,
|
|
1064
|
+
) -> "tuple[str, str, str, object]":
|
|
1065
|
+
return (
|
|
1066
|
+
str(source_root_key), str(observed_slot),
|
|
1067
|
+
_lib_jsonl.snap_window_minutes(str(logical_limit_key)),
|
|
1068
|
+
_lib_jsonl.snap_codex_window_minutes(window_minutes),
|
|
1069
|
+
)
|
|
1070
|
+
|
|
1071
|
+
def _anchors_for(
|
|
1072
|
+
self, group, logical_limit_key: str,
|
|
1073
|
+
) -> "_lib_quota.ResetAnchorIndex":
|
|
1074
|
+
"""The group's established anchors, as an O(1)-lookup index.
|
|
1075
|
+
|
|
1076
|
+
A bucketed index rather than a list because this seed is UNBOUNDED — it
|
|
1077
|
+
is every distinct anchor the group ever carried — and the resolver is
|
|
1078
|
+
driven over the whole table by cache migration 032 and over the whole
|
|
1079
|
+
walk by `cache-sync --rebuild`. A linear scan per observation makes both
|
|
1080
|
+
quadratic in the group's anchor count (~1,750 a year for a 5h window).
|
|
1081
|
+
"""
|
|
1082
|
+
anchors = self._groups.get(group)
|
|
1083
|
+
if anchors is not None:
|
|
1084
|
+
return anchors
|
|
1085
|
+
anchors = _lib_quota.ResetAnchorIndex()
|
|
1086
|
+
if not self._seed_failed:
|
|
1087
|
+
candidates = _lib_jsonl.codex_snap_equivalent_limit_keys(
|
|
1088
|
+
str(logical_limit_key))
|
|
1089
|
+
placeholders = ",".join("?" for _ in candidates)
|
|
1090
|
+
try:
|
|
1091
|
+
rows = self._conn.execute(
|
|
1092
|
+
"SELECT DISTINCT canonical_resets_at_utc "
|
|
1093
|
+
"FROM quota_window_snapshots "
|
|
1094
|
+
"WHERE source = 'codex' AND source_root_key = ? "
|
|
1095
|
+
" AND observed_slot = ? "
|
|
1096
|
+
f" AND logical_limit_key IN ({placeholders}) "
|
|
1097
|
+
" AND canonical_resets_at_utc IS NOT NULL "
|
|
1098
|
+
"ORDER BY canonical_resets_at_utc",
|
|
1099
|
+
(group[0], group[1], *candidates),
|
|
1100
|
+
).fetchall()
|
|
1101
|
+
except sqlite3.DatabaseError:
|
|
1102
|
+
# A cache that has not yet gained the column (an old binary
|
|
1103
|
+
# racing a new one) degrades to per-sync anchors rather than
|
|
1104
|
+
# failing the whole ingest — the column is re-derivable.
|
|
1105
|
+
self._seed_failed = True
|
|
1106
|
+
rows = []
|
|
1107
|
+
for row in rows:
|
|
1108
|
+
parsed = _parse_anchor_iso(row[0])
|
|
1109
|
+
if parsed is not None:
|
|
1110
|
+
anchors.add(parsed)
|
|
1111
|
+
self._groups[group] = anchors
|
|
1112
|
+
return anchors
|
|
1113
|
+
|
|
1114
|
+
def resolve(
|
|
1115
|
+
self, *, source_root_key: str, observed_slot: str,
|
|
1116
|
+
logical_limit_key: str, window_minutes: object, resets_at_utc: object,
|
|
1117
|
+
) -> "str | None":
|
|
1118
|
+
"""The canonical anchor for one observation, as stored TEXT.
|
|
1119
|
+
|
|
1120
|
+
Returns ``None`` when the raw reset cannot be parsed — the column then
|
|
1121
|
+
stays NULL and every reader falls back to the raw value, which is
|
|
1122
|
+
exactly today's behaviour.
|
|
1123
|
+
"""
|
|
1124
|
+
raw = _parse_anchor_iso(resets_at_utc)
|
|
1125
|
+
if raw is None:
|
|
1126
|
+
return None
|
|
1127
|
+
group = self.group_key(
|
|
1128
|
+
source_root_key, observed_slot, logical_limit_key, window_minutes)
|
|
1129
|
+
anchors = self._anchors_for(group, logical_limit_key)
|
|
1130
|
+
chosen = _lib_quota.resolve_reset_anchor(anchors, raw)
|
|
1131
|
+
if chosen == raw:
|
|
1132
|
+
anchors.add(raw)
|
|
1133
|
+
# ALWAYS re-serialized through the canonical UTC form, established or
|
|
1134
|
+
# joined alike: two spellings of one instant ("…Z" vs "…+00:00") must
|
|
1135
|
+
# never mint two anchors for one cluster, and a row seeded by anything
|
|
1136
|
+
# other than the walk (a migration backfill, a hand-written fixture) can
|
|
1137
|
+
# spell it either way.
|
|
1138
|
+
return _codex_anchor_iso(chosen)
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
def _codex_anchor_iso(value: dt.datetime) -> str:
|
|
1142
|
+
return value.astimezone(dt.timezone.utc).isoformat().replace("+00:00", "Z")
|
|
1143
|
+
|
|
1144
|
+
|
|
815
1145
|
def _codex_provider_roots() -> list[CodexProviderRoot]:
|
|
816
1146
|
"""Return configured provider roots with their sessions/direct walk roots.
|
|
817
1147
|
|
|
@@ -1080,6 +1410,15 @@ def _clear_codex_derived_rows(conn: sqlite3.Connection) -> bool:
|
|
|
1080
1410
|
counter even when the semantic Codex surface was already empty. Callers
|
|
1081
1411
|
use this return value, rather than ``Connection.total_changes``, when
|
|
1082
1412
|
deciding whether to advance the physical-mutation sequence.
|
|
1413
|
+
|
|
1414
|
+
DO NOT add ``codex_file_accounts`` / ``codex_file_incarnations`` to the
|
|
1415
|
+
DELETE list below (#416 spec §3.4). Every other family here is derivable
|
|
1416
|
+
from the rollout bytes, which is exactly why clearing them is safe. The
|
|
1417
|
+
attribution map is NOT: it records who owned bytes at the moment they were
|
|
1418
|
+
first read, and the live ``auth.json`` cannot reconstruct that after an
|
|
1419
|
+
account switch. Wiping it is the defect. It is re-derivable only from the
|
|
1420
|
+
journal, and ``sync_codex_cache`` rehydrates it from there immediately after
|
|
1421
|
+
this call.
|
|
1083
1422
|
"""
|
|
1084
1423
|
state_changed = any(
|
|
1085
1424
|
conn.execute(query).fetchone() is not None
|
|
@@ -1470,10 +1809,17 @@ def _append_codex_quota_obs(quota_rows: list) -> None:
|
|
|
1470
1809
|
import _cctally_journal as _jr
|
|
1471
1810
|
import _lib_journal as _jl
|
|
1472
1811
|
for row in quota_rows:
|
|
1812
|
+
# The trailing `canonical_resets_at_utc` (#416 §4.2) is deliberately NOT
|
|
1813
|
+
# unpacked and NOT journaled: it is a property of the observation's
|
|
1814
|
+
# POPULATION, not of the observation, so it is re-resolved by whichever
|
|
1815
|
+
# writer materializes the cache row. Journaling it would freeze one
|
|
1816
|
+
# cycle's clustering into the append-only record, where a later ingest
|
|
1817
|
+
# could never correct it — and would change the obs payload, hence its
|
|
1818
|
+
# content id, hence the natural-key dedup that keeps replay idempotent.
|
|
1473
1819
|
(source, source_root_key, source_path, line_offset, captured_at_utc,
|
|
1474
1820
|
observed_slot, logical_limit_key, limit_id, limit_name, window_minutes,
|
|
1475
1821
|
used_percent, resets_at_utc, plan_type, individual_limit_json,
|
|
1476
|
-
reached_type, observed_model, account_key) = row
|
|
1822
|
+
reached_type, observed_model, account_key, _canonical_resets_at) = row
|
|
1477
1823
|
at = captured_at_utc or (
|
|
1478
1824
|
_cctally_core._command_as_of()
|
|
1479
1825
|
.isoformat(timespec="seconds")
|
|
@@ -1505,6 +1851,34 @@ def _append_codex_quota_obs(quota_rows: list) -> None:
|
|
|
1505
1851
|
eprint(f"[codex-cache] quota obs journal append failed: {exc}")
|
|
1506
1852
|
|
|
1507
1853
|
|
|
1854
|
+
def _append_codex_file_account_decision(
|
|
1855
|
+
*, at: str, root_scope: str, file_identity: str, incarnation: int,
|
|
1856
|
+
from_offset: int, account_key: "str | None",
|
|
1857
|
+
) -> None:
|
|
1858
|
+
"""Journal one durable attribution decision — FAIL CLOSED (#416 spec §3.6).
|
|
1859
|
+
|
|
1860
|
+
Deliberately unlike ``_append_codex_quota_obs``, which catches every
|
|
1861
|
+
exception and lets the ingest continue. That is correct for an OBSERVATION
|
|
1862
|
+
(losing one is a gap in evidence) and wrong for a "decided once" map: if the
|
|
1863
|
+
append fails but the accounting rows and the file watermark commit anyway,
|
|
1864
|
+
those bytes are permanently ingested with no durable decision behind them,
|
|
1865
|
+
and the next rebuild has nothing to replay — which is exactly the hole this
|
|
1866
|
+
whole mechanism exists to close. The caller must therefore let the exception
|
|
1867
|
+
propagate into "defer this file", advancing no cursor.
|
|
1868
|
+
|
|
1869
|
+
Runs under the ``cache.db.codex.lock`` provider flock the ingest already
|
|
1870
|
+
holds; the journal append lock is a LEAF, so taking it inside a provider
|
|
1871
|
+
flock is legal (lock-order law, docs/journal-gotchas.md).
|
|
1872
|
+
"""
|
|
1873
|
+
import _cctally_journal as _jr
|
|
1874
|
+
import _lib_journal as _jl
|
|
1875
|
+
_jr.append_record(_jl.make_codex_file_account(
|
|
1876
|
+
at=at, root_scope=root_scope, file_identity=file_identity,
|
|
1877
|
+
incarnation=incarnation, from_offset=from_offset,
|
|
1878
|
+
account_key=account_key,
|
|
1879
|
+
))
|
|
1880
|
+
|
|
1881
|
+
|
|
1508
1882
|
def _write_codex_file_batch(
|
|
1509
1883
|
conn: sqlite3.Connection,
|
|
1510
1884
|
*,
|
|
@@ -1528,16 +1902,41 @@ def _write_codex_file_batch(
|
|
|
1528
1902
|
active_root_keys: set[str],
|
|
1529
1903
|
prune_roots: bool = True,
|
|
1530
1904
|
account_key: "str | None" = None,
|
|
1905
|
+
file_identity: "str | None" = None,
|
|
1906
|
+
incarnation: "int | None" = None,
|
|
1907
|
+
file_account_decision: "tuple[int, str | None] | None" = None,
|
|
1531
1908
|
) -> int:
|
|
1532
1909
|
"""Write one fully-buffered Codex file atomically and return entry changes.
|
|
1533
1910
|
|
|
1534
1911
|
``prune_roots`` gates the whole-tree ``_prune_inactive_codex_source_roots``
|
|
1535
1912
|
call: a targeted (only_paths) ingest passes ``False`` so it never deletes a
|
|
1536
1913
|
``codex_source_roots`` row for a root it wasn't asked about (spec §5.1
|
|
1537
|
-
whole-tree bypass — ``active_root_keys`` then covers only the targets).
|
|
1914
|
+
whole-tree bypass — ``active_root_keys`` then covers only the targets).
|
|
1915
|
+
|
|
1916
|
+
``file_identity``/``incarnation``/``file_account_decision`` (#416) carry the
|
|
1917
|
+
durable attribution decision into THIS transaction, so the decision, the
|
|
1918
|
+
rows it stamped and the file watermark commit or roll back as one unit. The
|
|
1919
|
+
decision was already journaled (fail-closed) before this call, so a crash
|
|
1920
|
+
between the two replays idempotently rather than losing it."""
|
|
1538
1921
|
now_iso = dt.datetime.now(dt.timezone.utc).isoformat()
|
|
1539
1922
|
if reset_file:
|
|
1540
1923
|
_delete_codex_file_derived_rows(conn, path_str)
|
|
1924
|
+
if file_identity is not None and incarnation is not None:
|
|
1925
|
+
# Idempotent MAX-set, NOT an increment: this statement is replayed
|
|
1926
|
+
# verbatim by the single in-memory-batch retry below.
|
|
1927
|
+
set_codex_file_incarnation(
|
|
1928
|
+
conn, file_identity, incarnation, at_utc=now_iso)
|
|
1929
|
+
if file_account_decision is not None:
|
|
1930
|
+
decision_offset, decision_key = file_account_decision
|
|
1931
|
+
record_codex_file_account(
|
|
1932
|
+
conn,
|
|
1933
|
+
file_identity=file_identity,
|
|
1934
|
+
incarnation=incarnation,
|
|
1935
|
+
from_offset=decision_offset,
|
|
1936
|
+
root_scope=discovered.source_root_key,
|
|
1937
|
+
account_key=decision_key,
|
|
1938
|
+
decided_at_utc=now_iso,
|
|
1939
|
+
)
|
|
1541
1940
|
conn.execute(
|
|
1542
1941
|
"""INSERT INTO codex_source_roots
|
|
1543
1942
|
(source_root_key, canonical_root_path, first_seen_utc, last_seen_utc)
|
|
@@ -1567,8 +1966,8 @@ def _write_codex_file_batch(
|
|
|
1567
1966
|
captured_at_utc, observed_slot, logical_limit_key, limit_id,
|
|
1568
1967
|
limit_name, window_minutes, used_percent, resets_at_utc,
|
|
1569
1968
|
plan_type, individual_limit_json, reached_type, observed_model,
|
|
1570
|
-
account_key)
|
|
1571
|
-
VALUES (
|
|
1969
|
+
account_key, canonical_resets_at_utc)
|
|
1970
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
1572
1971
|
quota_rows,
|
|
1573
1972
|
)
|
|
1574
1973
|
if thread_rows:
|
|
@@ -4409,6 +4808,34 @@ def sync_codex_cache(
|
|
|
4409
4808
|
)
|
|
4410
4809
|
seq_before = codex_physical_mutation_seq(conn)
|
|
4411
4810
|
|
|
4811
|
+
# #416 spec D1: which rollouts were ALREADY ingested before this rebuild
|
|
4812
|
+
# cleared the cursor table. A rebuild re-reads their bytes, and bytes
|
|
4813
|
+
# ingested before the durable-attribution mechanism existed must NOT be
|
|
4814
|
+
# re-attributed from the live auth.json — that is the inference the
|
|
4815
|
+
# design rejects, and it is what breaks acceptance criterion 4. Captured
|
|
4816
|
+
# BEFORE the clear because the clear is what erases the evidence. A
|
|
4817
|
+
# rollout first seen DURING a rebuild is absent here and takes a normal
|
|
4818
|
+
# first-ingest decision.
|
|
4819
|
+
# Keyed on the DURABLE file identity, never on `path`. `path` holds the
|
|
4820
|
+
# first configured candidate spelling, which is unstable across
|
|
4821
|
+
# `$CODEX_HOME` reordering and symlink respelling (review F12) — a
|
|
4822
|
+
# respelling between the last pre-#416 ingest and the remedial rebuild
|
|
4823
|
+
# would drop the file out of this set, send it to the auth.json branch
|
|
4824
|
+
# and re-stamp never-decided history, which is the exact violation the
|
|
4825
|
+
# snapshot exists to prevent.
|
|
4826
|
+
rebuild_known_identities: "set[str]" = set()
|
|
4827
|
+
if rebuild:
|
|
4828
|
+
from _lib_source_identity import codex_file_key
|
|
4829
|
+
for _path, _root_key in conn.execute(
|
|
4830
|
+
"SELECT path, source_root_key FROM codex_session_files"):
|
|
4831
|
+
if not _path or not _root_key:
|
|
4832
|
+
continue
|
|
4833
|
+
try:
|
|
4834
|
+
rebuild_known_identities.add(codex_file_key(
|
|
4835
|
+
str(_root_key),
|
|
4836
|
+
str(_canonical_codex_path(pathlib.Path(str(_path))))))
|
|
4837
|
+
except (ValueError, TypeError, OSError):
|
|
4838
|
+
continue
|
|
4412
4839
|
if rebuild:
|
|
4413
4840
|
# Clear INSIDE the lock — see sync_cache() for the full
|
|
4414
4841
|
# rationale. Done before the existing SELECT so delta
|
|
@@ -4417,6 +4844,86 @@ def sync_codex_cache(
|
|
|
4417
4844
|
_bump_codex_physical_mutation_seq(conn)
|
|
4418
4845
|
conn.commit()
|
|
4419
4846
|
eprint("[cache-sync] rebuild: cleared Codex cached entries")
|
|
4847
|
+
# #416 spec §3.4: rehydrate the attribution map from the journal BEFORE
|
|
4848
|
+
# the walk. The ordinary journal-to-cache replay lives inside
|
|
4849
|
+
# `rebuild_stats_index`; this path clears (on --rebuild) and walks on its
|
|
4850
|
+
# own with no applier in front of it, so a cache.db whose map cannot
|
|
4851
|
+
# answer would send every file back to the live auth.json.
|
|
4852
|
+
#
|
|
4853
|
+
# Deliberately NOT rebuild-only. Every production Codex call site syncs
|
|
4854
|
+
# with rebuild=False, and the corruption auto-heal recreates the cache.db
|
|
4855
|
+
# family and then re-runs the ORDINARY sync — so a rebuild-only wiring
|
|
4856
|
+
# leaves the defect reachable without anyone ever typing `--rebuild`.
|
|
4857
|
+
#
|
|
4858
|
+
# Under --rebuild the replay is AUTHORITATIVE (clear-then-replay), which
|
|
4859
|
+
# is what makes the documented remedy able to repair a map row that has
|
|
4860
|
+
# drifted away from the journal; the additive form cannot clear, though
|
|
4861
|
+
# its conflict clause is now last-op-wins so it converges too (#374).
|
|
4862
|
+
#
|
|
4863
|
+
# Otherwise it is a DELTA replay from the journal cursor this cache.db
|
|
4864
|
+
# last consumed (`codex_attribution_rehydrated_hw`), NOT a one-shot
|
|
4865
|
+
# "already rehydrated" marker. The one-shot form was the fix-round B1
|
|
4866
|
+
# defect: a file whose decision was journaled and whose cache write then
|
|
4867
|
+
# FAILED (or whose process died) leaves a journaled-but-unapplied
|
|
4868
|
+
# decision, and the marker — written at the TOP of that same sync —
|
|
4869
|
+
# stopped the retry from ever replaying it. The retry re-decided from
|
|
4870
|
+
# the live auth.json instead, so the journal gained a second op at the
|
|
4871
|
+
# same primary key and `cache-sync --rebuild` then flipped attribution,
|
|
4872
|
+
# violating acceptance criterion 4. Spec §3.6 asks for exactly this
|
|
4873
|
+
# cursor: "pending journal state replayed under the same locked
|
|
4874
|
+
# operation BEFORE auth.json is consulted on retry".
|
|
4875
|
+
#
|
|
4876
|
+
# The cursor keeps the Claude-only case cheap too — once it equals the
|
|
4877
|
+
# high-water, the replay reads no bytes and writes nothing.
|
|
4878
|
+
_ATTR_CURSOR_KEY = "codex_attribution_rehydrated_hw"
|
|
4879
|
+
try:
|
|
4880
|
+
_cursor_row = conn.execute(
|
|
4881
|
+
"SELECT value FROM cache_meta WHERE key = ? LIMIT 1",
|
|
4882
|
+
(_ATTR_CURSOR_KEY,)).fetchone()
|
|
4883
|
+
except sqlite3.DatabaseError:
|
|
4884
|
+
_cursor_row = None
|
|
4885
|
+
_cursor_text = _cursor_row[0] if _cursor_row else None
|
|
4886
|
+
_since = None
|
|
4887
|
+
if _cursor_text and not rebuild:
|
|
4888
|
+
_seg, _sep, _off = str(_cursor_text).rpartition(":")
|
|
4889
|
+
if _seg and _off.isdigit():
|
|
4890
|
+
_since = (_seg, int(_off))
|
|
4891
|
+
try:
|
|
4892
|
+
import _cctally_journal as _jr
|
|
4893
|
+
restored, _applied_hw, _declined = _jr.rehydrate_codex_file_accounts(
|
|
4894
|
+
conn, authoritative=bool(rebuild), since=_since)
|
|
4895
|
+
_new_cursor = (
|
|
4896
|
+
None if _applied_hw is None
|
|
4897
|
+
else f"{_applied_hw[0]}:{_applied_hw[1]}")
|
|
4898
|
+
if _new_cursor is not None and _new_cursor != _cursor_text:
|
|
4899
|
+
_set_cache_meta(conn, _ATTR_CURSOR_KEY, _new_cursor)
|
|
4900
|
+
# Unreleased one-shot marker this cursor replaces; dropped here
|
|
4901
|
+
# (a rare path) rather than on every sync.
|
|
4902
|
+
conn.execute("DELETE FROM cache_meta WHERE key = ?",
|
|
4903
|
+
("codex_attribution_rehydrated_at",))
|
|
4904
|
+
# The predicate is "could this call have written anything", NOT
|
|
4905
|
+
# "did it restore a row": the replay's upsert fires for every
|
|
4906
|
+
# record it sees, and `restored` deliberately counts only rows that
|
|
4907
|
+
# were ABSENT. A moved cursor is exactly the condition under which
|
|
4908
|
+
# `iter_range` yields records at all (an unmoved cursor reads no
|
|
4909
|
+
# bytes), and `rebuild` covers the authoritative DELETE, which
|
|
4910
|
+
# happens even when there is nothing to replay. Getting this wrong
|
|
4911
|
+
# strands an open transaction across the whole walk.
|
|
4912
|
+
if rebuild or _new_cursor != _cursor_text:
|
|
4913
|
+
conn.commit()
|
|
4914
|
+
# Both reports come AFTER the commit (closeout review C5): the
|
|
4915
|
+
# `except` below rolls back, so anything printed before it would
|
|
4916
|
+
# tell the operator about work that was undone.
|
|
4917
|
+
if restored:
|
|
4918
|
+
eprint(
|
|
4919
|
+
"[cache-sync] rehydrated "
|
|
4920
|
+
f"{restored} Codex attribution decision(s) from the journal")
|
|
4921
|
+
_jr._report_file_account_conflicts(_declined)
|
|
4922
|
+
except Exception as exc:
|
|
4923
|
+
conn.rollback()
|
|
4924
|
+
eprint(
|
|
4925
|
+
"[cache-sync] could not rehydrate Codex attribution "
|
|
4926
|
+
f"decisions: {exc}; undecided history stays unattributed")
|
|
4420
4927
|
|
|
4421
4928
|
# Pure read (glob + is_file only); safe to run before the SELECT and
|
|
4422
4929
|
# the per-file loop, where no cache.db write lock may be held. Targeted
|
|
@@ -4549,6 +5056,12 @@ def sync_codex_cache(
|
|
|
4549
5056
|
# is per provider root and rarely changes mid-sync). Keyed by
|
|
4550
5057
|
# source_root_key. A torn read defers every file under that root.
|
|
4551
5058
|
root_accounts: "dict[str, _CodexRootAccount]" = {}
|
|
5059
|
+
# #416 spec §4.2: ONE resolver for the whole sync. It memoises each
|
|
5060
|
+
# anchor group's established set, seeded lazily from cache.db, so the
|
|
5061
|
+
# anchor a previous sync established is joined rather than re-minted —
|
|
5062
|
+
# "first sight wins and the anchor never moves" across sync boundaries,
|
|
5063
|
+
# not just within one.
|
|
5064
|
+
anchor_resolver = CodexResetAnchorResolver(conn)
|
|
4552
5065
|
# #279 S2 F4: ONE coarse `walk` phase bracketing the per-file loop
|
|
4553
5066
|
# (count = files_processed, never per-row — §2 rule). Manual CM so
|
|
4554
5067
|
# the loop stays flat, mirroring sync_cache's walk seam.
|
|
@@ -4581,6 +5094,12 @@ def sync_codex_cache(
|
|
|
4581
5094
|
prev_conversation_key: str | None = None
|
|
4582
5095
|
prev_turn_id: str | None = None
|
|
4583
5096
|
requalified = False
|
|
5097
|
+
# #416 spec §3.3: TRUE only on a genuine delta resume — a known file
|
|
5098
|
+
# that grew under an unchanged identity, so `start_offset` is its
|
|
5099
|
+
# ingest watermark and every byte from there on has never been
|
|
5100
|
+
# attributed. It is the ONLY state in which the live auth.json may
|
|
5101
|
+
# mint a new range; a re-read from zero must replay, never re-decide.
|
|
5102
|
+
delta_append = False
|
|
4584
5103
|
if prev is not None:
|
|
4585
5104
|
(
|
|
4586
5105
|
prev_size, _, prev_offset, prev_sid, prev_model, prev_ttot,
|
|
@@ -4607,6 +5126,7 @@ def sync_codex_cache(
|
|
|
4607
5126
|
continue
|
|
4608
5127
|
if not requalified and size > prev_size:
|
|
4609
5128
|
start_offset = prev_offset
|
|
5129
|
+
delta_append = True
|
|
4610
5130
|
initial_session_id = prev_sid
|
|
4611
5131
|
initial_model = prev_model
|
|
4612
5132
|
initial_total_tokens = prev_total_tokens or 0
|
|
@@ -4618,29 +5138,130 @@ def sync_codex_cache(
|
|
|
4618
5138
|
initial_total_tokens = 0
|
|
4619
5139
|
prev_total_tokens = None
|
|
4620
5140
|
|
|
4621
|
-
# #
|
|
4622
|
-
#
|
|
4623
|
-
#
|
|
4624
|
-
#
|
|
4625
|
-
#
|
|
4626
|
-
#
|
|
4627
|
-
|
|
4628
|
-
|
|
4629
|
-
|
|
4630
|
-
|
|
4631
|
-
|
|
4632
|
-
|
|
4633
|
-
|
|
4634
|
-
|
|
4635
|
-
if
|
|
4636
|
-
|
|
4637
|
-
|
|
4638
|
-
|
|
4639
|
-
|
|
4640
|
-
|
|
4641
|
-
|
|
4642
|
-
|
|
4643
|
-
|
|
5141
|
+
# #416 spec §3: attribution is DECIDED ONCE at first ingest of a byte
|
|
5142
|
+
# range, journaled durably, and thereafter only REPLAYED. The live
|
|
5143
|
+
# auth.json is an input to that decision, never a source consulted
|
|
5144
|
+
# at rebuild time — re-deriving it per sync is precisely what let
|
|
5145
|
+
# `cache-sync --rebuild` re-stamp seven months of history with
|
|
5146
|
+
# whoever happened to be logged in (spec §1.1).
|
|
5147
|
+
file_identity = codex_file_identity(discovered)
|
|
5148
|
+
# A genuine shrink reuses offsets from zero under an UNCHANGED
|
|
5149
|
+
# identity, so it opens a new incarnation. A requalification does
|
|
5150
|
+
# not need one: the identity is scoped to source_root_key, so a
|
|
5151
|
+
# requalified file is already a different identity with no prior
|
|
5152
|
+
# decision at all — strictly stronger than a bump.
|
|
5153
|
+
base_incarnation = codex_file_incarnation(conn, file_identity)
|
|
5154
|
+
incarnation = (
|
|
5155
|
+
base_incarnation + 1 if (truncated and not requalified)
|
|
5156
|
+
else base_incarnation
|
|
5157
|
+
)
|
|
5158
|
+
account_ranges = load_codex_file_account_ranges(
|
|
5159
|
+
conn, file_identity, incarnation)
|
|
5160
|
+
covered, decided_key = codex_account_for_offset(
|
|
5161
|
+
account_ranges, start_offset)
|
|
5162
|
+
pending_decision: "tuple[int, str | None] | None" = None
|
|
5163
|
+
|
|
5164
|
+
def _live_root_account():
|
|
5165
|
+
"""This root's active account, resolved at most once per sync."""
|
|
5166
|
+
resolved = root_accounts.get(discovered.source_root_key)
|
|
5167
|
+
if resolved is None:
|
|
5168
|
+
resolved = _resolve_codex_account_for_root(
|
|
5169
|
+
discovered.provider_root)
|
|
5170
|
+
root_accounts[discovered.source_root_key] = resolved
|
|
5171
|
+
return resolved
|
|
5172
|
+
|
|
5173
|
+
if covered:
|
|
5174
|
+
# Replay. `covered` is what distinguishes a stably-absent
|
|
5175
|
+
# SENTINEL decision (covered, key None) from undecided bytes —
|
|
5176
|
+
# collapsing the two would send us back to auth.json for a file
|
|
5177
|
+
# that was already decided.
|
|
5178
|
+
file_account_key = decided_key
|
|
5179
|
+
# #416 spec §3.3: "A mid-file account change appends a SECOND
|
|
5180
|
+
# range-qualified op; the first is never rewritten." A decision
|
|
5181
|
+
# at from_offset 0 otherwise covers every future byte, so a
|
|
5182
|
+
# rollout that outlives an account switch — a long-running
|
|
5183
|
+
# session whose file keeps growing after `codex login` —
|
|
5184
|
+
# inherits the old account forever.
|
|
5185
|
+
#
|
|
5186
|
+
# The guard is the whole safety argument: `delta_append` means
|
|
5187
|
+
# `start_offset` is this file's ingest watermark, and the second
|
|
5188
|
+
# condition means the new range starts strictly beyond every
|
|
5189
|
+
# decided range. So auth.json can only ever mint a range for
|
|
5190
|
+
# bytes NOBODY has attributed yet; it can never re-decide bytes
|
|
5191
|
+
# a decision already covers, which is the original defect.
|
|
5192
|
+
if delta_append and start_offset > account_ranges[-1][0]:
|
|
5193
|
+
root_account = _live_root_account()
|
|
5194
|
+
if root_account.status == "torn":
|
|
5195
|
+
# We cannot tell whether the new bytes belong to a
|
|
5196
|
+
# different login, so defer the whole file exactly as a
|
|
5197
|
+
# first-ingest torn read does — no cursor advance, no
|
|
5198
|
+
# guess (spec §3.6 stable-read protocol).
|
|
5199
|
+
stats.files_deferred_torn += 1
|
|
5200
|
+
if targeted:
|
|
5201
|
+
stats.files_failed += 1
|
|
5202
|
+
continue
|
|
5203
|
+
if root_account.account_key != decided_key:
|
|
5204
|
+
file_account_key = root_account.account_key
|
|
5205
|
+
pending_decision = (start_offset, file_account_key)
|
|
5206
|
+
account_ranges = sorted(
|
|
5207
|
+
account_ranges + [pending_decision],
|
|
5208
|
+
key=lambda r: r[0])
|
|
5209
|
+
_maybe_append_codex_account_observe(
|
|
5210
|
+
root_account.identity)
|
|
5211
|
+
elif account_ranges:
|
|
5212
|
+
# Spec D1, the pre-#416 PREFIX of a partly-decided file. Reaching
|
|
5213
|
+
# here with a non-empty range list means every decided range
|
|
5214
|
+
# starts AFTER `start_offset` — i.e. we are re-reading bytes that
|
|
5215
|
+
# precede this file's earliest durable decision, which is exactly
|
|
5216
|
+
# the pre-mechanism history the design refuses to infer. Minting
|
|
5217
|
+
# `(start_offset, live_auth)` here would (a) attribute those old
|
|
5218
|
+
# bytes to whoever is logged in now and (b) leave the range list
|
|
5219
|
+
# UNSORTED, which `codex_account_for_offset` cannot resolve — its
|
|
5220
|
+
# `break` on the first `from_offset > offset` is only correct on
|
|
5221
|
+
# an ascending list, so the appended low offset would then shadow
|
|
5222
|
+
# the real decision for every later byte in the file.
|
|
5223
|
+
file_account_key = None
|
|
5224
|
+
elif rebuild and file_identity in rebuild_known_identities:
|
|
5225
|
+
# Spec D1: history that was never durably stamped becomes
|
|
5226
|
+
# unattributed; nothing is inferred. A rebuild re-reads bytes
|
|
5227
|
+
# that were ingested BEFORE this mechanism existed, and reading
|
|
5228
|
+
# the live auth.json for them would be exactly the inference the
|
|
5229
|
+
# design rejects (and would break acceptance criterion 4). Note
|
|
5230
|
+
# this is scoped by the pre-clear identity snapshot, so a rollout
|
|
5231
|
+
# first SEEN during a rebuild still takes a normal decision.
|
|
5232
|
+
file_account_key = None
|
|
5233
|
+
else:
|
|
5234
|
+
# #341: resolve this root's active account (per-root auth.json
|
|
5235
|
+
# stable-read, cached per sync). A torn read (auth.json
|
|
5236
|
+
# mid-rewrite) DEFERS the whole file this cycle — skip its new
|
|
5237
|
+
# bytes WITHOUT advancing the cursor, so the next sync re-reads
|
|
5238
|
+
# and re-stamps rather than guessing an account (spec §1
|
|
5239
|
+
# stable-read protocol). identified -> real key; stably-absent
|
|
5240
|
+
# (no auth / api-key mode) -> None, which is an explicit
|
|
5241
|
+
# sentinel DECISION, not an absence of one (spec §3.6).
|
|
5242
|
+
root_account = _live_root_account()
|
|
5243
|
+
if root_account.status == "torn":
|
|
5244
|
+
# Torn is NO decision and NO op — it is not an
|
|
5245
|
+
# `unattributed` decision (spec §3.6).
|
|
5246
|
+
stats.files_deferred_torn += 1
|
|
5247
|
+
if targeted:
|
|
5248
|
+
stats.files_failed += 1 # §5.1 deferred → call dirty
|
|
5249
|
+
continue
|
|
5250
|
+
file_account_key = root_account.account_key
|
|
5251
|
+
pending_decision = (start_offset, file_account_key)
|
|
5252
|
+
# `sorted` is not decoration: `codex_account_for_offset` breaks
|
|
5253
|
+
# on the first `from_offset > offset`, so it resolves correctly
|
|
5254
|
+
# ONLY against an ascending list. Every producer of a pending
|
|
5255
|
+
# decision must therefore merge it in order, never append.
|
|
5256
|
+
# Sort on the offset alone — a tuple sort would fall through to
|
|
5257
|
+
# comparing `account_key`, and `None < str` raises.
|
|
5258
|
+
account_ranges = sorted(
|
|
5259
|
+
account_ranges + [pending_decision], key=lambda r: r[0])
|
|
5260
|
+
# First-sight registry observe, journaled DURABLY BEFORE any
|
|
5261
|
+
# account-stamped quota obs / cache row for this account (spec
|
|
5262
|
+
# §1: replay can never see a stamped row whose account was never
|
|
5263
|
+
# observed). Marker-deduped; no-op for the sentinel.
|
|
5264
|
+
_maybe_append_codex_account_observe(root_account.identity)
|
|
4644
5265
|
|
|
4645
5266
|
accounting_rows: list[tuple[Any, ...]] = []
|
|
4646
5267
|
quota_rows: list[tuple[Any, ...]] = []
|
|
@@ -4708,7 +5329,23 @@ def sync_codex_cache(
|
|
|
4708
5329
|
quota.used_percent, quota.resets_at_utc,
|
|
4709
5330
|
quota.plan_type, quota.individual_limit_json,
|
|
4710
5331
|
quota.reached_type, iter_state.model,
|
|
4711
|
-
|
|
5332
|
+
# #416: stamped by the decision covering THIS
|
|
5333
|
+
# row's byte offset, so a file carrying two
|
|
5334
|
+
# range decisions replays each range correctly.
|
|
5335
|
+
codex_account_for_offset(
|
|
5336
|
+
account_ranges, quota.line_offset)[1],
|
|
5337
|
+
# #416 spec §4.2: the tolerance-anchored reset,
|
|
5338
|
+
# resolved HERE (at ingest, over the complete
|
|
5339
|
+
# population) rather than at read time, so a
|
|
5340
|
+
# bounded dashboard read and the unbounded CLI
|
|
5341
|
+
# read cannot disagree about window identity.
|
|
5342
|
+
anchor_resolver.resolve(
|
|
5343
|
+
source_root_key=quota.source_root_key,
|
|
5344
|
+
observed_slot=quota.observed_slot,
|
|
5345
|
+
logical_limit_key=quota.logical_limit_key,
|
|
5346
|
+
window_minutes=quota.window_minutes,
|
|
5347
|
+
resets_at_utc=quota.resets_at_utc,
|
|
5348
|
+
),
|
|
4712
5349
|
))
|
|
4713
5350
|
if (thread := emission.thread) is not None and (
|
|
4714
5351
|
thread.conversation_key is not None
|
|
@@ -4738,7 +5375,10 @@ def sync_codex_cache(
|
|
|
4738
5375
|
entry.total_tokens,
|
|
4739
5376
|
discovered.source_root_key,
|
|
4740
5377
|
event.conversation_key,
|
|
4741
|
-
|
|
5378
|
+
# #416: per-row decision lookup (see the quota rows
|
|
5379
|
+
# above) rather than one scalar stamp per file.
|
|
5380
|
+
codex_account_for_offset(
|
|
5381
|
+
account_ranges, emission.line_offset)[1],
|
|
4742
5382
|
))
|
|
4743
5383
|
yielded_count += 1
|
|
4744
5384
|
final_offset = fh.tell()
|
|
@@ -4806,6 +5446,30 @@ def sync_codex_cache(
|
|
|
4806
5446
|
# owns the authoritative transcript-local value.
|
|
4807
5447
|
new_last_turn_id = prev_turn_id
|
|
4808
5448
|
|
|
5449
|
+
# #416 spec §3.6: the attribution decision is journaled BEFORE any
|
|
5450
|
+
# accounting DML or watermark advance for this file, and FAIL
|
|
5451
|
+
# CLOSED. If the append cannot be made durable, the file is deferred
|
|
5452
|
+
# with zero mutations — a committed batch behind a lost decision
|
|
5453
|
+
# would be permanently un-replayable.
|
|
5454
|
+
if pending_decision is not None:
|
|
5455
|
+
decision_offset, decision_key = pending_decision
|
|
5456
|
+
try:
|
|
5457
|
+
_append_codex_file_account_decision(
|
|
5458
|
+
at=dt.datetime.now(dt.timezone.utc)
|
|
5459
|
+
.isoformat(timespec="seconds").replace("+00:00", "Z"),
|
|
5460
|
+
root_scope=discovered.source_root_key,
|
|
5461
|
+
file_identity=file_identity,
|
|
5462
|
+
incarnation=incarnation,
|
|
5463
|
+
from_offset=decision_offset,
|
|
5464
|
+
account_key=decision_key,
|
|
5465
|
+
)
|
|
5466
|
+
except Exception as exc:
|
|
5467
|
+
eprint(
|
|
5468
|
+
f"[codex-cache] attribution decision journal append "
|
|
5469
|
+
f"failed for {jp}: {exc}; deferring the file")
|
|
5470
|
+
stats.files_failed += 1
|
|
5471
|
+
continue
|
|
5472
|
+
|
|
4809
5473
|
# Task 7 Item 1: journal the Codex quota observations BEFORE the cache
|
|
4810
5474
|
# write (and before the offset advances), under the codex flock this
|
|
4811
5475
|
# function already holds. Durable-first: a crash after the append but
|
|
@@ -4845,6 +5509,11 @@ def sync_codex_cache(
|
|
|
4845
5509
|
# codex_source_roots for roots outside its target set.
|
|
4846
5510
|
prune_roots=not targeted,
|
|
4847
5511
|
account_key=file_account_key, # #341 last-observed stamp
|
|
5512
|
+
# #416: the decision + its incarnation commit in the
|
|
5513
|
+
# SAME transaction as the rows they stamped.
|
|
5514
|
+
file_identity=file_identity,
|
|
5515
|
+
incarnation=incarnation,
|
|
5516
|
+
file_account_decision=pending_decision,
|
|
4848
5517
|
)
|
|
4849
5518
|
except sqlite3.DatabaseError as exc:
|
|
4850
5519
|
conn.rollback()
|
|
@@ -4902,6 +5571,32 @@ def sync_codex_cache(
|
|
|
4902
5571
|
skip_reasons=stats.skip_reasons,
|
|
4903
5572
|
rebuild=rebuild,
|
|
4904
5573
|
)
|
|
5574
|
+
# #416 fix-round review B4: make a persistently torn `auth.json`
|
|
5575
|
+
# VISIBLE. The defer itself is correct (spec §3.6 stable-read protocol
|
|
5576
|
+
# — never guess an account), but since a growing DECIDED file also
|
|
5577
|
+
# consults auth.json, a truncated/half-written auth.json now halts every
|
|
5578
|
+
# rollout under that root, not just the never-decided ones. `cache-sync`
|
|
5579
|
+
# still exits 0, so without a durable record the operator sees Codex
|
|
5580
|
+
# spend and quota silently freeze. This marker is what `doctor` reads.
|
|
5581
|
+
#
|
|
5582
|
+
# Whole-tree syncs only: a targeted (`only_paths`) call looks at a
|
|
5583
|
+
# handful of files, so its zero deferral count says nothing about the
|
|
5584
|
+
# rest of the tree and must never clear the marker.
|
|
5585
|
+
#
|
|
5586
|
+
# NOT R8-gated. This is a health signal, not account decoration — it
|
|
5587
|
+
# names no account and adds no per-account column, the same carve-out
|
|
5588
|
+
# `alerts.log`'s runtime state has (docs/accounts-gotchas.md).
|
|
5589
|
+
if not targeted:
|
|
5590
|
+
if stats.files_deferred_torn:
|
|
5591
|
+
_set_cache_meta(conn, "codex_torn_auth_deferred", json.dumps({
|
|
5592
|
+
"files": stats.files_deferred_torn,
|
|
5593
|
+
"at": dt.datetime.now(dt.timezone.utc).isoformat(
|
|
5594
|
+
timespec="seconds").replace("+00:00", "Z"),
|
|
5595
|
+
}, sort_keys=True))
|
|
5596
|
+
else:
|
|
5597
|
+
conn.execute("DELETE FROM cache_meta WHERE key = ?",
|
|
5598
|
+
("codex_torn_auth_deferred",))
|
|
5599
|
+
conn.commit()
|
|
4905
5600
|
# Codex creates/extends cache.db sidecars independently of Claude's
|
|
4906
5601
|
# sync path. Harden them while both cache flocks are still held and
|
|
4907
5602
|
# after all Codex writes, before the optional checkpoint can rotate a
|
|
@@ -5856,6 +6551,238 @@ def open_cache_db() -> sqlite3.Connection:
|
|
|
5856
6551
|
return conn
|
|
5857
6552
|
|
|
5858
6553
|
|
|
6554
|
+
_CONVERSATION_RECOVERY_STATE_VERSION = 1
|
|
6555
|
+
_CONVERSATION_PROVIDERS = ("claude", "codex")
|
|
6556
|
+
_CONVERSATION_RECOVERY_PHASES = ("confirmed", "quarantined")
|
|
6557
|
+
_CONVERSATION_PROBE_PREFIX = ".conversations-probe-"
|
|
6558
|
+
_CONVERSATION_PROBE_CLONE_TIMEOUT_SECONDS = 5.0
|
|
6559
|
+
|
|
6560
|
+
|
|
6561
|
+
def _conversation_recovery_state_path() -> pathlib.Path:
|
|
6562
|
+
path = pathlib.Path(_cctally_core.CONVERSATIONS_DB_PATH)
|
|
6563
|
+
return path.with_name(f"{path.name}.recovery.json")
|
|
6564
|
+
|
|
6565
|
+
|
|
6566
|
+
def _normalize_conversation_providers(
|
|
6567
|
+
providers: "tuple[str, ...] | list[str]",
|
|
6568
|
+
) -> tuple[str, ...]:
|
|
6569
|
+
selected = tuple(
|
|
6570
|
+
provider for provider in _CONVERSATION_PROVIDERS
|
|
6571
|
+
if provider in providers
|
|
6572
|
+
)
|
|
6573
|
+
if (
|
|
6574
|
+
not selected
|
|
6575
|
+
or len(selected) != len(set(providers))
|
|
6576
|
+
or set(selected) != set(providers)
|
|
6577
|
+
):
|
|
6578
|
+
raise ValueError("conversation recovery providers are invalid")
|
|
6579
|
+
return selected
|
|
6580
|
+
|
|
6581
|
+
|
|
6582
|
+
def _load_conversation_recovery_state() -> "dict[str, Any] | None":
|
|
6583
|
+
path = _conversation_recovery_state_path()
|
|
6584
|
+
try:
|
|
6585
|
+
payload = json.loads(path.read_text())
|
|
6586
|
+
except FileNotFoundError:
|
|
6587
|
+
return None
|
|
6588
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
6589
|
+
raise sqlite3.DatabaseError(
|
|
6590
|
+
f"conversations.db recovery state is unreadable: {exc}"
|
|
6591
|
+
) from exc
|
|
6592
|
+
if (
|
|
6593
|
+
not isinstance(payload, dict)
|
|
6594
|
+
or payload.get("schemaVersion") != _CONVERSATION_RECOVERY_STATE_VERSION
|
|
6595
|
+
or not isinstance(payload.get("providers"), list)
|
|
6596
|
+
or payload.get("phase") not in _CONVERSATION_RECOVERY_PHASES
|
|
6597
|
+
):
|
|
6598
|
+
raise sqlite3.DatabaseError(
|
|
6599
|
+
f"conversations.db recovery state is invalid: {path}"
|
|
6600
|
+
)
|
|
6601
|
+
try:
|
|
6602
|
+
providers = _normalize_conversation_providers(payload["providers"])
|
|
6603
|
+
except ValueError as exc:
|
|
6604
|
+
raise sqlite3.DatabaseError(
|
|
6605
|
+
f"conversations.db recovery state is invalid: {path}"
|
|
6606
|
+
) from exc
|
|
6607
|
+
payload["providers"] = list(providers)
|
|
6608
|
+
return payload
|
|
6609
|
+
|
|
6610
|
+
|
|
6611
|
+
def _write_conversation_recovery_state(
|
|
6612
|
+
*,
|
|
6613
|
+
providers: tuple[str, ...],
|
|
6614
|
+
phase: str,
|
|
6615
|
+
forensics_path: "pathlib.Path | None" = None,
|
|
6616
|
+
quarantine_dir: "pathlib.Path | None" = None,
|
|
6617
|
+
) -> None:
|
|
6618
|
+
if phase not in _CONVERSATION_RECOVERY_PHASES:
|
|
6619
|
+
raise ValueError("conversation recovery phase is invalid")
|
|
6620
|
+
payload: dict[str, Any] = {
|
|
6621
|
+
"schemaVersion": _CONVERSATION_RECOVERY_STATE_VERSION,
|
|
6622
|
+
"providers": list(_normalize_conversation_providers(providers)),
|
|
6623
|
+
"phase": phase,
|
|
6624
|
+
}
|
|
6625
|
+
if forensics_path is not None:
|
|
6626
|
+
payload["forensicsPath"] = str(forensics_path)
|
|
6627
|
+
if quarantine_dir is not None:
|
|
6628
|
+
payload["quarantineDir"] = str(quarantine_dir)
|
|
6629
|
+
_cctally_db_sib._atomic_write_private_json(
|
|
6630
|
+
_conversation_recovery_state_path(), payload,
|
|
6631
|
+
)
|
|
6632
|
+
|
|
6633
|
+
|
|
6634
|
+
def _clear_conversation_recovery_state() -> None:
|
|
6635
|
+
path = _conversation_recovery_state_path()
|
|
6636
|
+
try:
|
|
6637
|
+
path.unlink()
|
|
6638
|
+
except FileNotFoundError:
|
|
6639
|
+
return
|
|
6640
|
+
_cctally_db_sib._fsync_directory(path.parent)
|
|
6641
|
+
|
|
6642
|
+
|
|
6643
|
+
def _release_conversation_provider_locks(lock_files: list[Any]) -> None:
|
|
6644
|
+
for lock_fh in reversed(lock_files):
|
|
6645
|
+
try:
|
|
6646
|
+
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
6647
|
+
except OSError:
|
|
6648
|
+
pass
|
|
6649
|
+
lock_fh.close()
|
|
6650
|
+
|
|
6651
|
+
|
|
6652
|
+
def _acquire_conversation_provider_locks(
|
|
6653
|
+
*, timeout: "float | None",
|
|
6654
|
+
) -> "list[Any] | None":
|
|
6655
|
+
lock_files: list[Any] = []
|
|
6656
|
+
try:
|
|
6657
|
+
for path in (
|
|
6658
|
+
_cctally_core.CONVERSATIONS_LOCK_PATH,
|
|
6659
|
+
_cctally_core.CONVERSATIONS_LOCK_CODEX_PATH,
|
|
6660
|
+
):
|
|
6661
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
6662
|
+
path.touch()
|
|
6663
|
+
lock_fh = open(path, "a+")
|
|
6664
|
+
lock_files.append(lock_fh)
|
|
6665
|
+
if not _acquire_cache_flock(lock_fh, timeout=timeout):
|
|
6666
|
+
_release_conversation_provider_locks(lock_files)
|
|
6667
|
+
return None
|
|
6668
|
+
return lock_files
|
|
6669
|
+
except BaseException:
|
|
6670
|
+
_release_conversation_provider_locks(lock_files)
|
|
6671
|
+
raise
|
|
6672
|
+
|
|
6673
|
+
|
|
6674
|
+
def _conversations_open_guarded(
|
|
6675
|
+
*, attach_cache: bool, allow_recovery_state: bool = False,
|
|
6676
|
+
) -> sqlite3.Connection:
|
|
6677
|
+
"""Open conversations.db while excluding confirmed family replacement."""
|
|
6678
|
+
path = pathlib.Path(_cctally_core.CONVERSATIONS_DB_PATH)
|
|
6679
|
+
marker = _cctally_db_sib._repair_marker_path(path)
|
|
6680
|
+
pending = _cctally_db_sib._quarantine_pending_path(path)
|
|
6681
|
+
recovery = _conversation_recovery_state_path()
|
|
6682
|
+
lock_path = pathlib.Path(
|
|
6683
|
+
_cctally_core.CONVERSATIONS_LOCK_MAINTENANCE_PATH
|
|
6684
|
+
)
|
|
6685
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
6686
|
+
lock_fh = open(lock_path, "a+")
|
|
6687
|
+
try:
|
|
6688
|
+
for _attempt in range(2):
|
|
6689
|
+
fcntl.flock(lock_fh, fcntl.LOCK_SH)
|
|
6690
|
+
if recovery.exists() and not allow_recovery_state:
|
|
6691
|
+
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
6692
|
+
raise sqlite3.DatabaseError(
|
|
6693
|
+
"conversations.db recovery is incomplete; "
|
|
6694
|
+
"run `cctally cache-sync --rebuild`"
|
|
6695
|
+
)
|
|
6696
|
+
if marker.exists() or pending.exists():
|
|
6697
|
+
live, reason = (
|
|
6698
|
+
_cctally_db_sib._repair_marker_is_live(marker)
|
|
6699
|
+
if marker.exists()
|
|
6700
|
+
else (False, "pending quarantine")
|
|
6701
|
+
)
|
|
6702
|
+
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
6703
|
+
if live:
|
|
6704
|
+
raise sqlite3.DatabaseError(
|
|
6705
|
+
"conversations.db maintenance is in progress "
|
|
6706
|
+
f"({reason})"
|
|
6707
|
+
)
|
|
6708
|
+
fcntl.flock(lock_fh, fcntl.LOCK_EX)
|
|
6709
|
+
provider_locks: list[Any] | None = None
|
|
6710
|
+
try:
|
|
6711
|
+
if marker.exists():
|
|
6712
|
+
live, reason = (
|
|
6713
|
+
_cctally_db_sib._repair_marker_is_live(marker)
|
|
6714
|
+
)
|
|
6715
|
+
if live:
|
|
6716
|
+
raise sqlite3.DatabaseError(
|
|
6717
|
+
"conversations.db maintenance is in progress "
|
|
6718
|
+
f"({reason})"
|
|
6719
|
+
)
|
|
6720
|
+
provider_locks = _acquire_conversation_provider_locks(
|
|
6721
|
+
timeout=None,
|
|
6722
|
+
)
|
|
6723
|
+
if provider_locks is None:
|
|
6724
|
+
raise sqlite3.DatabaseError(
|
|
6725
|
+
"conversations.db pending recovery could not claim "
|
|
6726
|
+
"both provider locks; retry shortly"
|
|
6727
|
+
)
|
|
6728
|
+
if pending.exists():
|
|
6729
|
+
open_pids = _cctally_db_sib._db_family_open_pids(path)
|
|
6730
|
+
if open_pids is None:
|
|
6731
|
+
raise sqlite3.DatabaseError(
|
|
6732
|
+
"conversations.db pending recovery could not "
|
|
6733
|
+
"verify that the family has no open handles"
|
|
6734
|
+
)
|
|
6735
|
+
if open_pids:
|
|
6736
|
+
raise sqlite3.DatabaseError(
|
|
6737
|
+
"conversations.db pending recovery found open "
|
|
6738
|
+
"handles in process(es) "
|
|
6739
|
+
+ ", ".join(
|
|
6740
|
+
str(pid) for pid in sorted(open_pids)
|
|
6741
|
+
)
|
|
6742
|
+
)
|
|
6743
|
+
_cctally_db_sib.quarantine_db_family(
|
|
6744
|
+
path, strict=True,
|
|
6745
|
+
)
|
|
6746
|
+
removed, reclaim_reason = (
|
|
6747
|
+
_cctally_db_sib._remove_stale_repair_marker(path)
|
|
6748
|
+
)
|
|
6749
|
+
if not removed:
|
|
6750
|
+
raise sqlite3.DatabaseError(
|
|
6751
|
+
"conversations.db maintenance is in progress: "
|
|
6752
|
+
f"{reclaim_reason}"
|
|
6753
|
+
)
|
|
6754
|
+
finally:
|
|
6755
|
+
if provider_locks is not None:
|
|
6756
|
+
_release_conversation_provider_locks(provider_locks)
|
|
6757
|
+
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
6758
|
+
continue
|
|
6759
|
+
conn: sqlite3.Connection | None = None
|
|
6760
|
+
try:
|
|
6761
|
+
conn = _open_conversations_db_unlocked(
|
|
6762
|
+
attach_cache=attach_cache,
|
|
6763
|
+
)
|
|
6764
|
+
if marker.exists() or pending.exists():
|
|
6765
|
+
conn.close()
|
|
6766
|
+
conn = None
|
|
6767
|
+
raise sqlite3.DatabaseError(
|
|
6768
|
+
"conversations.db maintenance started during open"
|
|
6769
|
+
)
|
|
6770
|
+
if recovery.exists() and not allow_recovery_state:
|
|
6771
|
+
conn.close()
|
|
6772
|
+
conn = None
|
|
6773
|
+
raise sqlite3.DatabaseError(
|
|
6774
|
+
"conversations.db recovery became incomplete during open"
|
|
6775
|
+
)
|
|
6776
|
+
return conn
|
|
6777
|
+
finally:
|
|
6778
|
+
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
6779
|
+
raise sqlite3.DatabaseError(
|
|
6780
|
+
"conversations.db stale recovery state could not be reclaimed"
|
|
6781
|
+
)
|
|
6782
|
+
finally:
|
|
6783
|
+
lock_fh.close()
|
|
6784
|
+
|
|
6785
|
+
|
|
5859
6786
|
def _harden_conversation_sidecars() -> None:
|
|
5860
6787
|
"""Best-effort 0600 on conversations.db and its WAL sidecars."""
|
|
5861
6788
|
base = str(_cctally_core.CONVERSATIONS_DB_PATH)
|
|
@@ -5869,7 +6796,9 @@ def _harden_conversation_sidecars() -> None:
|
|
|
5869
6796
|
)
|
|
5870
6797
|
|
|
5871
6798
|
|
|
5872
|
-
def
|
|
6799
|
+
def _open_conversations_db_unlocked(
|
|
6800
|
+
*, attach_cache: bool = True,
|
|
6801
|
+
) -> sqlite3.Connection:
|
|
5873
6802
|
"""Open the independent transcript/search store (#320).
|
|
5874
6803
|
|
|
5875
6804
|
``conversations.db`` is the main schema. Conversation readers optionally
|
|
@@ -5956,6 +6885,371 @@ def open_conversations_db(*, attach_cache: bool = True) -> sqlite3.Connection:
|
|
|
5956
6885
|
return conn
|
|
5957
6886
|
|
|
5958
6887
|
|
|
6888
|
+
def open_conversations_db(*, attach_cache: bool = True) -> sqlite3.Connection:
|
|
6889
|
+
return _conversations_open_guarded(attach_cache=attach_cache)
|
|
6890
|
+
|
|
6891
|
+
|
|
6892
|
+
def _open_conversations_db_for_recovery(
|
|
6893
|
+
*, attach_cache: bool = True,
|
|
6894
|
+
) -> sqlite3.Connection:
|
|
6895
|
+
"""Open only for the explicit provider plan retained in recovery.json."""
|
|
6896
|
+
return _conversations_open_guarded(
|
|
6897
|
+
attach_cache=attach_cache,
|
|
6898
|
+
allow_recovery_state=True,
|
|
6899
|
+
)
|
|
6900
|
+
|
|
6901
|
+
|
|
6902
|
+
def _conversation_recovery_test_pause(phase: str) -> None:
|
|
6903
|
+
"""Pytest-only kill seam for the durable recovery-state transitions."""
|
|
6904
|
+
if not os.environ.get("PYTEST_CURRENT_TEST"):
|
|
6905
|
+
return
|
|
6906
|
+
if os.environ.get("CCTALLY_TEST_CONVERSATION_RECOVERY_STALL") != phase:
|
|
6907
|
+
return
|
|
6908
|
+
while True:
|
|
6909
|
+
time.sleep(0.05)
|
|
6910
|
+
|
|
6911
|
+
|
|
6912
|
+
@contextlib.contextmanager
|
|
6913
|
+
def _conversation_probe_snapshot(path: pathlib.Path):
|
|
6914
|
+
"""Yield an isolated main/WAL copy so probes never mutate live sidecars."""
|
|
6915
|
+
for stale in path.parent.glob(f"{_CONVERSATION_PROBE_PREFIX}*"):
|
|
6916
|
+
if stale.is_dir() and not stale.is_symlink():
|
|
6917
|
+
shutil.rmtree(stale)
|
|
6918
|
+
with tempfile.TemporaryDirectory(
|
|
6919
|
+
prefix=_CONVERSATION_PROBE_PREFIX,
|
|
6920
|
+
dir=path.parent,
|
|
6921
|
+
) as temp_dir:
|
|
6922
|
+
snapshot = pathlib.Path(temp_dir) / path.name
|
|
6923
|
+
_clone_conversation_probe_member(path, snapshot)
|
|
6924
|
+
wal = pathlib.Path(f"{path}-wal")
|
|
6925
|
+
if wal.exists():
|
|
6926
|
+
_clone_conversation_probe_member(
|
|
6927
|
+
wal, pathlib.Path(f"{snapshot}-wal"),
|
|
6928
|
+
)
|
|
6929
|
+
yield snapshot
|
|
6930
|
+
|
|
6931
|
+
|
|
6932
|
+
def _clone_conversation_probe_member(
|
|
6933
|
+
source: pathlib.Path,
|
|
6934
|
+
destination: pathlib.Path,
|
|
6935
|
+
) -> None:
|
|
6936
|
+
"""Bounded same-volume COW clone; never fall back to a byte copy."""
|
|
6937
|
+
cp = shutil.which("cp")
|
|
6938
|
+
if cp is None:
|
|
6939
|
+
raise OSError(
|
|
6940
|
+
"copy-on-write transcript probe unavailable: `cp` was not found"
|
|
6941
|
+
)
|
|
6942
|
+
if sys.platform == "darwin":
|
|
6943
|
+
command = [cp, "-c", str(source), str(destination)]
|
|
6944
|
+
elif sys.platform.startswith("linux"):
|
|
6945
|
+
command = [
|
|
6946
|
+
cp, "--reflink=always", "--", str(source), str(destination),
|
|
6947
|
+
]
|
|
6948
|
+
else:
|
|
6949
|
+
raise OSError(
|
|
6950
|
+
"copy-on-write transcript probe is unsupported on "
|
|
6951
|
+
f"{sys.platform}"
|
|
6952
|
+
)
|
|
6953
|
+
try:
|
|
6954
|
+
result = subprocess.run(
|
|
6955
|
+
command,
|
|
6956
|
+
stdout=subprocess.DEVNULL,
|
|
6957
|
+
stderr=subprocess.PIPE,
|
|
6958
|
+
text=True,
|
|
6959
|
+
timeout=_CONVERSATION_PROBE_CLONE_TIMEOUT_SECONDS,
|
|
6960
|
+
check=False,
|
|
6961
|
+
)
|
|
6962
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
6963
|
+
raise OSError(
|
|
6964
|
+
f"copy-on-write transcript probe failed for {source.name}: {exc}"
|
|
6965
|
+
) from exc
|
|
6966
|
+
if result.returncode != 0:
|
|
6967
|
+
reason = (result.stderr or "").strip() or (
|
|
6968
|
+
f"cp exited {result.returncode}"
|
|
6969
|
+
)
|
|
6970
|
+
raise OSError(
|
|
6971
|
+
"copy-on-write transcript probe unavailable for "
|
|
6972
|
+
f"{source.name}: {reason}"
|
|
6973
|
+
)
|
|
6974
|
+
|
|
6975
|
+
|
|
6976
|
+
def _probe_conversation_rebuild(
|
|
6977
|
+
path: pathlib.Path,
|
|
6978
|
+
*,
|
|
6979
|
+
lock_timeout: "float | None",
|
|
6980
|
+
) -> "sqlite3.DatabaseError | None":
|
|
6981
|
+
"""Quick-check under every replacement exclusion lock, preserving sidecars."""
|
|
6982
|
+
maintenance_path = pathlib.Path(
|
|
6983
|
+
_cctally_core.CONVERSATIONS_LOCK_MAINTENANCE_PATH
|
|
6984
|
+
)
|
|
6985
|
+
maintenance_path.parent.mkdir(parents=True, exist_ok=True)
|
|
6986
|
+
maintenance_path.touch()
|
|
6987
|
+
maintenance_fh = open(maintenance_path, "a+")
|
|
6988
|
+
provider_locks: list[Any] | None = None
|
|
6989
|
+
probe: sqlite3.Connection | None = None
|
|
6990
|
+
try:
|
|
6991
|
+
if not _acquire_cache_flock(
|
|
6992
|
+
maintenance_fh, timeout=lock_timeout,
|
|
6993
|
+
):
|
|
6994
|
+
raise sqlite3.DatabaseError(
|
|
6995
|
+
"conversations.db recovery could not claim the maintenance "
|
|
6996
|
+
"lock; leaving the live family untouched"
|
|
6997
|
+
)
|
|
6998
|
+
provider_locks = _acquire_conversation_provider_locks(
|
|
6999
|
+
timeout=lock_timeout,
|
|
7000
|
+
)
|
|
7001
|
+
if provider_locks is None:
|
|
7002
|
+
raise sqlite3.DatabaseError(
|
|
7003
|
+
"conversations.db recovery could not claim both provider "
|
|
7004
|
+
"locks; leaving the live family untouched"
|
|
7005
|
+
)
|
|
7006
|
+
open_pids = _cctally_db_sib._db_family_open_pids(path)
|
|
7007
|
+
if open_pids is None:
|
|
7008
|
+
raise sqlite3.DatabaseError(
|
|
7009
|
+
"conversations.db recovery cannot verify that the database "
|
|
7010
|
+
"family has no open handles; leaving it untouched"
|
|
7011
|
+
)
|
|
7012
|
+
if open_pids:
|
|
7013
|
+
raise sqlite3.DatabaseError(
|
|
7014
|
+
"conversations.db is still open in process(es) "
|
|
7015
|
+
+ ", ".join(str(pid) for pid in sorted(open_pids))
|
|
7016
|
+
+ "; leaving the live family untouched"
|
|
7017
|
+
)
|
|
7018
|
+
try:
|
|
7019
|
+
with _conversation_probe_snapshot(path) as snapshot:
|
|
7020
|
+
try:
|
|
7021
|
+
probe = sqlite3.connect(
|
|
7022
|
+
snapshot.resolve().as_uri() + "?mode=ro",
|
|
7023
|
+
uri=True,
|
|
7024
|
+
)
|
|
7025
|
+
probe.execute("PRAGMA busy_timeout=2000")
|
|
7026
|
+
row = probe.execute("PRAGMA quick_check(1)").fetchone()
|
|
7027
|
+
result = (
|
|
7028
|
+
str(row[0]) if row and row[0] is not None else ""
|
|
7029
|
+
)
|
|
7030
|
+
if result.strip().casefold() != "ok":
|
|
7031
|
+
return sqlite3.DatabaseError(
|
|
7032
|
+
"database disk image is malformed "
|
|
7033
|
+
f"(conversations.db quick_check: {result})"
|
|
7034
|
+
)
|
|
7035
|
+
return None
|
|
7036
|
+
finally:
|
|
7037
|
+
if probe is not None:
|
|
7038
|
+
probe.close()
|
|
7039
|
+
except sqlite3.DatabaseError as exc:
|
|
7040
|
+
return exc
|
|
7041
|
+
finally:
|
|
7042
|
+
if provider_locks is not None:
|
|
7043
|
+
_release_conversation_provider_locks(provider_locks)
|
|
7044
|
+
try:
|
|
7045
|
+
fcntl.flock(maintenance_fh, fcntl.LOCK_UN)
|
|
7046
|
+
except OSError:
|
|
7047
|
+
pass
|
|
7048
|
+
maintenance_fh.close()
|
|
7049
|
+
|
|
7050
|
+
|
|
7051
|
+
def _recover_corrupt_conversations(
|
|
7052
|
+
exc: sqlite3.DatabaseError,
|
|
7053
|
+
*,
|
|
7054
|
+
origin: str,
|
|
7055
|
+
providers: "tuple[str, ...] | list[str]",
|
|
7056
|
+
lock_timeout: "float | None",
|
|
7057
|
+
) -> bool:
|
|
7058
|
+
"""Confirm, preserve, and quarantine a corrupt transcript family once."""
|
|
7059
|
+
if not _cctally_db_sib._is_sqlite_corruption_error(exc):
|
|
7060
|
+
return False
|
|
7061
|
+
if not origin.strip():
|
|
7062
|
+
raise ValueError("conversation recovery origin must be non-empty")
|
|
7063
|
+
selected = _normalize_conversation_providers(providers)
|
|
7064
|
+
path = pathlib.Path(_cctally_core.CONVERSATIONS_DB_PATH)
|
|
7065
|
+
try:
|
|
7066
|
+
claim, reason = _cctally_db_sib._claim_repair_marker(path)
|
|
7067
|
+
except OSError as marker_exc:
|
|
7068
|
+
raise sqlite3.DatabaseError(
|
|
7069
|
+
"conversations.db recovery could not claim maintenance: "
|
|
7070
|
+
f"{marker_exc}"
|
|
7071
|
+
) from exc
|
|
7072
|
+
if claim is None:
|
|
7073
|
+
raise sqlite3.DatabaseError(
|
|
7074
|
+
f"conversations.db maintenance is in progress: {reason}"
|
|
7075
|
+
) from exc
|
|
7076
|
+
|
|
7077
|
+
lock_fh = None
|
|
7078
|
+
provider_locks: list[Any] | None = None
|
|
7079
|
+
try:
|
|
7080
|
+
lock_path = pathlib.Path(
|
|
7081
|
+
_cctally_core.CONVERSATIONS_LOCK_MAINTENANCE_PATH
|
|
7082
|
+
)
|
|
7083
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
7084
|
+
lock_fh = open(lock_path, "a+")
|
|
7085
|
+
if not _acquire_cache_flock(lock_fh, timeout=lock_timeout):
|
|
7086
|
+
raise sqlite3.DatabaseError(
|
|
7087
|
+
"conversations.db recovery could not claim the maintenance "
|
|
7088
|
+
"lock; leaving the live family untouched"
|
|
7089
|
+
) from exc
|
|
7090
|
+
provider_locks = _acquire_conversation_provider_locks(
|
|
7091
|
+
timeout=lock_timeout,
|
|
7092
|
+
)
|
|
7093
|
+
if provider_locks is None:
|
|
7094
|
+
raise sqlite3.DatabaseError(
|
|
7095
|
+
"conversations.db recovery could not claim both provider "
|
|
7096
|
+
"locks; leaving the live family untouched"
|
|
7097
|
+
) from exc
|
|
7098
|
+
open_pids = _cctally_db_sib._db_family_open_pids(path)
|
|
7099
|
+
if open_pids is None:
|
|
7100
|
+
raise sqlite3.DatabaseError(
|
|
7101
|
+
"conversations.db recovery cannot verify that the database "
|
|
7102
|
+
"family has no open handles; leaving it untouched"
|
|
7103
|
+
) from exc
|
|
7104
|
+
if open_pids:
|
|
7105
|
+
raise sqlite3.DatabaseError(
|
|
7106
|
+
"conversations.db is still open in process(es) "
|
|
7107
|
+
+ ", ".join(str(pid) for pid in sorted(open_pids))
|
|
7108
|
+
+ "; leaving the live family untouched"
|
|
7109
|
+
) from exc
|
|
7110
|
+
|
|
7111
|
+
try:
|
|
7112
|
+
with _conversation_probe_snapshot(path) as snapshot:
|
|
7113
|
+
forensics = _cctally_db_sib.write_corruption_forensics(
|
|
7114
|
+
path,
|
|
7115
|
+
probe_db_path=snapshot,
|
|
7116
|
+
db_label="conversations",
|
|
7117
|
+
trigger_origin=origin,
|
|
7118
|
+
trigger_exception=exc,
|
|
7119
|
+
return_result=True,
|
|
7120
|
+
)
|
|
7121
|
+
except Exception as forensics_exc:
|
|
7122
|
+
eprint(
|
|
7123
|
+
"[conversations] destructive recovery declined for classified "
|
|
7124
|
+
f"trigger at {origin}: forensics was unavailable "
|
|
7125
|
+
f"({forensics_exc}); leaving the conversations.db family "
|
|
7126
|
+
"untouched"
|
|
7127
|
+
)
|
|
7128
|
+
return False
|
|
7129
|
+
assert isinstance(
|
|
7130
|
+
forensics, _cctally_db_sib.CorruptionForensicsResult,
|
|
7131
|
+
)
|
|
7132
|
+
if (
|
|
7133
|
+
forensics.disposition
|
|
7134
|
+
is not _cctally_db_sib.CorruptionProbeDisposition.CONFIRMED
|
|
7135
|
+
or forensics.path is None
|
|
7136
|
+
):
|
|
7137
|
+
bundle = (
|
|
7138
|
+
str(forensics.path)
|
|
7139
|
+
if forensics.path is not None
|
|
7140
|
+
else "unavailable"
|
|
7141
|
+
)
|
|
7142
|
+
eprint(
|
|
7143
|
+
"[conversations] destructive recovery declined for classified "
|
|
7144
|
+
f"trigger at {origin}: corruption was not confirmed "
|
|
7145
|
+
f"({forensics.reason}; forensics: {bundle}); leaving the "
|
|
7146
|
+
"conversations.db family untouched"
|
|
7147
|
+
)
|
|
7148
|
+
return False
|
|
7149
|
+
|
|
7150
|
+
_write_conversation_recovery_state(
|
|
7151
|
+
providers=selected,
|
|
7152
|
+
phase="confirmed",
|
|
7153
|
+
forensics_path=forensics.path,
|
|
7154
|
+
)
|
|
7155
|
+
_conversation_recovery_test_pause("confirmed")
|
|
7156
|
+
try:
|
|
7157
|
+
incident = _cctally_db_sib.quarantine_db_family(
|
|
7158
|
+
path, strict=True,
|
|
7159
|
+
)
|
|
7160
|
+
except OSError as quarantine_exc:
|
|
7161
|
+
raise sqlite3.DatabaseError(
|
|
7162
|
+
"conversations.db recovery could not complete whole-family "
|
|
7163
|
+
f"quarantine: {quarantine_exc}"
|
|
7164
|
+
) from exc
|
|
7165
|
+
_write_conversation_recovery_state(
|
|
7166
|
+
providers=selected,
|
|
7167
|
+
phase="quarantined",
|
|
7168
|
+
forensics_path=forensics.path,
|
|
7169
|
+
quarantine_dir=incident,
|
|
7170
|
+
)
|
|
7171
|
+
_conversation_recovery_test_pause("quarantined")
|
|
7172
|
+
eprint(
|
|
7173
|
+
f"[conversations] corrupt transcript DB ({exc}); quarantined its "
|
|
7174
|
+
f"file family under {incident} and rebuilding both requested "
|
|
7175
|
+
"provider transcript sets"
|
|
7176
|
+
)
|
|
7177
|
+
return True
|
|
7178
|
+
finally:
|
|
7179
|
+
if provider_locks is not None:
|
|
7180
|
+
_release_conversation_provider_locks(provider_locks)
|
|
7181
|
+
if lock_fh is not None:
|
|
7182
|
+
try:
|
|
7183
|
+
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
7184
|
+
finally:
|
|
7185
|
+
lock_fh.close()
|
|
7186
|
+
_cctally_db_sib._release_repair_marker(path, claim)
|
|
7187
|
+
|
|
7188
|
+
|
|
7189
|
+
def _prepare_conversation_rebuild(
|
|
7190
|
+
providers: "tuple[str, ...] | list[str]",
|
|
7191
|
+
*,
|
|
7192
|
+
lock_timeout: "float | None",
|
|
7193
|
+
) -> tuple[str, ...]:
|
|
7194
|
+
"""Resume durable recovery intent and make the transcript store openable."""
|
|
7195
|
+
selected = _normalize_conversation_providers(providers)
|
|
7196
|
+
state = _load_conversation_recovery_state()
|
|
7197
|
+
if state is not None:
|
|
7198
|
+
selected = _normalize_conversation_providers(
|
|
7199
|
+
[*selected, *(
|
|
7200
|
+
provider for provider in state["providers"]
|
|
7201
|
+
if provider not in selected
|
|
7202
|
+
)],
|
|
7203
|
+
)
|
|
7204
|
+
path = pathlib.Path(_cctally_core.CONVERSATIONS_DB_PATH)
|
|
7205
|
+
trigger: sqlite3.DatabaseError | None = None
|
|
7206
|
+
if _cctally_db_sib._quarantine_pending_path(path).exists():
|
|
7207
|
+
conn = _open_conversations_db_for_recovery()
|
|
7208
|
+
conn.close()
|
|
7209
|
+
return selected
|
|
7210
|
+
if path.exists():
|
|
7211
|
+
trigger = _probe_conversation_rebuild(
|
|
7212
|
+
path, lock_timeout=lock_timeout,
|
|
7213
|
+
)
|
|
7214
|
+
if trigger is not None:
|
|
7215
|
+
if not _recover_corrupt_conversations(
|
|
7216
|
+
trigger,
|
|
7217
|
+
origin="cache_sync.cli.conversations.open",
|
|
7218
|
+
providers=selected,
|
|
7219
|
+
lock_timeout=lock_timeout,
|
|
7220
|
+
):
|
|
7221
|
+
raise trigger
|
|
7222
|
+
state = _load_conversation_recovery_state()
|
|
7223
|
+
if state is not None:
|
|
7224
|
+
selected = _normalize_conversation_providers(
|
|
7225
|
+
state["providers"],
|
|
7226
|
+
)
|
|
7227
|
+
conn = _open_conversations_db_for_recovery()
|
|
7228
|
+
conn.close()
|
|
7229
|
+
return selected
|
|
7230
|
+
|
|
7231
|
+
|
|
7232
|
+
def _complete_conversation_recovery_if_ready() -> None:
|
|
7233
|
+
state = _load_conversation_recovery_state()
|
|
7234
|
+
if state is None:
|
|
7235
|
+
return
|
|
7236
|
+
conn = _open_conversations_db_for_recovery(attach_cache=False)
|
|
7237
|
+
try:
|
|
7238
|
+
keys = tuple(
|
|
7239
|
+
f"conversation_rebuild_{provider}_pending"
|
|
7240
|
+
for provider in state["providers"]
|
|
7241
|
+
)
|
|
7242
|
+
placeholders = ",".join("?" for _ in keys)
|
|
7243
|
+
pending = conn.execute(
|
|
7244
|
+
f"SELECT 1 FROM cache_meta WHERE key IN ({placeholders}) LIMIT 1",
|
|
7245
|
+
keys,
|
|
7246
|
+
).fetchone()
|
|
7247
|
+
finally:
|
|
7248
|
+
conn.close()
|
|
7249
|
+
if pending is None:
|
|
7250
|
+
_clear_conversation_recovery_state()
|
|
7251
|
+
|
|
7252
|
+
|
|
5959
7253
|
def read_session_titles_bounded(
|
|
5960
7254
|
session_ids,
|
|
5961
7255
|
*,
|
|
@@ -5983,14 +7277,37 @@ def read_session_titles_bounded(
|
|
|
5983
7277
|
if not ids:
|
|
5984
7278
|
return {}
|
|
5985
7279
|
path = _cctally_core.CONVERSATIONS_DB_PATH
|
|
7280
|
+
marker = _cctally_db_sib._repair_marker_path(path)
|
|
7281
|
+
pending = _cctally_db_sib._quarantine_pending_path(path)
|
|
7282
|
+
recovery = _conversation_recovery_state_path()
|
|
7283
|
+
maintenance_path = pathlib.Path(
|
|
7284
|
+
_cctally_core.CONVERSATIONS_LOCK_MAINTENANCE_PATH
|
|
7285
|
+
)
|
|
7286
|
+
maintenance_fh = None
|
|
5986
7287
|
try:
|
|
5987
|
-
if
|
|
7288
|
+
if (
|
|
7289
|
+
not path.is_file()
|
|
7290
|
+
or marker.exists()
|
|
7291
|
+
or pending.exists()
|
|
7292
|
+
or recovery.exists()
|
|
7293
|
+
or not maintenance_path.is_file()
|
|
7294
|
+
):
|
|
5988
7295
|
return {}
|
|
5989
7296
|
uri = f"{path.resolve().as_uri()}?mode=ro"
|
|
5990
7297
|
except OSError:
|
|
5991
7298
|
return {}
|
|
5992
7299
|
conn: sqlite3.Connection | None = None
|
|
5993
7300
|
try:
|
|
7301
|
+
maintenance_fh = open(maintenance_path, "r")
|
|
7302
|
+
try:
|
|
7303
|
+
fcntl.flock(
|
|
7304
|
+
maintenance_fh,
|
|
7305
|
+
fcntl.LOCK_SH | fcntl.LOCK_NB,
|
|
7306
|
+
)
|
|
7307
|
+
except BlockingIOError:
|
|
7308
|
+
return {}
|
|
7309
|
+
if marker.exists() or pending.exists() or recovery.exists():
|
|
7310
|
+
return {}
|
|
5994
7311
|
conn = sqlite3.connect(uri, uri=True, timeout=max(timeout_s, 0.0))
|
|
5995
7312
|
return dict(
|
|
5996
7313
|
_load_lib("_lib_conversation_query").session_titles_indexed_map(
|
|
@@ -6005,6 +7322,12 @@ def read_session_titles_bounded(
|
|
|
6005
7322
|
conn.close()
|
|
6006
7323
|
except sqlite3.Error:
|
|
6007
7324
|
pass
|
|
7325
|
+
if maintenance_fh is not None:
|
|
7326
|
+
try:
|
|
7327
|
+
fcntl.flock(maintenance_fh, fcntl.LOCK_UN)
|
|
7328
|
+
except OSError:
|
|
7329
|
+
pass
|
|
7330
|
+
maintenance_fh.close()
|
|
6008
7331
|
|
|
6009
7332
|
|
|
6010
7333
|
def _import_legacy_conversation_rows(conn: sqlite3.Connection) -> None:
|
|
@@ -6926,7 +8249,7 @@ def _run_transcript_rebuild_worker(
|
|
|
6926
8249
|
try:
|
|
6927
8250
|
emit({"event": "progress", "phase": "open", "filesDone": 0,
|
|
6928
8251
|
"filesTotal": 0})
|
|
6929
|
-
conn =
|
|
8252
|
+
conn = _open_conversations_db_for_recovery()
|
|
6930
8253
|
emit({"event": "progress", "phase": "sync-start", "filesDone": 0,
|
|
6931
8254
|
"filesTotal": 0})
|
|
6932
8255
|
sync = (
|
|
@@ -7294,6 +8617,18 @@ def cmd_cache_sync(args: argparse.Namespace) -> int:
|
|
|
7294
8617
|
f"{stats.lines_malformed} malformed, "
|
|
7295
8618
|
f"{stats.token_events_skipped} drift-skipped"
|
|
7296
8619
|
)
|
|
8620
|
+
# #416 review B4: emitted on EVERY branch (including a contended or
|
|
8621
|
+
# incomplete rebuild) — a deferral means Codex spend and quota stopped
|
|
8622
|
+
# updating, which the "done" line's zeroes look identical to. Exit code
|
|
8623
|
+
# stays 0: the defer is the correct conservative behaviour, not a
|
|
8624
|
+
# failure, and the condition clears itself once auth.json reads cleanly.
|
|
8625
|
+
if stats.files_deferred_torn:
|
|
8626
|
+
eprint(
|
|
8627
|
+
f"[cache-sync] codex: {stats.files_deferred_torn} file(s) "
|
|
8628
|
+
"deferred — a Codex auth.json read torn (truncated or "
|
|
8629
|
+
"half-written); no usage was attributed from them. Re-run "
|
|
8630
|
+
"`codex login` if it stays this way; `cctally doctor` reports it."
|
|
8631
|
+
)
|
|
7297
8632
|
|
|
7298
8633
|
conn.close()
|
|
7299
8634
|
|
|
@@ -7309,6 +8644,21 @@ def cmd_cache_sync(args: argparse.Namespace) -> int:
|
|
|
7309
8644
|
for provider in ("claude", "codex")
|
|
7310
8645
|
if source in (provider, "all")
|
|
7311
8646
|
]
|
|
8647
|
+
try:
|
|
8648
|
+
providers = list(_prepare_conversation_rebuild(
|
|
8649
|
+
providers, lock_timeout=lt,
|
|
8650
|
+
))
|
|
8651
|
+
except (OSError, sqlite3.DatabaseError) as exc:
|
|
8652
|
+
eprint(
|
|
8653
|
+
"[cache-sync] transcript rebuild failed: "
|
|
8654
|
+
"store=conversations.db phase=recovery "
|
|
8655
|
+
f"({exc}); core accounting/quota sync is complete. "
|
|
8656
|
+
"Re-run `cctally cache-sync --rebuild`."
|
|
8657
|
+
)
|
|
8658
|
+
_p_root.__exit__(None, None, None)
|
|
8659
|
+
if _perf.enabled():
|
|
8660
|
+
_perf.flush_stderr(_perf.current_root())
|
|
8661
|
+
return 1
|
|
7312
8662
|
for provider in providers:
|
|
7313
8663
|
outcome = _run_transcript_rebuild_worker(
|
|
7314
8664
|
provider, lock_timeout=lt
|
|
@@ -7365,6 +8715,19 @@ def cmd_cache_sync(args: argparse.Namespace) -> int:
|
|
|
7365
8715
|
f"{conv_stats.files_processed} processed, "
|
|
7366
8716
|
f"{conv_stats.files_skipped_unchanged} skipped"
|
|
7367
8717
|
)
|
|
8718
|
+
try:
|
|
8719
|
+
_complete_conversation_recovery_if_ready()
|
|
8720
|
+
except (OSError, sqlite3.DatabaseError) as exc:
|
|
8721
|
+
eprint(
|
|
8722
|
+
"[cache-sync] transcript rebuild incomplete: "
|
|
8723
|
+
"store=conversations.db phase=finalize "
|
|
8724
|
+
f"({exc}); core accounting/quota sync is complete. "
|
|
8725
|
+
"Re-run `cctally cache-sync --rebuild`."
|
|
8726
|
+
)
|
|
8727
|
+
_p_root.__exit__(None, None, None)
|
|
8728
|
+
if _perf.enabled():
|
|
8729
|
+
_perf.flush_stderr(_perf.current_root())
|
|
8730
|
+
return 1
|
|
7368
8731
|
else:
|
|
7369
8732
|
try:
|
|
7370
8733
|
conversation_conn = open_conversations_db()
|