cctally 1.76.0 → 1.78.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.
@@ -243,7 +243,14 @@ def _conv_row_tuple(m, path_str):
243
243
  )
244
244
 
245
245
 
246
- def _iter_sync_entries(fh, path_str, stats: "IngestStats | None" = None):
246
+ def _iter_sync_entries(
247
+ fh,
248
+ path_str,
249
+ stats: "IngestStats | None" = None,
250
+ *,
251
+ include_cost: bool = True,
252
+ include_conversations: bool = True,
253
+ ):
247
254
  """Fused single-pass sync walker (#138). Yields
248
255
  ``(byte_offset, cost_or_None, msgrow_or_None, aititle_or_None)`` for each
249
256
  JSONL line from ``fh``'s current position that produces a cost entry, a
@@ -293,7 +300,7 @@ def _iter_sync_entries(fh, path_str, stats: "IngestStats | None" = None):
293
300
  if stats is not None:
294
301
  stats.lines_malformed += 1
295
302
  continue
296
- cost = _lib_jsonl.parse_cost_entry(obj, path_str)
303
+ cost = _lib_jsonl.parse_cost_entry(obj, path_str) if include_cost else None
297
304
  if cost is None and stats is not None:
298
305
  # Assistant-typed line rejected for a NON-deliberate reason
299
306
  # (schema-drift tripwire; <synthetic>/non-assistant are normal).
@@ -302,8 +309,14 @@ def _iter_sync_entries(fh, path_str, stats: "IngestStats | None" = None):
302
309
  stats.assistant_lines_skipped += 1
303
310
  stats.skip_reasons[reason] = \
304
311
  stats.skip_reasons.get(reason, 0) + 1
305
- mrow = _lib_conversation.parse_message_row(obj, offset)
306
- ai = _lib_conversation.parse_ai_title(obj, offset)
312
+ mrow = (
313
+ _lib_conversation.parse_message_row(obj, offset)
314
+ if include_conversations else None
315
+ )
316
+ ai = (
317
+ _lib_conversation.parse_ai_title(obj, offset)
318
+ if include_conversations else None
319
+ )
307
320
  if cost is not None or mrow is not None or ai is not None:
308
321
  yield offset, cost, mrow, ai
309
322
 
@@ -792,13 +805,6 @@ def _delete_codex_file_derived_rows(
792
805
  "DELETE FROM codex_conversation_threads WHERE source_path = ?" + root_clause,
793
806
  params,
794
807
  )
795
- # #294 S6: normalized rows + file touches for this file. This is a PARTIAL
796
- # delete (one file), so it rides the per-row FTS delete trigger — surviving
797
- # conversations keep their postings (§3.4 two-path policy). File touches have
798
- # no source_root_key column; a source_path maps to exactly one file/root, so
799
- # scoping them by source_path alone is exact. Rollups are repaired by the
800
- # caller's _recompute_codex_rollups (§3.2), which needs the affected keys
801
- # captured BEFORE this delete.
802
808
  conn.execute(
803
809
  "DELETE FROM codex_conversation_file_touches WHERE source_path = ?",
804
810
  (path_str,),
@@ -843,9 +849,6 @@ def _clear_codex_derived_rows(conn: sqlite3.Connection) -> bool:
843
849
  conn.execute("DELETE FROM codex_conversation_events")
844
850
  conn.execute("DELETE FROM codex_session_files")
845
851
  conn.execute("DELETE FROM codex_source_roots")
846
- # #294 S6: FULL clear of the normalized derived tables (messages + touches +
847
- # rollups) via the storm-free helper — this is a whole-corpus rebuild, so
848
- # 'delete-all' resets the FTS shadow tables cleanly (§3.4 full-clear path).
849
852
  _codex_conversation_fts_full_clear(conn)
850
853
  # F3: this clears the Codex physical quota state, so any stored
851
854
  # quota-projection certificate would become stale-valid (its cache
@@ -1068,7 +1071,6 @@ def _collect_inactive_codex_paths_and_roots(
1068
1071
  "SELECT source_path, source_root_key FROM quota_window_snapshots "
1069
1072
  "WHERE source = 'codex'",
1070
1073
  "SELECT source_path, source_root_key FROM codex_conversation_threads",
1071
- "SELECT source_path, source_root_key FROM codex_conversation_events",
1072
1074
  )
1073
1075
  for query in family_queries:
1074
1076
  for source_path, root_key in conn.execute(query):
@@ -1138,10 +1140,7 @@ def _prune_inactive_codex_source_roots(
1138
1140
  SELECT 1 FROM codex_conversation_threads AS threads
1139
1141
  WHERE threads.source_root_key = roots.source_root_key
1140
1142
  )
1141
- AND NOT EXISTS (
1142
- SELECT 1 FROM codex_conversation_events AS events
1143
- WHERE events.source_root_key = roots.source_root_key
1144
- )""",
1143
+ """,
1145
1144
  tuple(params),
1146
1145
  )
1147
1146
 
@@ -1166,9 +1165,6 @@ def _write_codex_file_batch(
1166
1165
  accounting_rows: list[tuple[Any, ...]],
1167
1166
  quota_rows: list[tuple[Any, ...]],
1168
1167
  thread_rows: list[tuple[Any, ...]],
1169
- event_rows: list[tuple[Any, ...]],
1170
- normalized_rows: list,
1171
- normalized_touches: list,
1172
1168
  active_root_keys: set[str],
1173
1169
  prune_roots: bool = True,
1174
1170
  ) -> int:
@@ -1179,15 +1175,7 @@ def _write_codex_file_batch(
1179
1175
  ``codex_source_roots`` row for a root it wasn't asked about (spec §5.1
1180
1176
  whole-tree bypass — ``active_root_keys`` then covers only the targets)."""
1181
1177
  now_iso = dt.datetime.now(dt.timezone.utc).isoformat()
1182
- # #294 S6: rollups are recomputed-affected-or-deleted (§3.2), so capture the
1183
- # conversation keys this file's normalized rows touch BEFORE a reset delete
1184
- # removes them.
1185
- affected_keys: set = set()
1186
1178
  if reset_file:
1187
- affected_keys.update(
1188
- row[0] for row in conn.execute(
1189
- "SELECT DISTINCT conversation_key FROM codex_conversation_messages "
1190
- "WHERE source_path = ?", (path_str,)) if row[0])
1191
1179
  _delete_codex_file_derived_rows(conn, path_str)
1192
1180
  conn.execute(
1193
1181
  """INSERT INTO codex_source_roots
@@ -1217,8 +1205,8 @@ def _write_codex_file_batch(
1217
1205
  (source, source_root_key, source_path, line_offset,
1218
1206
  captured_at_utc, observed_slot, logical_limit_key, limit_id,
1219
1207
  limit_name, window_minutes, used_percent, resets_at_utc,
1220
- plan_type, individual_limit_json, reached_type)
1221
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
1208
+ plan_type, individual_limit_json, reached_type, observed_model)
1209
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
1222
1210
  quota_rows,
1223
1211
  )
1224
1212
  if thread_rows:
@@ -1242,25 +1230,6 @@ def _write_codex_file_batch(
1242
1230
  last_seen_utc=excluded.last_seen_utc""",
1243
1231
  [(*row, now_iso, now_iso) for row in thread_rows],
1244
1232
  )
1245
- if event_rows:
1246
- conn.executemany(
1247
- """INSERT OR IGNORE INTO codex_conversation_events
1248
- (source_path, line_offset, source_root_key, conversation_key,
1249
- native_thread_id, root_thread_id, parent_thread_id,
1250
- timestamp_utc, record_type, event_type, turn_id, call_id,
1251
- payload_json)
1252
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
1253
- event_rows,
1254
- )
1255
- # #294 S6: normalized rows + file touches ride the same per-file transaction
1256
- # as the events themselves. INSERT OR IGNORE on the physical key keeps a delta
1257
- # re-read idempotent; the per-row FTS triggers index them. Then recompute the
1258
- # rollup for every affected conversation (aggregating across all its files),
1259
- # deleting emptied rollups. Threads were inserted above, so project
1260
- # attribution can read them.
1261
- _insert_codex_normalized_rows(conn, normalized_rows, normalized_touches)
1262
- affected_keys.update(r.conversation_key for r in normalized_rows)
1263
- _recompute_codex_rollups(conn, affected_keys)
1264
1233
  conn.execute(
1265
1234
  """INSERT OR REPLACE INTO codex_session_files
1266
1235
  (path, size_bytes, mtime_ns, last_byte_offset, last_ingested_at,
@@ -1517,13 +1486,14 @@ def _reset_orphan_warning_throttle():
1517
1486
  _LAST_WARNED_ORPHAN_SET = frozenset()
1518
1487
 
1519
1488
 
1520
- # Flags whose presence means the cache is mid-migration / mid-reingest. A
1489
+ # Flags whose presence means the conversation store is mid-migration /
1490
+ # mid-reingest. A
1521
1491
  # targeted (only_paths) ingest DECLINES when any is set and defers to the next
1522
1492
  # full background sync — inserting through a half-migrated FTS shape or skipping
1523
1493
  # a pending backfill would diverge from what a full sync produces (spec §
1524
1494
  # "Targeted ingest contract"). Enumerated against the flag-consumption blocks
1525
- # guarded by the `if not rebuild and not targeted:` branch in sync_cache (the
1526
- # full-sync-only `_consume_*` calls); keep this tuple in sync with those.
1495
+ # guarded by the full-sync-only maintenance path in
1496
+ # sync_claude_conversations; keep this tuple in sync with those consumers.
1527
1497
  _TARGETED_DECLINE_FLAGS = (
1528
1498
  "conversation_backfill_pending",
1529
1499
  "ai_titles_backfill_pending",
@@ -1595,23 +1565,32 @@ def _prune_orphaned_cache_entries(conn, *, lock_timeout=None):
1595
1565
  the same turn -> deleting P's deduped cost row would lose it).
1596
1566
  Anything failing A/B/C is left as residual (reported; for `--rebuild`).
1597
1567
 
1598
- Deletes the full derived surface for the safe set in ONE transaction
1599
- (conversation_file_touches -> conversation_messages [+FTS trigger] ->
1600
- conversation_ai_titles [+FTS trigger] -> session_entries -> session_files),
1601
- then recomputes the conversation_sessions rollup for exactly the pruned
1602
- session_ids (all their messages gone -> the stale rail rows drop). Does NOT
1603
- write the walk-complete marker: the next full sync_cache re-establishes it
1604
- on a clean walk. Acquires the cache.db.lock flock itself; a contended
1605
- lock_timeout returns a `contended` result without mutating.
1568
+ Deletes the derived conversation rows first, then the core accounting rows
1569
+ in a second transaction. The ordering is deliberately failure-safe: an
1570
+ interruption may leave re-derivable transcript rows absent, but cannot
1571
+ delete accounting evidence while its conversation coverage is still the
1572
+ only proof that the orphan is safe. Recomputes conversation_sessions for
1573
+ exactly the pruned session_ids. Does NOT write the walk-complete marker:
1574
+ the next full sync_cache re-establishes it on a clean walk. Acquires both
1575
+ provider flocks itself; contention returns a `contended` result without
1576
+ mutating.
1606
1577
  """
1607
1578
  result = PruneResult()
1608
1579
  _cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
1609
1580
  _cctally_core.CACHE_LOCK_PATH.touch()
1610
1581
  lock_fh = open(_cctally_core.CACHE_LOCK_PATH, "w")
1582
+ conv_lock_fh = None
1583
+ conv = None
1611
1584
  try:
1612
1585
  if not _acquire_cache_flock(lock_fh, timeout=lock_timeout):
1613
1586
  result.contended = True
1614
1587
  return result
1588
+ _cctally_core.CONVERSATIONS_LOCK_PATH.touch()
1589
+ conv_lock_fh = open(_cctally_core.CONVERSATIONS_LOCK_PATH, "w")
1590
+ if not _acquire_cache_flock(conv_lock_fh, timeout=lock_timeout):
1591
+ result.contended = True
1592
+ return result
1593
+ conv = open_conversations_db(attach_cache=False)
1615
1594
 
1616
1595
  tracked = conn.execute(
1617
1596
  "SELECT path, size_bytes, session_id FROM session_files").fetchall()
@@ -1635,14 +1614,14 @@ def _prune_orphaned_cache_entries(conn, *, lock_timeout=None):
1635
1614
  (path,)).fetchall()
1636
1615
  ok = True
1637
1616
  for mid, rid in keys:
1638
- covered = conn.execute( # Gate B
1617
+ covered = conv.execute( # Gate B
1639
1618
  "SELECT 1 FROM conversation_messages "
1640
1619
  "WHERE msg_id=? AND req_id=? AND source_path=? LIMIT 1",
1641
1620
  (mid, rid, path)).fetchone() is not None
1642
1621
  if not covered:
1643
1622
  ok = False
1644
1623
  break
1645
- shared = conn.execute( # Gate C
1624
+ shared = conv.execute( # Gate C
1646
1625
  "SELECT source_path FROM conversation_messages "
1647
1626
  "WHERE msg_id=? AND req_id=?", (mid, rid)).fetchall()
1648
1627
  if any(sp in on_disk for (sp,) in shared):
@@ -1661,30 +1640,44 @@ def _prune_orphaned_cache_entries(conn, *, lock_timeout=None):
1661
1640
  # handful of removed files), well under SQLite's variable limit;
1662
1641
  # _recompute_conversation_sessions chunks its own session-id list.
1663
1642
  ph = ",".join("?" * len(safe_paths))
1664
- result.pruned_messages = conn.execute(
1643
+ result.pruned_messages = conv.execute(
1665
1644
  f"SELECT count(*) FROM conversation_messages WHERE source_path IN ({ph})",
1666
1645
  safe_paths).fetchone()[0]
1667
- conn.execute("BEGIN")
1646
+ conv.execute("BEGIN")
1668
1647
  try:
1669
- conn.execute(
1648
+ conv.execute(
1670
1649
  f"DELETE FROM conversation_file_touches WHERE message_id IN "
1671
1650
  f"(SELECT id FROM conversation_messages WHERE source_path IN ({ph}))",
1672
1651
  safe_paths)
1673
- conn.execute(
1652
+ conv.execute(
1674
1653
  f"DELETE FROM conversation_messages WHERE source_path IN ({ph})", safe_paths)
1675
- conn.execute(
1654
+ conv.execute(
1676
1655
  f"DELETE FROM conversation_ai_titles WHERE source_path IN ({ph})", safe_paths)
1656
+ _recompute_conversation_sessions(conv, list(pruned_sids))
1657
+ conv.commit()
1658
+ except BaseException:
1659
+ conv.rollback()
1660
+ raise
1661
+ conn.execute("BEGIN")
1662
+ try:
1677
1663
  result.pruned_entries = conn.execute(
1678
1664
  f"DELETE FROM session_entries WHERE source_path IN ({ph})", safe_paths).rowcount
1679
1665
  result.pruned_files = conn.execute(
1680
1666
  f"DELETE FROM session_files WHERE path IN ({ph})", safe_paths).rowcount
1681
- _recompute_conversation_sessions(conn, list(pruned_sids))
1682
1667
  conn.commit()
1683
1668
  except BaseException:
1684
1669
  conn.rollback()
1685
1670
  raise
1686
1671
  return result
1687
1672
  finally:
1673
+ if conv is not None:
1674
+ conv.close()
1675
+ if conv_lock_fh is not None:
1676
+ try:
1677
+ fcntl.flock(conv_lock_fh, fcntl.LOCK_UN)
1678
+ except OSError:
1679
+ pass
1680
+ conv_lock_fh.close()
1688
1681
  try:
1689
1682
  fcntl.flock(lock_fh, fcntl.LOCK_UN)
1690
1683
  except OSError:
@@ -1720,7 +1713,9 @@ def _bump_mutation_seq(conn: sqlite3.Connection) -> int:
1720
1713
  return int(row[0])
1721
1714
 
1722
1715
 
1723
- def _force_retention_prune_after_replay(conn: sqlite3.Connection) -> None:
1716
+ def _force_retention_prune_after_replay(
1717
+ conn: "sqlite3.Connection | None" = None,
1718
+ ) -> None:
1724
1719
  """#313 P3 (F9): run an UNTHROTTLED transcript retention prune after a
1725
1720
  from-zero replay (a ``--rebuild`` or a truncation/requalification re-ingest,
1726
1721
  both of which replay from offset 0 and restore >retention-day rows the
@@ -1734,12 +1729,18 @@ def _force_retention_prune_after_replay(conn: sqlite3.Connection) -> None:
1734
1729
  retention_days = resolve_retention_days(_cctally().load_config())
1735
1730
  if retention_days <= 0:
1736
1731
  return
1737
- retention._maybe_prune_conversation_retention(
1738
- conn,
1739
- now_utc=dt.datetime.now(dt.timezone.utc),
1740
- retention_days=retention_days,
1741
- force=True,
1742
- )
1732
+ owned = conn is None
1733
+ conv_conn = open_conversations_db(attach_cache=False) if owned else conn
1734
+ try:
1735
+ retention._maybe_prune_conversation_retention(
1736
+ conv_conn,
1737
+ now_utc=dt.datetime.now(dt.timezone.utc),
1738
+ retention_days=retention_days,
1739
+ force=True,
1740
+ )
1741
+ finally:
1742
+ if owned:
1743
+ conv_conn.close()
1743
1744
  except Exception:
1744
1745
  pass
1745
1746
 
@@ -2396,7 +2397,12 @@ def sync_cache(
2396
2397
  # walk over the identical span — the "identical span"
2397
2398
  # invariant is now structural (a single stop point) rather
2398
2399
  # than a prose-enforced ``>= final_offset`` runtime break.
2399
- for offset, cost, mrow, ai in _iter_sync_entries(fh, path_str, stats=stats):
2400
+ for offset, cost, mrow, ai in _iter_sync_entries(
2401
+ fh,
2402
+ path_str,
2403
+ stats=stats,
2404
+ include_conversations=False,
2405
+ ):
2400
2406
  if cost is not None:
2401
2407
  entry, msg_id, req_id = cost
2402
2408
  usage = entry.usage
@@ -2696,19 +2702,12 @@ def sync_cache(
2696
2702
  # and the flock is still held, so the short busy_timeout keeps it from
2697
2703
  # stalling the lock under heavy-reader contention.
2698
2704
  _maybe_truncate_wal(conn, _cctally_core.CACHE_DB_PATH)
2699
- # #313 P3 (F9): a rebuild or a truncation escalation replays from offset
2700
- # 0 and restores >retention-day transcript rows. Force an unthrottled
2701
- # prune AFTER the flock releases below (early lock-contended / deferred
2702
- # returns above never reach here, so a no-op sync does not prune).
2703
- did_from_zero_replay = rebuild or stats.files_reset_truncated > 0
2704
2705
  finally:
2705
2706
  try:
2706
2707
  fcntl.flock(lock_fh, fcntl.LOCK_UN)
2707
2708
  except OSError:
2708
2709
  pass
2709
2710
  lock_fh.close()
2710
- if did_from_zero_replay:
2711
- _force_retention_prune_after_replay(conn)
2712
2711
  return stats
2713
2712
 
2714
2713
 
@@ -3875,7 +3874,6 @@ def sync_codex_cache(
3875
3874
  """
3876
3875
  stats = CodexIngestStats()
3877
3876
  project_after_unlock = False
3878
- did_from_zero_replay = False
3879
3877
  # #313 P1 review (F4/F1): when the CACHE certificate is current we cannot
3880
3878
  # yet decide whether to skip the reconcile — reconcile's own short-circuit
3881
3879
  # ALSO requires the stats-side quota_projection_state signatures to match
@@ -4083,12 +4081,12 @@ def sync_codex_cache(
4083
4081
  initial_model: str | None = None
4084
4082
  initial_total_tokens = 0
4085
4083
  # #294 S6: the sticky-turn resume seed (parallel to initial_model).
4086
- initial_turn_id: str | None = None
4087
4084
  prev_total_tokens: int | None = None
4088
4085
  prev_native_thread_id: str | None = None
4089
4086
  prev_root_thread_id: str | None = None
4090
4087
  prev_parent_thread_id: str | None = None
4091
4088
  prev_conversation_key: str | None = None
4089
+ prev_turn_id: str | None = None
4092
4090
  requalified = False
4093
4091
  if prev is not None:
4094
4092
  (
@@ -4119,23 +4117,17 @@ def sync_codex_cache(
4119
4117
  initial_session_id = prev_sid
4120
4118
  initial_model = prev_model
4121
4119
  initial_total_tokens = prev_total_tokens or 0
4122
- initial_turn_id = prev_turn_id
4123
4120
  else:
4124
4121
  truncated = True
4125
4122
  start_offset = 0
4126
4123
  initial_session_id = None
4127
4124
  initial_model = None
4128
4125
  initial_total_tokens = 0
4129
- initial_turn_id = None
4130
4126
  prev_total_tokens = None
4131
4127
 
4132
4128
  accounting_rows: list[tuple[Any, ...]] = []
4133
4129
  quota_rows: list[tuple[Any, ...]] = []
4134
4130
  thread_rows: list[tuple[Any, ...]] = []
4135
- event_rows: list[tuple[Any, ...]] = []
4136
- # #294 S6: the physical event objects for this file window, fed to the
4137
- # normalization kernel after the drain (in offset order).
4138
- events_list: list = []
4139
4131
  final_offset = start_offset
4140
4132
  # Mutable tracker that the iterator updates on every
4141
4133
  # session_meta / turn_context record, regardless of whether a
@@ -4189,15 +4181,6 @@ def sync_codex_cache(
4189
4181
  state=iter_state,
4190
4182
  ):
4191
4183
  event = emission.event
4192
- events_list.append(event)
4193
- event_rows.append((
4194
- event.source_path, event.line_offset,
4195
- event.source_root_key, event.conversation_key,
4196
- event.native_thread_id, event.root_thread_id,
4197
- event.parent_thread_id, event.timestamp_utc,
4198
- event.record_type, event.event_type, event.turn_id,
4199
- event.call_id, event.payload_json,
4200
- ))
4201
4184
  for quota in emission.quotas:
4202
4185
  quota_rows.append((
4203
4186
  quota.source, quota.source_root_key,
@@ -4207,7 +4190,7 @@ def sync_codex_cache(
4207
4190
  quota.limit_name, quota.window_minutes,
4208
4191
  quota.used_percent, quota.resets_at_utc,
4209
4192
  quota.plan_type, quota.individual_limit_json,
4210
- quota.reached_type,
4193
+ quota.reached_type, iter_state.model,
4211
4194
  ))
4212
4195
  if (thread := emission.thread) is not None and (
4213
4196
  thread.conversation_key is not None
@@ -4298,28 +4281,11 @@ def sync_codex_cache(
4298
4281
  if terminal_thread is not None else prev_conversation_key
4299
4282
  )
4300
4283
 
4301
- # #294 S6: normalize this file window's physical events into
4302
- # normalized rows + file touches, replaying sticky turn/model state
4303
- # seeded from the persisted resume seed. Buffered before the first DML
4304
- # (like every other family), so the single retry re-runs the same
4305
- # in-memory batch. The terminal sticky turn persists as last_turn_id.
4306
- try:
4307
- norm_result = _lib_codex_conversation.normalize_codex_events(
4308
- events_list,
4309
- initial=_lib_codex_conversation.CodexStickyState(
4310
- turn_id=initial_turn_id, model=initial_model),
4311
- )
4312
- except Exception as exc: # noqa: BLE001
4313
- # §5.1: a normalization exception during a targeted ingest is a
4314
- # per-file decline → the call is dirty (files_failed) but earlier
4315
- # commits stand. A full sync keeps its historical crash-loud
4316
- # behavior (there the whole walk is authoritative).
4317
- if not targeted:
4318
- raise
4319
- eprint(f"[codex-cache] normalization failed for {jp}: {exc}")
4320
- stats.files_failed += 1
4321
- continue
4322
- new_last_turn_id = norm_result.terminal.turn_id
4284
+ # Transcript normalization and its sticky turn cursor belong to the
4285
+ # independent conversations.db pass. Preserve the legacy compact
4286
+ # cursor column without advancing it here; sync_codex_conversations
4287
+ # owns the authoritative transcript-local value.
4288
+ new_last_turn_id = prev_turn_id
4323
4289
 
4324
4290
  # Every derived row above was buffered before the first DML. A
4325
4291
  # late database failure therefore rolls the whole file back and
@@ -4346,9 +4312,6 @@ def sync_codex_cache(
4346
4312
  accounting_rows=accounting_rows,
4347
4313
  quota_rows=quota_rows,
4348
4314
  thread_rows=thread_rows,
4349
- event_rows=event_rows,
4350
- normalized_rows=norm_result.rows,
4351
- normalized_touches=norm_result.touches,
4352
4315
  active_root_keys={item.source_root_key for item in files},
4353
4316
  # §5.1 whole-tree bypass: targeted mode never prunes
4354
4317
  # codex_source_roots for roots outside its target set.
@@ -4441,7 +4404,7 @@ def sync_codex_cache(
4441
4404
  # reconciler (its observation load reconciles all roots at seconds-scale
4442
4405
  # cost). Quota projection is deferred to the next full sync, which the
4443
4406
  # ordinary hook cadence supplies. Skip the whole decision block so
4444
- # project_after_unlock / deferred_cert_* / did_from_zero_replay keep
4407
+ # project_after_unlock / deferred_cert_* keep
4445
4408
  # their no-op defaults — the post-flock reconcile paths below then all
4446
4409
  # short-circuit for a targeted call.
4447
4410
  if not targeted:
@@ -4471,11 +4434,6 @@ def sync_codex_cache(
4471
4434
  deferred_cert_sigs = dict(certificate[1])
4472
4435
  else:
4473
4436
  project_after_unlock = True
4474
- # #313 P3 (F9): a Codex rebuild or a truncation/requalification
4475
- # re-ingest replays from offset 0 and restores >retention-day
4476
- # codex_conversation_events. Force an unthrottled prune after the
4477
- # flock releases (below), so restored old rows don't persist for 24h.
4478
- did_from_zero_replay = rebuild or stats.files_reset_truncated > 0
4479
4437
  finally:
4480
4438
  try:
4481
4439
  fcntl.flock(lock_fh, fcntl.LOCK_UN)
@@ -4504,8 +4462,6 @@ def sync_codex_cache(
4504
4462
  if project_after_unlock:
4505
4463
  from _cctally_quota import reconcile_codex_quota_projection
4506
4464
  reconcile_codex_quota_projection()
4507
- if did_from_zero_replay:
4508
- _force_retention_prune_after_replay(conn)
4509
4465
  return stats
4510
4466
 
4511
4467
 
@@ -4807,9 +4763,773 @@ def open_cache_db() -> sqlite3.Connection:
4807
4763
  conn, registry=_CACHE_MIGRATIONS, db_label="cache.db",
4808
4764
  recover_version_ahead=True,
4809
4765
  )
4766
+ # Migration 028 removes the legacy transcript objects after arming the
4767
+ # independent rebuild. Recreate EMPTY compatibility objects only so older
4768
+ # migration/fixture probes remain valid; live core sync never populates
4769
+ # them. Keeping this conditional avoids a second schema pass on normal
4770
+ # opens while preserving the physical data split.
4771
+ if conn.execute(
4772
+ "SELECT 1 FROM sqlite_master WHERE name='conversation_messages'"
4773
+ ).fetchone() is None:
4774
+ _cctally_db_sib._apply_cache_schema(conn)
4810
4775
  return conn
4811
4776
 
4812
4777
 
4778
+ def _harden_conversation_sidecars() -> None:
4779
+ """Best-effort 0600 on conversations.db and its WAL sidecars."""
4780
+ base = str(_cctally_core.CONVERSATIONS_DB_PATH)
4781
+ for path in (base, base + "-wal", base + "-shm"):
4782
+ try:
4783
+ if os.path.exists(path):
4784
+ os.chmod(path, 0o600)
4785
+ except OSError as exc:
4786
+ eprint(
4787
+ f"[conversations] could not chmod {path} 0600 ({exc}); continuing"
4788
+ )
4789
+
4790
+
4791
+ def open_conversations_db(*, attach_cache: bool = True) -> sqlite3.Connection:
4792
+ """Open the independent transcript/search store (#320).
4793
+
4794
+ ``conversations.db`` is the main schema. Conversation readers optionally
4795
+ attach ``cache.db`` read-only as ``cache_db`` for cost/token and compact
4796
+ Codex-thread metadata. Core cache callers never take the inverse
4797
+ dependency, so a missing or locked transcript store cannot block quota or
4798
+ accounting refreshes.
4799
+ """
4800
+ _cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
4801
+ try:
4802
+ os.chmod(_cctally_core.APP_DIR, 0o700)
4803
+ except OSError as exc:
4804
+ eprint(
4805
+ f"[conversations] could not chmod data dir 0700 ({exc}); continuing"
4806
+ )
4807
+
4808
+ path = _cctally_core.CONVERSATIONS_DB_PATH
4809
+ conn: sqlite3.Connection | None = None
4810
+ try:
4811
+ # URI mode belongs to the connection, not only the later ATTACH value.
4812
+ # Without it, some supported system-Python SQLite builds interpret the
4813
+ # read-only ``file:...cache.db?mode=ro`` attachment as a literal path.
4814
+ conn = sqlite3.connect(path, uri=True)
4815
+ conn.execute("SELECT 1").fetchone()
4816
+ except sqlite3.DatabaseError as exc:
4817
+ if conn is not None:
4818
+ conn.close()
4819
+ # Do not unlink a live SQLite family from a reader path. The store is
4820
+ # re-derivable, but safe replacement still requires excluding its
4821
+ # independent writers; callers degrade this surface and leave core
4822
+ # accounting/quota available. An explicit rebuild/delete can recover it.
4823
+ eprint(f"[conversations] corrupt transcript DB ({exc}); unavailable")
4824
+ raise
4825
+
4826
+ assert conn is not None
4827
+
4828
+ try:
4829
+ os.chmod(path, 0o600)
4830
+ except OSError as exc:
4831
+ eprint(
4832
+ f"[conversations] could not chmod conversations.db 0600 ({exc}); continuing"
4833
+ )
4834
+
4835
+ conn.execute("PRAGMA auto_vacuum=INCREMENTAL")
4836
+ conn.execute("PRAGMA journal_mode=WAL")
4837
+ conn.execute("PRAGMA busy_timeout=15000")
4838
+ conn.execute(f"PRAGMA journal_size_limit={CACHE_WAL_SIZE_LIMIT_BYTES}")
4839
+ conn.execute("PRAGMA synchronous=NORMAL")
4840
+ _cctally_db_sib._apply_conversations_schema(conn)
4841
+ conn.commit()
4842
+
4843
+ if attach_cache:
4844
+ # Ensure the compact schema exists before opening it read-only. This
4845
+ # call has no dependency on conversations.db (pinned by the split RED
4846
+ # test), so the direction remains one-way.
4847
+ cache = open_cache_db()
4848
+ cache.close()
4849
+ cache_uri = _cctally_core.CACHE_DB_PATH.resolve().as_uri() + "?mode=ro"
4850
+ conn.execute("ATTACH DATABASE ? AS cache_db", (cache_uri,))
4851
+ _import_legacy_conversation_rows(conn)
4852
+ _harden_conversation_sidecars()
4853
+ return conn
4854
+
4855
+
4856
+ def _import_legacy_conversation_rows(conn: sqlite3.Connection) -> None:
4857
+ """Bridge pre-028/compatibility rows into an empty conversation store.
4858
+
4859
+ Migration 028 normally arms a JSONL rebuild and clears the old rows. This
4860
+ defensive bridge covers an interrupted upgrade and keeps historical test
4861
+ fixtures readable without making core sync depend on the transcript DB.
4862
+ It writes only the main conversation store; ``cache_db`` is attached RO.
4863
+ """
4864
+ tables = (
4865
+ "conversation_messages",
4866
+ "conversation_ai_titles",
4867
+ "conversation_sessions",
4868
+ "conversation_file_touches",
4869
+ "codex_conversation_events",
4870
+ "codex_conversation_messages",
4871
+ "codex_conversation_file_touches",
4872
+ "codex_conversation_rollups",
4873
+ )
4874
+ changed = False
4875
+ for table in tables:
4876
+ try:
4877
+ if conn.execute(f"SELECT 1 FROM main.{table} LIMIT 1").fetchone():
4878
+ continue
4879
+ if not conn.execute(
4880
+ f"SELECT 1 FROM cache_db.{table} LIMIT 1"
4881
+ ).fetchone():
4882
+ continue
4883
+ main_cols = [
4884
+ str(row[1]) for row in conn.execute(f"PRAGMA main.table_info({table})")
4885
+ ]
4886
+ source_cols = {
4887
+ str(row[1])
4888
+ for row in conn.execute(f"PRAGMA cache_db.table_info({table})")
4889
+ }
4890
+ cols = [col for col in main_cols if col in source_cols]
4891
+ quoted = ",".join(f'"{col}"' for col in cols)
4892
+ conn.execute(
4893
+ f"INSERT OR IGNORE INTO main.{table} ({quoted}) "
4894
+ f"SELECT {quoted} FROM cache_db.{table}"
4895
+ )
4896
+ changed = True
4897
+ except sqlite3.Error:
4898
+ continue
4899
+ if changed:
4900
+ conn.commit()
4901
+
4902
+
4903
+ def _prepare_claude_conversation_maintenance(
4904
+ conn: sqlite3.Connection,
4905
+ *,
4906
+ rebuild: bool,
4907
+ targeted: bool,
4908
+ ) -> None:
4909
+ """Consume transcript-only upgrade work under the conversation flock.
4910
+
4911
+ These consumers historically ran inside ``sync_cache`` because prose and
4912
+ accounting shared one database. Keeping them here is the load-bearing
4913
+ half of the #320 split: schema upgrades may re-derive transcript state, but
4914
+ they never extend the core-cache critical section.
4915
+ """
4916
+ if rebuild:
4917
+ # The offset-zero walk below re-derives every transcript projection.
4918
+ conn.execute(
4919
+ "DELETE FROM cache_meta WHERE key IN ("
4920
+ "'conversation_backfill_pending',"
4921
+ "'conversation_reingest_pending',"
4922
+ "'conversation_source_tool_use_reingest_pending',"
4923
+ "'conversation_reingest_enrichment_pending',"
4924
+ "'conversation_media_reingest_pending',"
4925
+ "'conversation_queued_prompt_reingest_pending',"
4926
+ "'conversation_reingest_nested_agent_pending',"
4927
+ "'conversation_reingest_file_touches_pending',"
4928
+ "'conversation_file_touches_cursor',"
4929
+ "'conversation_reingest_cursor',"
4930
+ "'conversation_reingest_cursor_gen',"
4931
+ "'conversation_promote_command_args_pending',"
4932
+ "'conversation_promote_command_args_cursor',"
4933
+ "'conversation_title_fts_backfill_pending',"
4934
+ "'ai_titles_backfill_pending')"
4935
+ )
4936
+ split_pending = conn.execute(
4937
+ "SELECT 1 FROM cache_meta "
4938
+ "WHERE key='conversation_search_split_pending'"
4939
+ ).fetchone() is not None
4940
+ if split_pending:
4941
+ fts_off = conn.execute(
4942
+ "SELECT 1 FROM cache_meta WHERE key='fts5_unavailable'"
4943
+ ).fetchone() is not None
4944
+ if not fts_off and not _cctally_db_sib._conversation_fts_is_split(conn):
4945
+ _cctally_db_sib._swap_conversation_fts_to_split(conn)
4946
+ conn.execute(
4947
+ "DELETE FROM cache_meta WHERE key IN "
4948
+ "('conversation_search_split_pending',"
4949
+ " 'conversation_search_split_cursor')"
4950
+ )
4951
+ _set_cache_meta(conn, "conversation_sessions_backfill_pending", "1")
4952
+ conn.commit()
4953
+ return
4954
+
4955
+ if targeted:
4956
+ return
4957
+
4958
+ if conn.execute(
4959
+ "SELECT 1 FROM cache_meta WHERE key='conversation_backfill_pending'"
4960
+ ).fetchone() is not None:
4961
+ backfill_conversation_messages(conn)
4962
+ conn.execute(
4963
+ "DELETE FROM cache_meta WHERE key='conversation_backfill_pending'"
4964
+ )
4965
+ _set_cache_meta(conn, "conversation_sessions_backfill_pending", "1")
4966
+ conn.commit()
4967
+
4968
+ if conn.execute(
4969
+ "SELECT 1 FROM cache_meta WHERE key='ai_titles_backfill_pending'"
4970
+ ).fetchone() is not None:
4971
+ backfill_ai_titles(conn)
4972
+ conn.execute(
4973
+ "DELETE FROM cache_meta WHERE key='ai_titles_backfill_pending'"
4974
+ )
4975
+ conn.commit()
4976
+
4977
+ reingest = conn.execute(
4978
+ "SELECT 1 FROM cache_meta WHERE key IN ("
4979
+ "'conversation_reingest_pending',"
4980
+ "'conversation_source_tool_use_reingest_pending',"
4981
+ "'conversation_reingest_enrichment_pending',"
4982
+ "'conversation_media_reingest_pending',"
4983
+ "'conversation_queued_prompt_reingest_pending',"
4984
+ "'conversation_reingest_nested_agent_pending')"
4985
+ ).fetchone() is not None
4986
+ if reingest:
4987
+ _resumable_reingest_conversation_messages(conn)
4988
+ _set_cache_meta(conn, "conversation_sessions_backfill_pending", "1")
4989
+ conn.commit()
4990
+
4991
+ _consume_search_split(conn)
4992
+ _consume_promote_command_args(conn)
4993
+ _consume_title_fts(conn)
4994
+ _consume_file_touches(conn)
4995
+
4996
+
4997
+ def sync_claude_conversations(
4998
+ conn: sqlite3.Connection,
4999
+ *,
5000
+ rebuild: bool = False,
5001
+ lock_timeout: "float | None" = None,
5002
+ only_paths: "set[str] | None" = None,
5003
+ ) -> IngestStats:
5004
+ """Delta-sync Claude transcript/search rows into conversations.db (#320).
5005
+
5006
+ The transcript cursor is committed in the same conversations.db
5007
+ transaction as its message/title rows. No cache.db table is written, and
5008
+ the core accounting cursor is neither read nor advanced.
5009
+ """
5010
+ stats = IngestStats()
5011
+ did_from_zero_replay = False
5012
+ _cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
5013
+ _cctally_core.CONVERSATIONS_LOCK_PATH.touch()
5014
+ lock_fh = open(_cctally_core.CONVERSATIONS_LOCK_PATH, "w")
5015
+ try:
5016
+ if not _acquire_cache_flock(lock_fh, timeout=lock_timeout):
5017
+ stats.lock_contended = True
5018
+ return stats
5019
+
5020
+ targeted = only_paths is not None
5021
+ pending_rebuild = conn.execute(
5022
+ "SELECT 1 FROM cache_meta "
5023
+ "WHERE key='conversation_rebuild_claude_pending'"
5024
+ ).fetchone() is not None
5025
+ if pending_rebuild and targeted:
5026
+ stats.deferred_reason = "rebuild_pending"
5027
+ return stats
5028
+ if targeted:
5029
+ placeholders = ",".join("?" for _ in _TARGETED_DECLINE_FLAGS)
5030
+ if conn.execute(
5031
+ f"SELECT 1 FROM cache_meta WHERE key IN ({placeholders}) LIMIT 1",
5032
+ _TARGETED_DECLINE_FLAGS,
5033
+ ).fetchone() is not None:
5034
+ stats.deferred_reason = "pending_global_flags"
5035
+ return stats
5036
+ rebuild = rebuild or pending_rebuild
5037
+
5038
+ _prepare_claude_conversation_maintenance(
5039
+ conn, rebuild=rebuild, targeted=targeted
5040
+ )
5041
+
5042
+ if rebuild:
5043
+ clear_conversation_messages(conn)
5044
+ conn.execute("DELETE FROM conversation_ai_titles")
5045
+ conn.execute("DELETE FROM conversation_sessions")
5046
+ conn.execute("DELETE FROM conversation_source_files")
5047
+ conn.commit()
5048
+
5049
+ if only_paths is not None and rebuild:
5050
+ raise ValueError(
5051
+ "sync_claude_conversations: only_paths is incompatible with rebuild"
5052
+ )
5053
+ paths = (
5054
+ [pathlib.Path(path) for path in sorted(only_paths)
5055
+ if pathlib.Path(path).is_file()]
5056
+ if only_paths is not None
5057
+ else list(_iter_claude_jsonl_files())
5058
+ )
5059
+ stats.files_total = len(paths)
5060
+ existing = {
5061
+ row[0]: (row[1], row[2], row[3])
5062
+ for row in conn.execute(
5063
+ "SELECT path,size_bytes,mtime_ns,last_byte_offset "
5064
+ "FROM conversation_source_files"
5065
+ )
5066
+ }
5067
+ if targeted:
5068
+ for jp in paths:
5069
+ prev = existing.get(str(jp))
5070
+ if prev is None:
5071
+ continue
5072
+ try:
5073
+ current_size = jp.stat().st_size
5074
+ except OSError:
5075
+ continue
5076
+ if current_size < prev[0]:
5077
+ stats.deferred_reason = "truncation"
5078
+ return stats
5079
+ # Missing Claude paths are deliberately retained here. Their message
5080
+ # rows are the evidence used by _prune_orphaned_cache_entries's
5081
+ # coverage/disjointness gates before it deletes core accounting rows.
5082
+ # Eager transcript cleanup would destroy that proof and turn every
5083
+ # dashboard orphan heal into a residual. Explicit --rebuild may clear
5084
+ # the whole re-derivable store; ordinary sync remains detect/retain.
5085
+ touched_sessions: set[str] = set()
5086
+
5087
+ for jp in paths:
5088
+ path_str = str(jp)
5089
+ try:
5090
+ st = jp.stat()
5091
+ except OSError:
5092
+ stats.files_failed += 1
5093
+ continue
5094
+ size, mtime_ns = st.st_size, st.st_mtime_ns
5095
+ prev = existing.get(path_str)
5096
+ if prev is not None and size == prev[0]:
5097
+ stats.files_skipped_unchanged += 1
5098
+ continue
5099
+ truncated = prev is not None and size < prev[0]
5100
+ if targeted and truncated:
5101
+ stats.deferred_reason = "truncation"
5102
+ return stats
5103
+ start_offset = 0 if prev is None or truncated else prev[2]
5104
+ conv_rows: list[tuple[Any, ...]] = []
5105
+ ai_rows: list[tuple[Any, ...]] = []
5106
+ final_offset = start_offset
5107
+ try:
5108
+ with open(jp, "r", encoding="utf-8", errors="replace") as fh:
5109
+ fh.seek(start_offset)
5110
+ for _offset, _cost, mrow, ai in _iter_sync_entries(
5111
+ fh,
5112
+ path_str,
5113
+ include_cost=False,
5114
+ ):
5115
+ if mrow is not None:
5116
+ conv_rows.append(_conv_row_tuple(mrow, path_str))
5117
+ if ai is not None:
5118
+ ai_rows.append(
5119
+ (ai.session_id, ai.ai_title, path_str, ai.byte_offset)
5120
+ )
5121
+ final_offset = fh.tell()
5122
+ except OSError as exc:
5123
+ eprint(f"[conversations] could not read {jp}: {exc}")
5124
+ stats.files_failed += 1
5125
+ continue
5126
+
5127
+ try:
5128
+ if truncated:
5129
+ touched_sessions.update(
5130
+ row[0]
5131
+ for row in conn.execute(
5132
+ "SELECT DISTINCT session_id FROM conversation_messages "
5133
+ "WHERE source_path=? AND session_id IS NOT NULL",
5134
+ (path_str,),
5135
+ )
5136
+ )
5137
+ conn.execute(
5138
+ "DELETE FROM conversation_file_touches WHERE message_id IN "
5139
+ "(SELECT id FROM conversation_messages WHERE source_path=?)",
5140
+ (path_str,),
5141
+ )
5142
+ conn.execute(
5143
+ "DELETE FROM conversation_messages WHERE source_path=?",
5144
+ (path_str,),
5145
+ )
5146
+ conn.execute(
5147
+ "DELETE FROM conversation_ai_titles WHERE source_path=?",
5148
+ (path_str,),
5149
+ )
5150
+ stats.files_reset_truncated += 1
5151
+ if conv_rows:
5152
+ conn.executemany(_CONV_INSERT_SQL, conv_rows)
5153
+ _fill_file_touches(
5154
+ conn, scope=[(row[3], row[4]) for row in conv_rows]
5155
+ )
5156
+ if ai_rows:
5157
+ conn.executemany(_AI_TITLE_UPSERT_SQL, ai_rows)
5158
+ conn.execute(
5159
+ "INSERT INTO conversation_source_files "
5160
+ "(path,size_bytes,mtime_ns,last_byte_offset,last_ingested_at) "
5161
+ "VALUES(?,?,?,?,?) ON CONFLICT(path) DO UPDATE SET "
5162
+ "size_bytes=excluded.size_bytes,mtime_ns=excluded.mtime_ns,"
5163
+ "last_byte_offset=excluded.last_byte_offset,"
5164
+ "last_ingested_at=excluded.last_ingested_at",
5165
+ (
5166
+ path_str,
5167
+ size,
5168
+ mtime_ns,
5169
+ final_offset,
5170
+ dt.datetime.now(dt.timezone.utc).isoformat(),
5171
+ ),
5172
+ )
5173
+ conn.commit()
5174
+ stats.files_processed += 1
5175
+ touched_sessions.update(
5176
+ row[0] for row in conv_rows if row[0] is not None
5177
+ )
5178
+ except sqlite3.DatabaseError as exc:
5179
+ conn.rollback()
5180
+ eprint(f"[conversations] db error on {jp}: {exc}")
5181
+ stats.files_failed += 1
5182
+
5183
+ _arm_rollup_backfill_on_pricing_change(conn)
5184
+ if _conversation_sessions_backfill_pending(conn):
5185
+ _recompute_conversation_sessions(conn)
5186
+ conn.execute(
5187
+ "DELETE FROM cache_meta "
5188
+ "WHERE key='conversation_sessions_backfill_pending'"
5189
+ )
5190
+ conn.commit()
5191
+ elif touched_sessions:
5192
+ _recompute_conversation_sessions(conn, touched_sessions)
5193
+ conn.commit()
5194
+ if only_paths is None and stats.files_failed == 0:
5195
+ conn.execute(
5196
+ "DELETE FROM cache_meta "
5197
+ "WHERE key='conversation_rebuild_claude_pending'"
5198
+ )
5199
+ conn.commit()
5200
+ _harden_conversation_sidecars()
5201
+ _maybe_truncate_wal(conn, _cctally_core.CONVERSATIONS_DB_PATH)
5202
+ did_from_zero_replay = rebuild or stats.files_reset_truncated > 0
5203
+ finally:
5204
+ try:
5205
+ fcntl.flock(lock_fh, fcntl.LOCK_UN)
5206
+ except OSError:
5207
+ pass
5208
+ lock_fh.close()
5209
+ if did_from_zero_replay:
5210
+ _force_retention_prune_after_replay()
5211
+ return stats
5212
+
5213
+
5214
+ def _clear_codex_conversation_store(conn: sqlite3.Connection) -> None:
5215
+ """Clear only the re-derivable Codex transcript families."""
5216
+ conn.execute("DELETE FROM codex_conversation_events")
5217
+ _codex_conversation_fts_full_clear(conn)
5218
+ conn.execute("DELETE FROM codex_conversation_source_files")
5219
+
5220
+
5221
+ def sync_codex_conversations(
5222
+ conn: sqlite3.Connection,
5223
+ *,
5224
+ rebuild: bool = False,
5225
+ lock_timeout: "float | None" = None,
5226
+ only_paths: "set[str] | None" = None,
5227
+ ) -> CodexIngestStats:
5228
+ """Delta-sync Codex events/search rows into conversations.db (#320)."""
5229
+ stats = CodexIngestStats()
5230
+ did_from_zero_replay = False
5231
+ _cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
5232
+ _cctally_core.CONVERSATIONS_LOCK_CODEX_PATH.touch()
5233
+ lock_fh = open(_cctally_core.CONVERSATIONS_LOCK_CODEX_PATH, "w")
5234
+ try:
5235
+ if not _acquire_cache_flock(lock_fh, timeout=lock_timeout):
5236
+ stats.lock_contended = True
5237
+ return stats
5238
+ targeted = only_paths is not None
5239
+ pending_rebuild = conn.execute(
5240
+ "SELECT 1 FROM cache_meta "
5241
+ "WHERE key='conversation_rebuild_codex_pending'"
5242
+ ).fetchone() is not None
5243
+ if pending_rebuild and targeted:
5244
+ stats.deferred_reason = "rebuild_pending"
5245
+ return stats
5246
+ rebuild = rebuild or pending_rebuild
5247
+ if rebuild:
5248
+ _clear_codex_conversation_store(conn)
5249
+ conn.commit()
5250
+
5251
+ if only_paths is not None and rebuild:
5252
+ raise ValueError(
5253
+ "sync_codex_conversations: only_paths is incompatible with rebuild"
5254
+ )
5255
+ files = (
5256
+ _qualify_codex_targets(only_paths)
5257
+ if only_paths is not None
5258
+ else _discover_codex_files_with_roots()
5259
+ )
5260
+ stats.files_total = len(files)
5261
+ existing = {
5262
+ row[0]: tuple(row[1:])
5263
+ for row in conn.execute(
5264
+ "SELECT path,size_bytes,mtime_ns,last_byte_offset,source_root_key,"
5265
+ "last_session_id,last_model,last_total_tokens,"
5266
+ "last_native_thread_id,last_root_thread_id,last_parent_thread_id,"
5267
+ "last_conversation_key,last_turn_id "
5268
+ "FROM codex_conversation_source_files"
5269
+ )
5270
+ }
5271
+ if targeted:
5272
+ for discovered in files:
5273
+ prev = existing.get(str(discovered.source_path))
5274
+ if prev is None:
5275
+ continue
5276
+ if prev[3] != discovered.source_root_key:
5277
+ stats.deferred_reason = "requalification"
5278
+ return stats
5279
+ try:
5280
+ current_size = discovered.source_path.stat().st_size
5281
+ except OSError:
5282
+ continue
5283
+ if current_size < prev[0]:
5284
+ stats.deferred_reason = "truncation"
5285
+ return stats
5286
+ if only_paths is None:
5287
+ active_paths = {str(item.source_path) for item in files}
5288
+ for stale_path in sorted(set(existing) - active_paths):
5289
+ affected = {
5290
+ row[0] for row in conn.execute(
5291
+ "SELECT DISTINCT conversation_key "
5292
+ "FROM codex_conversation_messages WHERE source_path=?",
5293
+ (stale_path,),
5294
+ ) if row[0]
5295
+ }
5296
+ conn.execute(
5297
+ "DELETE FROM codex_conversation_file_touches WHERE source_path=?",
5298
+ (stale_path,),
5299
+ )
5300
+ conn.execute(
5301
+ "DELETE FROM codex_conversation_messages WHERE source_path=?",
5302
+ (stale_path,),
5303
+ )
5304
+ conn.execute(
5305
+ "DELETE FROM codex_conversation_events WHERE source_path=?",
5306
+ (stale_path,),
5307
+ )
5308
+ conn.execute(
5309
+ "DELETE FROM codex_conversation_source_files WHERE path=?",
5310
+ (stale_path,),
5311
+ )
5312
+ _recompute_codex_rollups(conn, affected)
5313
+ conn.commit()
5314
+
5315
+ for discovered in files:
5316
+ jp = discovered.source_path
5317
+ path_str = str(jp)
5318
+ try:
5319
+ st = jp.stat()
5320
+ except OSError:
5321
+ stats.files_failed += 1
5322
+ continue
5323
+ size, mtime_ns = st.st_size, st.st_mtime_ns
5324
+ prev = existing.get(path_str)
5325
+ if prev is not None and size == prev[0] and prev[3] == discovered.source_root_key:
5326
+ stats.files_skipped_unchanged += 1
5327
+ continue
5328
+ reset_file = (
5329
+ prev is not None
5330
+ and (size < prev[0] or prev[3] != discovered.source_root_key)
5331
+ )
5332
+ if targeted and reset_file:
5333
+ stats.deferred_reason = (
5334
+ "requalification"
5335
+ if prev is not None and prev[3] != discovered.source_root_key
5336
+ else "truncation"
5337
+ )
5338
+ return stats
5339
+ start_offset = 0 if prev is None or reset_file else int(prev[2])
5340
+ initial_session_id = prev[4] if prev else None
5341
+ initial_model = prev[5] if prev else None
5342
+ initial_total_tokens = (
5343
+ int(prev[6]) if prev and prev[6] is not None else 0
5344
+ )
5345
+ initial_native = prev[7] if prev else None
5346
+ initial_root = prev[8] if prev else None
5347
+ initial_parent = prev[9] if prev else None
5348
+ initial_conversation = prev[10] if prev else None
5349
+ initial_turn = prev[11] if prev else None
5350
+
5351
+ state = _CodexIterState(
5352
+ session_id=initial_session_id,
5353
+ model=initial_model,
5354
+ total_tokens=initial_total_tokens,
5355
+ )
5356
+ if initial_native and initial_root:
5357
+ state.thread = _lib_jsonl.CodexThreadMetadata(
5358
+ source_root_key=discovered.source_root_key,
5359
+ source_path=path_str,
5360
+ native_thread_id=initial_native,
5361
+ root_thread_id=initial_root,
5362
+ parent_thread_id=initial_parent,
5363
+ conversation_key=initial_conversation,
5364
+ cwd=None,
5365
+ git_json=None,
5366
+ source_kind=None,
5367
+ thread_source_json=None,
5368
+ model_provider=None,
5369
+ context_window=None,
5370
+ )
5371
+ events = []
5372
+ event_rows = []
5373
+ yielded = 0
5374
+ try:
5375
+ with open(jp, "rb") as fh:
5376
+ fh.seek(start_offset)
5377
+ for emission in _iter_codex_fused_records_with_offsets(
5378
+ fh,
5379
+ path_str,
5380
+ initial_session_id=initial_session_id,
5381
+ initial_model=initial_model,
5382
+ initial_total_tokens=initial_total_tokens,
5383
+ source_root_key=discovered.source_root_key,
5384
+ state=state,
5385
+ ):
5386
+ event = emission.event
5387
+ events.append(event)
5388
+ event_rows.append((
5389
+ event.source_path,
5390
+ event.line_offset,
5391
+ event.source_root_key,
5392
+ event.conversation_key,
5393
+ event.native_thread_id,
5394
+ event.root_thread_id,
5395
+ event.parent_thread_id,
5396
+ event.timestamp_utc,
5397
+ event.record_type,
5398
+ event.event_type,
5399
+ event.turn_id,
5400
+ event.call_id,
5401
+ event.payload_json,
5402
+ ))
5403
+ if emission.accounting is not None:
5404
+ yielded += 1
5405
+ final_offset = fh.tell()
5406
+ except OSError as exc:
5407
+ eprint(f"[codex-conversations] could not read {jp}: {exc}")
5408
+ stats.files_failed += 1
5409
+ continue
5410
+
5411
+ try:
5412
+ normalized = _lib_codex_conversation.normalize_codex_events(
5413
+ events,
5414
+ initial=_lib_codex_conversation.CodexStickyState(
5415
+ turn_id=initial_turn,
5416
+ model=initial_model,
5417
+ ),
5418
+ )
5419
+ except Exception as exc: # noqa: BLE001
5420
+ if not targeted:
5421
+ raise
5422
+ eprint(
5423
+ f"[codex-conversations] normalization failed for {jp}: {exc}"
5424
+ )
5425
+ stats.files_failed += 1
5426
+ continue
5427
+ affected_keys = {
5428
+ row[0]
5429
+ for row in conn.execute(
5430
+ "SELECT DISTINCT conversation_key "
5431
+ "FROM codex_conversation_messages WHERE source_path=?",
5432
+ (path_str,),
5433
+ )
5434
+ if row[0]
5435
+ } if reset_file else set()
5436
+ try:
5437
+ if reset_file:
5438
+ conn.execute(
5439
+ "DELETE FROM codex_conversation_file_touches "
5440
+ "WHERE source_path=?",
5441
+ (path_str,),
5442
+ )
5443
+ conn.execute(
5444
+ "DELETE FROM codex_conversation_messages WHERE source_path=?",
5445
+ (path_str,),
5446
+ )
5447
+ conn.execute(
5448
+ "DELETE FROM codex_conversation_events WHERE source_path=?",
5449
+ (path_str,),
5450
+ )
5451
+ stats.files_reset_truncated += 1
5452
+ if event_rows:
5453
+ conn.executemany(
5454
+ "INSERT OR IGNORE INTO codex_conversation_events "
5455
+ "(source_path,line_offset,source_root_key,conversation_key,"
5456
+ "native_thread_id,root_thread_id,parent_thread_id,"
5457
+ "timestamp_utc,record_type,event_type,turn_id,call_id,payload_json) "
5458
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
5459
+ event_rows,
5460
+ )
5461
+ _insert_codex_normalized_rows(
5462
+ conn, normalized.rows, normalized.touches
5463
+ )
5464
+ affected_keys.update(
5465
+ row.conversation_key for row in normalized.rows
5466
+ )
5467
+ _recompute_codex_rollups(conn, affected_keys)
5468
+ terminal = state.thread
5469
+ conn.execute(
5470
+ "INSERT INTO codex_conversation_source_files "
5471
+ "(path,size_bytes,mtime_ns,last_byte_offset,last_ingested_at,"
5472
+ "source_root_key,last_session_id,last_model,last_total_tokens,"
5473
+ "last_native_thread_id,last_root_thread_id,last_parent_thread_id,"
5474
+ "last_conversation_key,last_turn_id) "
5475
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) "
5476
+ "ON CONFLICT(path) DO UPDATE SET "
5477
+ "size_bytes=excluded.size_bytes,mtime_ns=excluded.mtime_ns,"
5478
+ "last_byte_offset=excluded.last_byte_offset,"
5479
+ "last_ingested_at=excluded.last_ingested_at,"
5480
+ "source_root_key=excluded.source_root_key,"
5481
+ "last_session_id=excluded.last_session_id,"
5482
+ "last_model=excluded.last_model,"
5483
+ "last_total_tokens=excluded.last_total_tokens,"
5484
+ "last_native_thread_id=excluded.last_native_thread_id,"
5485
+ "last_root_thread_id=excluded.last_root_thread_id,"
5486
+ "last_parent_thread_id=excluded.last_parent_thread_id,"
5487
+ "last_conversation_key=excluded.last_conversation_key,"
5488
+ "last_turn_id=excluded.last_turn_id",
5489
+ (
5490
+ path_str,
5491
+ size,
5492
+ mtime_ns,
5493
+ final_offset,
5494
+ dt.datetime.now(dt.timezone.utc).isoformat(),
5495
+ discovered.source_root_key,
5496
+ state.session_id or initial_session_id,
5497
+ state.model or initial_model,
5498
+ state.total_tokens if yielded else initial_total_tokens,
5499
+ terminal.native_thread_id if terminal else initial_native,
5500
+ terminal.root_thread_id if terminal else initial_root,
5501
+ terminal.parent_thread_id if terminal else initial_parent,
5502
+ terminal.conversation_key if terminal else initial_conversation,
5503
+ normalized.terminal.turn_id,
5504
+ ),
5505
+ )
5506
+ conn.commit()
5507
+ stats.files_processed += 1
5508
+ except sqlite3.DatabaseError as exc:
5509
+ conn.rollback()
5510
+ eprint(f"[codex-conversations] db error on {jp}: {exc}")
5511
+ stats.files_failed += 1
5512
+
5513
+ if only_paths is None and stats.files_failed == 0:
5514
+ conn.execute(
5515
+ "DELETE FROM cache_meta "
5516
+ "WHERE key='conversation_rebuild_codex_pending'"
5517
+ )
5518
+ conn.commit()
5519
+ _harden_conversation_sidecars()
5520
+ _maybe_truncate_wal(conn, _cctally_core.CONVERSATIONS_DB_PATH)
5521
+ did_from_zero_replay = rebuild or stats.files_reset_truncated > 0
5522
+ finally:
5523
+ try:
5524
+ fcntl.flock(lock_fh, fcntl.LOCK_UN)
5525
+ except OSError:
5526
+ pass
5527
+ lock_fh.close()
5528
+ if did_from_zero_replay:
5529
+ _force_retention_prune_after_replay()
5530
+ return stats
5531
+
5532
+
4813
5533
  # === Region 7: cmd_cache_sync (was bin/cctally:11563-11616) ===
4814
5534
 
4815
5535
 
@@ -4878,12 +5598,16 @@ def cmd_cache_sync(args: argparse.Namespace) -> int:
4878
5598
  "(conversation.retention_days=0); nothing pruned."
4879
5599
  )
4880
5600
  return 0
4881
- result = retention._maybe_prune_conversation_retention(
4882
- conn,
4883
- now_utc=dt.datetime.now(dt.timezone.utc),
4884
- retention_days=retention_days,
4885
- force=True,
4886
- )
5601
+ conv_conn = open_conversations_db(attach_cache=False)
5602
+ try:
5603
+ result = retention._maybe_prune_conversation_retention(
5604
+ conv_conn,
5605
+ now_utc=dt.datetime.now(dt.timezone.utc),
5606
+ retention_days=retention_days,
5607
+ force=True,
5608
+ )
5609
+ finally:
5610
+ conv_conn.close()
4887
5611
  if result is None:
4888
5612
  eprint(
4889
5613
  "[cache-sync] prune-conversations skipped: another process "
@@ -4896,7 +5620,7 @@ def cmd_cache_sync(args: argparse.Namespace) -> int:
4896
5620
  f"{result.claude_messages} message(s), "
4897
5621
  f"codex {result.codex_conversations} conversation(s) / "
4898
5622
  f"{result.codex_events} event(s). "
4899
- f"Run `cctally db vacuum` to reclaim the freed space."
5623
+ f"Run `cctally db vacuum --db conversations` to reclaim the freed space."
4900
5624
  )
4901
5625
  return 0
4902
5626
 
@@ -4963,6 +5687,57 @@ def cmd_cache_sync(args: argparse.Namespace) -> int:
4963
5687
  f"{stats.token_events_skipped} drift-skipped"
4964
5688
  )
4965
5689
 
5690
+ # #320: transcript/search ingestion is a second physical database with its
5691
+ # own cursors and flocks. Run it only after the core providers have
5692
+ # committed so a slow/failed transcript pass can never roll back accounting
5693
+ # or quota state.
5694
+ try:
5695
+ conversation_conn = open_conversations_db()
5696
+ except (OSError, sqlite3.DatabaseError) as exc:
5697
+ eprint(
5698
+ f"[cache-sync] transcript store unavailable ({exc}); "
5699
+ "core accounting/quota sync is complete"
5700
+ )
5701
+ _p_root.__exit__(None, None, None)
5702
+ if _perf.enabled():
5703
+ _perf.flush_stderr(_perf.current_root())
5704
+ return 1 if args.rebuild else (1 if contended else 0)
5705
+ try:
5706
+ if source in ("claude", "all"):
5707
+ conv_stats = sync_claude_conversations(
5708
+ conversation_conn, rebuild=args.rebuild, lock_timeout=lt
5709
+ )
5710
+ if conv_stats.lock_contended:
5711
+ eprint(
5712
+ "[cache-sync] transcript sync skipped (claude): "
5713
+ "another process holds the conversations lock"
5714
+ )
5715
+ contended = contended or bool(args.rebuild)
5716
+ else:
5717
+ eprint(
5718
+ f"[cache-sync] claude transcripts done: "
5719
+ f"{conv_stats.files_processed} processed, "
5720
+ f"{conv_stats.files_skipped_unchanged} skipped"
5721
+ )
5722
+ if source in ("codex", "all"):
5723
+ conv_stats = sync_codex_conversations(
5724
+ conversation_conn, rebuild=args.rebuild, lock_timeout=lt
5725
+ )
5726
+ if conv_stats.lock_contended:
5727
+ eprint(
5728
+ "[cache-sync] transcript sync skipped (codex): "
5729
+ "another process holds the conversations lock"
5730
+ )
5731
+ contended = contended or bool(args.rebuild)
5732
+ else:
5733
+ eprint(
5734
+ f"[cache-sync] codex transcripts done: "
5735
+ f"{conv_stats.files_processed} processed, "
5736
+ f"{conv_stats.files_skipped_unchanged} skipped"
5737
+ )
5738
+ finally:
5739
+ conversation_conn.close()
5740
+
4966
5741
  _p_root.__exit__(None, None, None)
4967
5742
  # #276 perf: when tracing is enabled, flush the completed "cache-sync"
4968
5743
  # phase tree to stderr (stdout stays byte-identical). No-op when off.