cctally 1.60.0 → 1.62.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.
@@ -106,6 +106,7 @@ import os
106
106
  import pathlib
107
107
  import sqlite3
108
108
  import sys
109
+ import time
109
110
  from dataclasses import dataclass, field
110
111
  from typing import Any, Callable, Iterator
111
112
 
@@ -341,22 +342,40 @@ def _resolve_project_key(
341
342
  is_unknown=True,
342
343
  )
343
344
 
345
+ # Win 1 (#269 §14, Codex-M4 P1): raw-`project_path` fast path. The
346
+ # normalized cache is keyed on ``os.path.realpath(...)``, so on the base
347
+ # code the expensive realpath/lstat walk runs once per ENTRY (~190K on a
348
+ # large instance) rather than once per DISTINCT raw spelling (~10K). A
349
+ # namespaced raw key (``("raw", project_path)``) — a tuple that can never
350
+ # collide with the normalized string keys — short-circuits the common case
351
+ # (the same raw spelling repeated across a project's entries). This does
352
+ # NOT replace the normalized cache: a raw MISS still consults/populates the
353
+ # normalized entry, so ``mode="full-path"`` symlink-alias collapse to the
354
+ # first spelling seen is preserved (a second alias misses the raw fast
355
+ # path, hits the normalized entry, and returns the first spelling's key).
356
+ # Byte-identical for ALL modes.
357
+ raw_key = ("raw", project_path)
358
+ raw_hit = cache.get(raw_key)
359
+ if raw_hit is not None:
360
+ return raw_hit
361
+
344
362
  if mode == "full-path":
345
363
  normalized = os.path.realpath(os.path.expanduser(project_path))
346
364
  key = cache.get(normalized)
347
- if key is not None:
348
- return key
349
- key = ProjectKey(
350
- bucket_path=normalized,
351
- display_key=project_path, # raw, so user sees what they typed
352
- git_root=None,
353
- )
354
- cache[normalized] = key
365
+ if key is None:
366
+ key = ProjectKey(
367
+ bucket_path=normalized,
368
+ display_key=project_path, # raw, so user sees what they typed
369
+ git_root=None,
370
+ )
371
+ cache[normalized] = key
372
+ cache[raw_key] = key
355
373
  return key
356
374
 
357
375
  normalized = os.path.realpath(os.path.expanduser(project_path))
358
376
  cached = cache.get(normalized)
359
377
  if cached is not None:
378
+ cache[raw_key] = cached
360
379
  return cached
361
380
 
362
381
  home = os.path.expanduser("~")
@@ -371,6 +390,7 @@ def _resolve_project_key(
371
390
  git_root=cur,
372
391
  )
373
392
  cache[normalized] = key
393
+ cache[raw_key] = key
374
394
  return key
375
395
  cur = os.path.dirname(cur)
376
396
 
@@ -381,6 +401,7 @@ def _resolve_project_key(
381
401
  is_no_git=True,
382
402
  )
383
403
  cache[normalized] = key
404
+ cache[raw_key] = key
384
405
  return key
385
406
 
386
407
 
@@ -481,6 +502,19 @@ class IngestStats:
481
502
  and self.files_failed == 0)
482
503
 
483
504
 
505
+ @dataclass
506
+ class PruneResult:
507
+ """Outcome of _prune_orphaned_cache_entries: how much of the derived Claude
508
+ surface was removed for safely-orphaned source paths, plus the orphan paths
509
+ left in place (residual — a gate failed, so `--rebuild` is the escape hatch)
510
+ and whether the flock was contended (nothing mutated)."""
511
+ pruned_files: int = 0
512
+ pruned_entries: int = 0
513
+ pruned_messages: int = 0
514
+ residual_paths: "list[str]" = field(default_factory=list)
515
+ contended: bool = False
516
+
517
+
484
518
  def _progress_stderr(stats: IngestStats, *, force: bool = False) -> None:
485
519
  """Default stderr progress callback. Every 200 files or when forced."""
486
520
  if not force and stats.files_processed % 200 != 0:
@@ -577,6 +611,25 @@ def _ensure_session_files_row(conn: sqlite3.Connection, source_path: str) -> Non
577
611
  conn.commit()
578
612
 
579
613
 
614
+ # How long `cache-sync --rebuild` (and the --prune-orphans path) waits on the
615
+ # cache.db flock before giving up. Routine auto-syncs stay non-blocking
616
+ # (lock_timeout=None); an explicit rebuild is worth a bounded wait so a running
617
+ # dashboard's background tick doesn't turn the rebuild into a silent no-op.
618
+ # Read at call time in cmd_cache_sync so tests can monkeypatch it low.
619
+ _REBUILD_LOCK_TIMEOUT_SECONDS = 30.0
620
+
621
+
622
+ # Orphan-warning throttle: warn only when the detected orphan set CHANGES,
623
+ # so a long-lived dashboard doesn't re-spam the "[cache] N tracked file(s) no
624
+ # longer on disk" line every ~5s sync tick. Reset in tests.
625
+ _LAST_WARNED_ORPHAN_SET: "frozenset[str]" = frozenset()
626
+
627
+
628
+ def _reset_orphan_warning_throttle():
629
+ global _LAST_WARNED_ORPHAN_SET
630
+ _LAST_WARNED_ORPHAN_SET = frozenset()
631
+
632
+
580
633
  # Flags whose presence means the cache is mid-migration / mid-reingest. A
581
634
  # targeted (only_paths) ingest DECLINES when any is set and defers to the next
582
635
  # full background sync — inserting through a half-migrated FTS shape or skipping
@@ -612,12 +665,181 @@ def _targeted_has_pending_global_work(conn) -> bool:
612
665
  return row is not None
613
666
 
614
667
 
668
+ def _acquire_cache_flock(lock_fh, *, timeout):
669
+ """Acquire the exclusive cache flock on ``lock_fh``.
670
+
671
+ ``timeout is None`` -> a single non-blocking attempt (today's behavior
672
+ for routine auto-syncs): returns False immediately on contention.
673
+ ``timeout > 0`` -> retry ``LOCK_NB`` every ~0.2s until acquired or the
674
+ deadline elapses. Returns True iff the lock is held on return.
675
+
676
+ A retry-with-sleep loop, NOT a SIGALRM-based blocking LOCK_EX: the
677
+ dashboard runs its sync on a background thread, where Python signals
678
+ never fire, so an alarm timeout would silently never trip.
679
+ """
680
+ deadline = None if timeout is None else (time.monotonic() + timeout)
681
+ while True:
682
+ try:
683
+ fcntl.flock(lock_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
684
+ return True
685
+ except BlockingIOError:
686
+ if deadline is None or time.monotonic() >= deadline:
687
+ return False
688
+ time.sleep(0.2)
689
+
690
+
691
+ def _prune_orphaned_cache_entries(conn, *, lock_timeout=None):
692
+ """Safely prune the FULL derived Claude cache surface for orphaned
693
+ (removed-from-disk) source paths. See the design spec
694
+ docs/superpowers/specs/2026-07-04-cache-orphan-prune-design.md §2.
695
+
696
+ Kept OUT of the shared sync_cache (which stays detect-only, fixture-safe):
697
+ only real runtimes call this — the CLI cache-sync --prune-orphans and the
698
+ dashboard self-heal — so synthetic /fake/… fixture paths never reach it.
699
+
700
+ Three gates decide safety per orphan path P (session_id sid):
701
+ A) sid is non-null and not shared by any surviving on-disk file
702
+ (cheap prefilter; "same session_id" is empirical, not proof).
703
+ B) coverage: every one of P's session_entries (msg_id, req_id) keys
704
+ has a conversation_messages row under P's OWN source_path (else
705
+ it is a uuid-less blind spot -> unprovable -> residual).
706
+ C) disjointness: none of P's keys appears in conversation_messages
707
+ under a surviving on-disk source_path (a survivor physically holds
708
+ the same turn -> deleting P's deduped cost row would lose it).
709
+ Anything failing A/B/C is left as residual (reported; for `--rebuild`).
710
+
711
+ Deletes the full derived surface for the safe set in ONE transaction
712
+ (conversation_file_touches -> conversation_messages [+FTS trigger] ->
713
+ conversation_ai_titles [+FTS trigger] -> session_entries -> session_files),
714
+ then recomputes the conversation_sessions rollup for exactly the pruned
715
+ session_ids (all their messages gone -> the stale rail rows drop). Does NOT
716
+ write the walk-complete marker: the next full sync_cache re-establishes it
717
+ on a clean walk. Acquires the cache.db.lock flock itself; a contended
718
+ lock_timeout returns a `contended` result without mutating.
719
+ """
720
+ result = PruneResult()
721
+ _cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
722
+ _cctally_core.CACHE_LOCK_PATH.touch()
723
+ lock_fh = open(_cctally_core.CACHE_LOCK_PATH, "w")
724
+ try:
725
+ if not _acquire_cache_flock(lock_fh, timeout=lock_timeout):
726
+ result.contended = True
727
+ return result
728
+
729
+ tracked = conn.execute(
730
+ "SELECT path, size_bytes, session_id FROM session_files").fetchall()
731
+ on_disk = {p for (p, _sz, _sid) in tracked if os.path.isfile(p)}
732
+ surviving_sids = {sid for (p, _sz, sid) in tracked
733
+ if p in on_disk and sid is not None}
734
+ orphan_cands = [(p, sid) for (p, sz, sid) in tracked
735
+ if sz and p not in on_disk]
736
+ if not orphan_cands:
737
+ return result
738
+
739
+ safe_paths = []
740
+ pruned_sids = set()
741
+ for path, sid in orphan_cands:
742
+ if sid is None or sid in surviving_sids: # Gate A
743
+ result.residual_paths.append(path)
744
+ continue
745
+ keys = conn.execute(
746
+ "SELECT DISTINCT msg_id, req_id FROM session_entries "
747
+ "WHERE source_path=? AND msg_id IS NOT NULL AND req_id IS NOT NULL",
748
+ (path,)).fetchall()
749
+ ok = True
750
+ for mid, rid in keys:
751
+ covered = conn.execute( # Gate B
752
+ "SELECT 1 FROM conversation_messages "
753
+ "WHERE msg_id=? AND req_id=? AND source_path=? LIMIT 1",
754
+ (mid, rid, path)).fetchone() is not None
755
+ if not covered:
756
+ ok = False
757
+ break
758
+ shared = conn.execute( # Gate C
759
+ "SELECT source_path FROM conversation_messages "
760
+ "WHERE msg_id=? AND req_id=?", (mid, rid)).fetchall()
761
+ if any(sp in on_disk for (sp,) in shared):
762
+ ok = False
763
+ break
764
+ if not ok:
765
+ result.residual_paths.append(path)
766
+ continue
767
+ safe_paths.append(path)
768
+ pruned_sids.add(sid)
769
+
770
+ if not safe_paths:
771
+ return result
772
+
773
+ # No IN(...) chunking: safe_paths is bounded by the orphan count (a
774
+ # handful of removed files), well under SQLite's variable limit;
775
+ # _recompute_conversation_sessions chunks its own session-id list.
776
+ ph = ",".join("?" * len(safe_paths))
777
+ result.pruned_messages = conn.execute(
778
+ f"SELECT count(*) FROM conversation_messages WHERE source_path IN ({ph})",
779
+ safe_paths).fetchone()[0]
780
+ conn.execute("BEGIN")
781
+ try:
782
+ conn.execute(
783
+ f"DELETE FROM conversation_file_touches WHERE message_id IN "
784
+ f"(SELECT id FROM conversation_messages WHERE source_path IN ({ph}))",
785
+ safe_paths)
786
+ conn.execute(
787
+ f"DELETE FROM conversation_messages WHERE source_path IN ({ph})", safe_paths)
788
+ conn.execute(
789
+ f"DELETE FROM conversation_ai_titles WHERE source_path IN ({ph})", safe_paths)
790
+ result.pruned_entries = conn.execute(
791
+ f"DELETE FROM session_entries WHERE source_path IN ({ph})", safe_paths).rowcount
792
+ result.pruned_files = conn.execute(
793
+ f"DELETE FROM session_files WHERE path IN ({ph})", safe_paths).rowcount
794
+ _recompute_conversation_sessions(conn, list(pruned_sids))
795
+ conn.commit()
796
+ except BaseException:
797
+ conn.rollback()
798
+ raise
799
+ return result
800
+ finally:
801
+ try:
802
+ fcntl.flock(lock_fh, fcntl.LOCK_UN)
803
+ except OSError:
804
+ pass
805
+ lock_fh.close()
806
+
807
+
808
+ def _bump_mutation_seq(conn: sqlite3.Connection) -> int:
809
+ """Atomically increment the ``session_entries`` mutation counter in
810
+ ``cache_meta`` and return the new value (#270, spec §6).
811
+
812
+ The counter (key ``session_entries_mutation_seq``) is a monotonic integer
813
+ stamped onto every insert and every WHERE-passing in-place UPSERT so an
814
+ id-stable finalization advances the composite dispatch signature (via the
815
+ ``entry_mutation_seq`` leg) and the change-aware watermark, closing the
816
+ dashboard idle-path stale-snapshot hole.
817
+
818
+ ``value`` has TEXT affinity; ``CAST(... AS INTEGER) + 1`` yields an integer
819
+ stored back as text, and ``RETURNING value`` returns that text form so
820
+ ``int(...)`` is correct. Called PER FILE inside ``sync_cache``'s per-file
821
+ write transaction (rollback-safe: a file that rolls back reverts the counter
822
+ and its row stamps together), under the single-writer ``cache.db.lock``
823
+ flock, so no concurrency guard beyond the flock is needed. ``cache_meta`` is
824
+ guaranteed present by ``_apply_cache_schema`` before any ``sync_cache`` runs.
825
+ """
826
+ row = conn.execute(
827
+ "INSERT INTO cache_meta(key, value) "
828
+ "VALUES ('session_entries_mutation_seq', '1') "
829
+ "ON CONFLICT(key) DO UPDATE SET "
830
+ " value = CAST(cache_meta.value AS INTEGER) + 1 "
831
+ "RETURNING value"
832
+ ).fetchone()
833
+ return int(row[0])
834
+
835
+
615
836
  def sync_cache(
616
837
  conn: sqlite3.Connection,
617
838
  *,
618
839
  progress: Callable[[IngestStats], None] | None = None,
619
840
  rebuild: bool = False,
620
841
  only_paths: "set[str] | None" = None,
842
+ lock_timeout: "float | None" = None,
621
843
  ) -> IngestStats:
622
844
  """Read-through delta ingest. Acquires an exclusive fcntl.flock; if
623
845
  another process holds it, returns immediately with lock_contended=True
@@ -635,9 +857,7 @@ def sync_cache(
635
857
 
636
858
  lock_fh = open(_cctally_core.CACHE_LOCK_PATH, "w")
637
859
  try:
638
- try:
639
- fcntl.flock(lock_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
640
- except BlockingIOError:
860
+ if not _acquire_cache_flock(lock_fh, timeout=lock_timeout):
641
861
  eprint("[cache] sync already in progress; using existing cache")
642
862
  stats.lock_contended = True
643
863
  return stats
@@ -994,27 +1214,46 @@ def sync_cache(
994
1214
  # tracked in session_files (with data already ingested) but no
995
1215
  # longer present on disk leaves orphaned session_entries rows that
996
1216
  # the per-file loop below never visits — it iterates only on-disk
997
- # `paths`. sync_cache deliberately does NOT prune those orphans
998
- # in-place: a deleted file shares the truncation hazard (under the
999
- # sticky source_path dedup a surviving file may carry the same
1000
- # (msg_id, req_id) yet keep its size_bytes, so a per-orphan DELETE
1001
- # could drop a row the survivor still owns without re-ingesting
1002
- # it), and a blanket full-reset would wrongly fire on the
1003
- # legitimate "cache seeded with synthetic source paths" fixture
1004
- # pattern. Instead we INVALIDATE the walk-complete marker: an
1005
- # orphaned cache no longer faithfully mirrors disk, so it is by
1006
- # the marker's own definition not a complete walk. We must
1007
- # actively DELETE any marker a PRIOR clean walk left behind (not
1008
- # merely withhold THIS run's end-of-loop rewrite that rewrite is
1009
- # gated on walk_clean, but a stale marker from a previous sync
1010
- # would otherwise survive and keep vouching for completeness).
1011
- # Setting walk_clean=False additionally suppresses the end-of-loop
1012
- # rewrite so the marker stays absent for this run. With the marker
1013
- # gone the upgrade gate DEFERs the 008/009/010 recomputes (rather
1014
- # than certifying aggregates that still include data from files no
1015
- # longer on disk); the operator clears the orphans by running
1016
- # `cache-sync --rebuild` (the documented re-derive path), which
1017
- # re-establishes the marker. Only paths whose row carried ingested
1217
+ # `paths`. sync_cache stays DETECT-ONLY here it never prunes
1218
+ # orphans in-place. Two reasons it must not delete from this hot,
1219
+ # shared path: (1) the truncation hazard under the sticky
1220
+ # source_path dedup a surviving file may carry the same
1221
+ # (msg_id, req_id) yet keep its size_bytes, so a naive per-orphan
1222
+ # DELETE could drop a deduped cost row the survivor still owns
1223
+ # without re-ingesting it; (2) fixture safety a blanket full-reset
1224
+ # would wrongly fire on the legitimate "cache seeded with synthetic
1225
+ # source paths" fixture pattern, and only sync_cache runs against
1226
+ # those fixtures. So detection does two things and no more: it emits
1227
+ # a THROTTLED warning (once per distinct orphan set a removed
1228
+ # worktree persists, so an unthrottled warn would re-spam every
1229
+ # dashboard tick) and it INVALIDATES the walk-complete marker. An
1230
+ # orphaned cache no longer faithfully mirrors disk, so it is — by the
1231
+ # marker's own definition not a complete walk. We actively DELETE
1232
+ # any marker a PRIOR clean walk left behind (idempotently only when
1233
+ # one exists, so a repeated orphaned sync doesn't churn a no-op write
1234
+ # txn every tick); merely withholding THIS run's end-of-loop rewrite
1235
+ # is not enough, since a stale marker from a previous sync would
1236
+ # otherwise survive and keep vouching for completeness. Setting
1237
+ # walk_clean=False additionally suppresses the end-of-loop rewrite so
1238
+ # the marker stays absent for this run. With the marker gone the
1239
+ # upgrade gate DEFERs the 008/009/010 recomputes (rather than
1240
+ # certifying aggregates that still include data from files no longer
1241
+ # on disk). The safe CLEANUP lives OUT of sync_cache, in
1242
+ # _prune_orphaned_cache_entries — it re-derives the safe orphan set
1243
+ # independently and removes the full derived surface under three
1244
+ # gates: A (session-id not shared by a survivor), B (coverage — every
1245
+ # orphan (msg_id, req_id) key has a conversation_messages row under
1246
+ # the orphan's OWN path), and C (disjointness — no key of the orphan
1247
+ # appears in conversation_messages under a surviving path). B + C
1248
+ # together close the truncation hazard soundly: C refuses to delete
1249
+ # any key a survivor physically holds, and B refuses the uuid-less
1250
+ # blind spot where coverage can't be proved (anything failing A/B/C
1251
+ # is left as residual, cleared by `--rebuild`). That helper is
1252
+ # invoked by `cache-sync --prune-orphans` and the dashboard
1253
+ # self-heal, never from here (so fixtures never reach a destructive
1254
+ # delete); `cache-sync --rebuild` remains the whole-cache re-derive.
1255
+ # Both cleanup paths re-establish the marker on the next clean walk.
1256
+ # Only paths whose row carried ingested
1018
1257
  # bytes (size_bytes > 0) count — a size_bytes=0 row holds no
1019
1258
  # session_entries, so its absence leaves no orphan. The DELETE +
1020
1259
  # commit lands BEFORE the per-file read+parse loop, so no write
@@ -1027,22 +1266,43 @@ def sync_cache(
1027
1266
  # for targeted: the live-tail fast path never prunes orphans (the full
1028
1267
  # background sync owns that).
1029
1268
  if not targeted:
1269
+ global _LAST_WARNED_ORPHAN_SET
1030
1270
  on_disk_paths = {str(jp) for jp in paths}
1031
1271
  orphaned_tracked_paths = [
1032
1272
  p for p, (size_bytes, _, _) in existing.items()
1033
1273
  if size_bytes and p not in on_disk_paths
1034
1274
  ]
1035
1275
  if orphaned_tracked_paths:
1036
- eprint(
1037
- f"[cache] {len(orphaned_tracked_paths)} tracked file(s) no "
1038
- f"longer on disk; invalidating walk-complete marker "
1039
- f"(run `cache-sync --rebuild` to prune orphaned entries)"
1040
- )
1041
- conn.execute(
1042
- "DELETE FROM cache_meta WHERE key='claude_ingest_walk_complete'"
1043
- )
1044
- conn.commit()
1276
+ # Throttle the warning: emit only when the orphan set CHANGES,
1277
+ # not on every ~5s dashboard tick (a removed worktree persists,
1278
+ # so an unthrottled warn re-spams indefinitely). The marker
1279
+ # invalidation below stays UNCONDITIONAL throttling the print
1280
+ # must not weaken the D5a invariant.
1281
+ cur = frozenset(orphaned_tracked_paths)
1282
+ if cur != _LAST_WARNED_ORPHAN_SET:
1283
+ eprint(
1284
+ f"[cache] {len(orphaned_tracked_paths)} tracked file(s) no "
1285
+ f"longer on disk; invalidating walk-complete marker "
1286
+ f"(run `cache-sync --prune-orphans` to prune, or "
1287
+ f"`cache-sync --rebuild`)"
1288
+ )
1289
+ _LAST_WARNED_ORPHAN_SET = cur
1290
+ # Idempotent marker invalidation: only DELETE (and commit) when a
1291
+ # prior clean walk actually left the marker behind, so a repeated
1292
+ # orphaned sync doesn't churn a no-op write transaction every tick.
1293
+ if conn.execute(
1294
+ "SELECT 1 FROM cache_meta WHERE key='claude_ingest_walk_complete'"
1295
+ ).fetchone() is not None:
1296
+ conn.execute(
1297
+ "DELETE FROM cache_meta WHERE key='claude_ingest_walk_complete'"
1298
+ )
1299
+ conn.commit()
1045
1300
  walk_clean = False # orphaned rows -> cache doesn't mirror disk (D5a)
1301
+ else:
1302
+ # No orphans this walk: clear the throttle memory so a LATER,
1303
+ # distinct orphan episode (even one recreated at the same paths)
1304
+ # warns again rather than being silently suppressed.
1305
+ _LAST_WARNED_ORPHAN_SET = frozenset()
1046
1306
 
1047
1307
  # Pre-scan for any truncation among tracked files. Under the
1048
1308
  # ccusage-parity ON CONFLICT DO UPDATE, source_path is PINNED to
@@ -1261,6 +1521,18 @@ def sync_cache(
1261
1521
  )
1262
1522
  stats.files_reset_truncated += 1
1263
1523
  if rows:
1524
+ # #270: bump the per-file mutation counter BEFORE capturing
1525
+ # `before`, so this cache_meta write stays OUTSIDE the
1526
+ # [before, after] total_changes window and never inflates
1527
+ # `stats.rows_changed` (byte-identity). Per file (not once
1528
+ # per sync) for rollback-safety: the counter write is atomic
1529
+ # with the row stamps in this file's write transaction, so a
1530
+ # file that rolls back reverts both together (spec §6). Each
1531
+ # row built for this file is stamped mutation_seq = this
1532
+ # file's `sync_seq` and mutation_min_ts = its own
1533
+ # timestamp_utc (== the event time on insert).
1534
+ sync_seq = _bump_mutation_seq(conn)
1535
+ stamped_rows = [r + (sync_seq, r[2]) for r in rows]
1264
1536
  before = conn.total_changes
1265
1537
  # ccusage-parity ON CONFLICT DO UPDATE: higher-token total
1266
1538
  # wins on conflict; speed-set breaks ties. The partial
@@ -1290,8 +1562,9 @@ def sync_cache(
1290
1562
  (source_path, line_offset, timestamp_utc, model,
1291
1563
  msg_id, req_id, input_tokens, output_tokens,
1292
1564
  cache_create_tokens, cache_read_tokens,
1293
- usage_extra_json, speed, cost_usd_raw)
1294
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
1565
+ usage_extra_json, speed, cost_usd_raw,
1566
+ mutation_seq, mutation_min_ts)
1567
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
1295
1568
  ON CONFLICT(msg_id, req_id)
1296
1569
  WHERE msg_id IS NOT NULL AND req_id IS NOT NULL
1297
1570
  DO UPDATE SET
@@ -1303,7 +1576,32 @@ def sync_cache(
1303
1576
  cache_read_tokens = excluded.cache_read_tokens,
1304
1577
  usage_extra_json = excluded.usage_extra_json,
1305
1578
  speed = excluded.speed,
1306
- cost_usd_raw = excluded.cost_usd_raw
1579
+ cost_usd_raw = excluded.cost_usd_raw,
1580
+ -- #270: stamp the change. mutation_seq advances
1581
+ -- exactly when this guarded UPSERT's WHERE passes
1582
+ -- (incl. the equal-tokens speed-tiebreak branch,
1583
+ -- Codex-2d). mutation_min_ts accumulates the
1584
+ -- EARLIEST event time the row has held —
1585
+ -- session_entries.mutation_min_ts is the OLD
1586
+ -- (pre-update) value, excluded.timestamp_utc the
1587
+ -- finalization's new time — so a finalization
1588
+ -- that moves the row across a bucket boundary
1589
+ -- still lets the closed-bucket watermark reach
1590
+ -- the OLD bucket (spec §6/§7b). The SET reads
1591
+ -- pre-update column values, unaffected by the
1592
+ -- sibling timestamp_utc = excluded.timestamp_utc.
1593
+ -- COALESCE(mutation_min_ts, timestamp_utc) guards
1594
+ -- a LEGACY row (written before these columns
1595
+ -- existed: mutation_min_ts NULL): SQLite scalar
1596
+ -- MIN(NULL, x) is NULL, which would strand the
1597
+ -- watermark; the pre-update timestamp_utc is that
1598
+ -- legacy row's old event time, so both its old
1599
+ -- and new buckets stay reachable. No-op for
1600
+ -- non-legacy rows (mutation_min_ts already set).
1601
+ mutation_seq = excluded.mutation_seq,
1602
+ mutation_min_ts = MIN(COALESCE(session_entries.mutation_min_ts,
1603
+ session_entries.timestamp_utc),
1604
+ excluded.timestamp_utc)
1307
1605
  WHERE
1308
1606
  (excluded.input_tokens + excluded.output_tokens
1309
1607
  + excluded.cache_create_tokens + excluded.cache_read_tokens)
@@ -1319,7 +1617,7 @@ def sync_cache(
1319
1617
  AND excluded.speed IS NOT NULL
1320
1618
  AND session_entries.speed IS NULL
1321
1619
  )""",
1322
- rows,
1620
+ stamped_rows,
1323
1621
  )
1324
1622
  stats.rows_changed += conn.total_changes - before
1325
1623
  # Conversation message ingest (Plan 1). Lands in the SAME
@@ -2070,7 +2368,14 @@ def iter_entries(
2070
2368
  )
2071
2369
  sql += r" AND source_path LIKE ? ESCAPE '\'"
2072
2370
  params.append(f"%/projects/{escaped}/%")
2073
- sql += " ORDER BY timestamp_utc ASC"
2371
+ # Explicit (timestamp_utc, id) tie-break (#271 §5 / Codex-3): the
2372
+ # `idx_entries_timestamp` index already stores keys as (timestamp_utc,
2373
+ # rowid), so an index-driven walk yields exactly this order and pinning it
2374
+ # is free at runtime (goldens unchanged). The pin converts that OBSERVED
2375
+ # planner behavior into a CONTRACT — a guaranteed total fold order — which
2376
+ # is what makes #271's incremental current-bucket append provably
2377
+ # byte-identical to the full-pass fold.
2378
+ sql += " ORDER BY timestamp_utc ASC, id ASC"
2074
2379
 
2075
2380
  entries: list[UsageEntry] = []
2076
2381
  for row in conn.execute(sql, params):
@@ -2096,6 +2401,74 @@ def iter_entries(
2096
2401
  return entries
2097
2402
 
2098
2403
 
2404
+ def iter_entries_with_id(
2405
+ conn: sqlite3.Connection,
2406
+ range_start: dt.datetime,
2407
+ range_end: dt.datetime,
2408
+ *,
2409
+ after_seq: int | None = None,
2410
+ after_ts: dt.datetime | None = None,
2411
+ ) -> list[tuple[int, UsageEntry]]:
2412
+ """Like ``iter_entries`` but yields ``(id, UsageEntry)`` rows, ordered
2413
+ ``(timestamp_utc, id)``, for #271's current-bucket accumulator (§7d).
2414
+
2415
+ When ``after_seq`` / ``after_ts`` are given, restricts to the incremental
2416
+ delta ``(mutation_seq > after_seq OR timestamp_utc > after_ts)`` — the
2417
+ ``mutation_seq`` leg (#270 §8) catches genuinely-new ingests AND id-stable
2418
+ in-place finalizations (which advance ``mutation_seq`` while leaving ``id``
2419
+ flat, so the pre-#270 ``id > after_id`` leg missed them and double-counted);
2420
+ the ``timestamp_utc`` leg catches already-ingested rows that newly entered
2421
+ the window because ``now`` advanced (Codex-1). Both disjuncts stay
2422
+ index-usable (``idx_entries_mutation_seq`` range + ``idx_entries_timestamp``
2423
+ range), so the delta is O(delta), not a full current-bucket scan (an
2424
+ ``EXPLAIN QUERY PLAN`` regression test guards this, Codex-2). On a
2425
+ pure-insert interval ``{mutation_seq > after_seq}`` == ``{id > after_id}``
2426
+ (each insert carries a fresh seq monotone with id), so the delta row set is
2427
+ byte-identical to the old ``id`` leg (§7b). The ``id`` is still SELECTed (the
2428
+ accumulator's ``id <= reconciled_max_id`` pre-existing-row cold-refold trigger
2429
+ reads it). ``iter_entries``' public ``list[UsageEntry]`` shape and
2430
+ ``UsageEntry`` (which has no ``id`` field) are left untouched — this is a
2431
+ thin internal sibling, not an overload (Codex-5).
2432
+ """
2433
+ start_iso = range_start.astimezone(dt.timezone.utc).isoformat()
2434
+ end_iso = range_end.astimezone(dt.timezone.utc).isoformat()
2435
+ sql = (
2436
+ "SELECT id, timestamp_utc, model, input_tokens, output_tokens, "
2437
+ "cache_create_tokens, cache_read_tokens, speed, cost_usd_raw, source_path "
2438
+ "FROM session_entries "
2439
+ "WHERE timestamp_utc >= ? AND timestamp_utc <= ?"
2440
+ )
2441
+ params: list[Any] = [start_iso, end_iso]
2442
+ if after_seq is not None or after_ts is not None:
2443
+ after_seq_val = -1 if after_seq is None else int(after_seq)
2444
+ after_ts_val = (
2445
+ "" if after_ts is None
2446
+ else after_ts.astimezone(dt.timezone.utc).isoformat()
2447
+ )
2448
+ sql += " AND (mutation_seq > ? OR timestamp_utc > ?)"
2449
+ params += [after_seq_val, after_ts_val]
2450
+ sql += " ORDER BY timestamp_utc ASC, id ASC"
2451
+
2452
+ out: list[tuple[int, UsageEntry]] = []
2453
+ for row in conn.execute(sql, params):
2454
+ usage: dict[str, Any] = {
2455
+ "input_tokens": row[3],
2456
+ "output_tokens": row[4],
2457
+ "cache_creation_input_tokens": row[5],
2458
+ "cache_read_input_tokens": row[6],
2459
+ }
2460
+ if row[7] is not None: # speed (materialized column, #181)
2461
+ usage["speed"] = row[7]
2462
+ out.append((row[0], UsageEntry(
2463
+ timestamp=dt.datetime.fromisoformat(row[1]),
2464
+ model=row[2],
2465
+ usage=usage,
2466
+ cost_usd=row[8],
2467
+ source_path=row[9],
2468
+ )))
2469
+ return out
2470
+
2471
+
2099
2472
  def _collect_entries_direct(
2100
2473
  range_start: dt.datetime,
2101
2474
  range_end: dt.datetime,
@@ -2230,7 +2603,16 @@ def get_claude_session_entries(
2230
2603
  )
2231
2604
  sql += r" AND se.source_path LIKE ? ESCAPE '\'"
2232
2605
  params.append(f"%/projects/{escaped}/%")
2233
- sql += " ORDER BY se.timestamp_utc ASC"
2606
+ # Explicit (timestamp_utc, id) tie-break (#275) — the same contract #271 §5
2607
+ # pinned on `get_entries` (see the twin ORDER BY above). `id` is the rowid, so
2608
+ # against `idx_entries_timestamp` (which stores keys as (timestamp_utc, rowid))
2609
+ # this is free at runtime and byte-identical to today's observed order. Pinning
2610
+ # it makes the fold order a total, plan-INDEPENDENT contract: the #272 warm path
2611
+ # folds today over a narrow `[today_start, now]` query while the cold path folds
2612
+ # over the full `[since, now]` query, and both — plus the `+=` day-row fold and
2613
+ # the by_project partials — must agree on equal-timestamp rows regardless of
2614
+ # which plan SQLite picks for either window.
2615
+ sql += " ORDER BY se.timestamp_utc ASC, se.id ASC"
2234
2616
 
2235
2617
  rows = conn.execute(sql, params).fetchall()
2236
2618
 
@@ -2429,6 +2811,7 @@ def sync_codex_cache(
2429
2811
  *,
2430
2812
  progress: Callable[[CodexIngestStats], None] | None = None,
2431
2813
  rebuild: bool = False,
2814
+ lock_timeout: "float | None" = None,
2432
2815
  ) -> CodexIngestStats:
2433
2816
  """Read-through delta ingest of ~/.codex/sessions/**/*.jsonl.
2434
2817
 
@@ -2448,9 +2831,7 @@ def sync_codex_cache(
2448
2831
 
2449
2832
  lock_fh = open(_cctally_core.CACHE_LOCK_CODEX_PATH, "w")
2450
2833
  try:
2451
- try:
2452
- fcntl.flock(lock_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
2453
- except BlockingIOError:
2834
+ if not _acquire_cache_flock(lock_fh, timeout=lock_timeout):
2454
2835
  eprint("[codex-cache] sync already in progress; using existing cache")
2455
2836
  stats.lock_contended = True
2456
2837
  return stats
@@ -2991,20 +3372,63 @@ def cmd_cache_sync(args: argparse.Namespace) -> int:
2991
3372
  source = getattr(args, "source", "all")
2992
3373
  conn = open_cache_db()
2993
3374
 
3375
+ # --prune-orphans: fast, targeted cleanup of cache rows whose source
3376
+ # JSONL was removed from disk (e.g. a deleted git worktree), without a
3377
+ # full rebuild. Claude-only surface; runs the three-gate safe helper.
3378
+ if getattr(args, "prune_orphans", False):
3379
+ if source == "codex":
3380
+ # The prune surface is Claude-only; Codex orphans are pruned
3381
+ # automatically during codex sync (see sync_codex_cache). Respect
3382
+ # the explicit --source codex rather than silently pruning Claude.
3383
+ eprint(
3384
+ "[cache-sync] --prune-orphans applies to the Claude cache only "
3385
+ "(Codex orphans are pruned automatically during codex sync); "
3386
+ "nothing to do for --source codex."
3387
+ )
3388
+ return 0
3389
+ res = _prune_orphaned_cache_entries(
3390
+ conn, lock_timeout=_REBUILD_LOCK_TIMEOUT_SECONDS
3391
+ )
3392
+ if res.contended:
3393
+ eprint(
3394
+ "[cache-sync] prune-orphans skipped: "
3395
+ "another process holds the lock"
3396
+ )
3397
+ return 1
3398
+ eprint(
3399
+ f"[cache-sync] pruned {res.pruned_files} orphaned file(s), "
3400
+ f"{res.pruned_entries} cost row(s), {res.pruned_messages} message(s)"
3401
+ )
3402
+ if res.residual_paths:
3403
+ eprint(
3404
+ f"[cache-sync] {len(res.residual_paths)} orphan(s) left in place "
3405
+ f"(shared session or missing conversation evidence); "
3406
+ f"run `cache-sync --rebuild` to clear them"
3407
+ )
3408
+ return 0
3409
+
2994
3410
  # Note: when --rebuild is set we delegate the DELETE to sync_cache /
2995
3411
  # sync_codex_cache, which execute it AFTER acquiring the flock. A
2996
3412
  # pre-sync DELETE here would wipe the cache even when the subsequent
2997
3413
  # sync loses the lock race and bails — leaving the user with empty
2998
- # state. See sync_cache() / sync_codex_cache() docstrings.
3414
+ # state. See sync_cache() / sync_codex_cache() docstrings. A rebuild
3415
+ # is worth a bounded wait on the flock (vs the non-blocking auto-sync)
3416
+ # so a running dashboard's background tick doesn't silently no-op it;
3417
+ # if it still can't acquire, we report + exit non-zero rather than lie.
3418
+ lt = _REBUILD_LOCK_TIMEOUT_SECONDS if args.rebuild else None
3419
+ contended = False
2999
3420
 
3000
3421
  if source in ("claude", "all"):
3001
- stats = sync_cache(conn, progress=_progress_stderr, rebuild=args.rebuild)
3422
+ stats = sync_cache(
3423
+ conn, progress=_progress_stderr, rebuild=args.rebuild, lock_timeout=lt
3424
+ )
3002
3425
  _progress_stderr(stats, force=True)
3003
3426
  if stats.lock_contended and args.rebuild:
3004
3427
  eprint(
3005
3428
  "[cache-sync] rebuild skipped (claude): "
3006
3429
  "another process holds the lock"
3007
3430
  )
3431
+ contended = True
3008
3432
  elif not stats.lock_contended:
3009
3433
  eprint(
3010
3434
  f"[cache-sync] claude done: {stats.files_processed} processed, "
@@ -3015,7 +3439,7 @@ def cmd_cache_sync(args: argparse.Namespace) -> int:
3015
3439
 
3016
3440
  if source in ("codex", "all"):
3017
3441
  stats = sync_codex_cache(
3018
- conn, progress=_progress_codex_stderr, rebuild=args.rebuild
3442
+ conn, progress=_progress_codex_stderr, rebuild=args.rebuild, lock_timeout=lt
3019
3443
  )
3020
3444
  _progress_codex_stderr(stats, force=True)
3021
3445
  if stats.lock_contended and args.rebuild:
@@ -3023,6 +3447,7 @@ def cmd_cache_sync(args: argparse.Namespace) -> int:
3023
3447
  "[cache-sync] rebuild skipped (codex): "
3024
3448
  "another process holds the lock"
3025
3449
  )
3450
+ contended = True
3026
3451
  elif not stats.lock_contended:
3027
3452
  eprint(
3028
3453
  f"[cache-sync] codex done: {stats.files_processed} processed, "
@@ -3031,4 +3456,4 @@ def cmd_cache_sync(args: argparse.Namespace) -> int:
3031
3456
  f"{stats.rows_changed} rows changed"
3032
3457
  )
3033
3458
 
3034
- return 0
3459
+ return 1 if contended else 0