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.
- package/CHANGELOG.md +24 -0
- package/bin/_cctally_cache.py +479 -54
- package/bin/_cctally_core.py +8 -0
- package/bin/_cctally_dashboard.py +1467 -234
- package/bin/_cctally_db.py +27 -1
- package/bin/_cctally_doctor.py +2 -0
- package/bin/_cctally_forecast.py +24 -2
- package/bin/_cctally_parser.py +28 -2
- package/bin/_cctally_tui.py +708 -16
- package/bin/_cctally_update.py +49 -9
- package/bin/_cctally_weekrefs.py +101 -2
- package/bin/_lib_aggregators.py +98 -71
- package/bin/_lib_cache_report.py +455 -67
- package/bin/_lib_doctor.py +10 -0
- package/bin/_lib_pricing.py +31 -4
- package/bin/_lib_snapshot_cache.py +1797 -0
- package/bin/_lib_view_models.py +107 -12
- package/bin/cctally +57 -0
- package/dashboard/static/assets/index-0jzYm75p.css +1 -0
- package/dashboard/static/assets/{index-DQH6UPc_.js → index-D_Ylyqsf.js} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-uGwZSssU.css +0 -1
package/bin/_cctally_cache.py
CHANGED
|
@@ -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
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
cache[
|
|
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
|
-
|
|
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
|
|
998
|
-
# in-place
|
|
999
|
-
#
|
|
1000
|
-
#
|
|
1001
|
-
#
|
|
1002
|
-
#
|
|
1003
|
-
#
|
|
1004
|
-
#
|
|
1005
|
-
#
|
|
1006
|
-
#
|
|
1007
|
-
#
|
|
1008
|
-
#
|
|
1009
|
-
#
|
|
1010
|
-
#
|
|
1011
|
-
#
|
|
1012
|
-
#
|
|
1013
|
-
#
|
|
1014
|
-
#
|
|
1015
|
-
#
|
|
1016
|
-
#
|
|
1017
|
-
#
|
|
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
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|