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.
@@ -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
- + _SESSION_ENTRY_SET + _SESSION_ENTRY_GUARD)
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
- # #341: resolve this root's active account (per-root auth.json
4622
- # stable-read, resolved once per sync). A torn read (auth.json
4623
- # mid-rewrite) DEFERS the whole file this cycle skip its new bytes
4624
- # WITHOUT advancing the cursor, so the next sync re-reads and
4625
- # re-stamps rather than guessing an account (spec §1 stable-read
4626
- # protocol). identified -> real key; stably-absent (no auth /
4627
- # api-key mode) -> None (stamped NULL == unattributed on read).
4628
- root_account = root_accounts.get(discovered.source_root_key)
4629
- if root_account is None:
4630
- root_account = _resolve_codex_account_for_root(
4631
- discovered.provider_root)
4632
- root_accounts[discovered.source_root_key] = root_account
4633
- if root_account.status == "torn":
4634
- stats.files_deferred_torn += 1
4635
- if targeted:
4636
- stats.files_failed += 1 # §5.1 deferred → call dirty
4637
- continue
4638
- file_account_key = root_account.account_key
4639
- # First-sight registry observe, journaled DURABLY BEFORE any
4640
- # account-stamped quota obs / cache row for this account (spec §1:
4641
- # replay can never see a stamped row whose account was never
4642
- # observed). Marker-deduped; no-op for the sentinel.
4643
- _maybe_append_codex_account_observe(root_account.identity)
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
- file_account_key, # #341 trailing account_key
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
- file_account_key, # #341 trailing account_key
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 open_conversations_db(*, attach_cache: bool = True) -> sqlite3.Connection:
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 not path.is_file():
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 = open_conversations_db()
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()