cctally 1.67.1 → 1.69.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 +68 -0
- package/bin/_cctally_alerts.py +54 -2
- package/bin/_cctally_cache.py +754 -109
- package/bin/_cctally_cache_report.py +41 -17
- package/bin/_cctally_codex.py +109 -4
- package/bin/_cctally_config.py +368 -2
- package/bin/_cctally_core.py +187 -15
- package/bin/_cctally_dashboard.py +679 -5
- package/bin/_cctally_dashboard_conversation.py +9 -4
- package/bin/_cctally_dashboard_envelope.py +110 -1
- package/bin/_cctally_dashboard_share.py +557 -79
- package/bin/_cctally_db.py +366 -17
- package/bin/_cctally_diff.py +49 -16
- package/bin/_cctally_doctor.py +131 -0
- package/bin/_cctally_forecast.py +12 -4
- package/bin/_cctally_parser.py +321 -92
- package/bin/_cctally_project.py +24 -8
- package/bin/_cctally_quota.py +1381 -0
- package/bin/_cctally_record.py +319 -14
- package/bin/_cctally_refresh.py +105 -3
- package/bin/_cctally_reporting.py +7 -1
- package/bin/_cctally_setup.py +343 -113
- package/bin/_cctally_statusline.py +228 -0
- package/bin/_cctally_tui.py +787 -7
- package/bin/_lib_alert_axes.py +11 -0
- package/bin/_lib_codex_hooks.py +367 -0
- package/bin/_lib_conversation_query.py +156 -67
- package/bin/_lib_doctor.py +238 -0
- package/bin/_lib_jsonl.py +529 -162
- package/bin/_lib_quota.py +566 -0
- package/bin/_lib_share.py +152 -34
- package/bin/_lib_snapshot_cache.py +26 -0
- package/bin/_lib_source_identity.py +74 -0
- package/bin/cctally +325 -10
- package/dashboard/static/assets/index-CBbErI-P.js +80 -0
- package/dashboard/static/assets/index-kDDVOLa_.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +5 -1
- package/dashboard/static/assets/index-BybNp_Di.js +0 -80
- package/dashboard/static/assets/index-DgAmLK65.css +0 -1
package/bin/_cctally_cache.py
CHANGED
|
@@ -107,7 +107,7 @@ import sqlite3
|
|
|
107
107
|
import sys
|
|
108
108
|
import time
|
|
109
109
|
from dataclasses import dataclass, field
|
|
110
|
-
from typing import Any, Callable, Iterator
|
|
110
|
+
from typing import Any, Callable, Iterator, NamedTuple
|
|
111
111
|
|
|
112
112
|
|
|
113
113
|
def _cctally():
|
|
@@ -121,6 +121,7 @@ def _cctally():
|
|
|
121
121
|
# for ``eprint`` is deleted.
|
|
122
122
|
import _cctally_core
|
|
123
123
|
from _cctally_core import eprint
|
|
124
|
+
from _lib_source_identity import source_root_key
|
|
124
125
|
|
|
125
126
|
|
|
126
127
|
# Module-level back-ref shims for the three out-of-scope JSONL/project
|
|
@@ -165,6 +166,7 @@ CodexEntry = _lib_jsonl.CodexEntry
|
|
|
165
166
|
_CodexIterState = _lib_jsonl._CodexIterState
|
|
166
167
|
_iter_jsonl_entries_with_offsets = _lib_jsonl._iter_jsonl_entries_with_offsets
|
|
167
168
|
_iter_codex_jsonl_entries_with_offsets = _lib_jsonl._iter_codex_jsonl_entries_with_offsets
|
|
169
|
+
_iter_codex_fused_records_with_offsets = _lib_jsonl._iter_codex_fused_records_with_offsets
|
|
168
170
|
_parse_usage_entries = _lib_jsonl._parse_usage_entries
|
|
169
171
|
_should_replace = _lib_jsonl._should_replace
|
|
170
172
|
|
|
@@ -182,6 +184,13 @@ _iter_message_rows = _lib_conversation.iter_message_rows
|
|
|
182
184
|
# the default path.
|
|
183
185
|
_perf = _load_lib("_lib_perf")
|
|
184
186
|
|
|
187
|
+
# #302: the single embedded-pricing version knob (bumped on every pricing sync),
|
|
188
|
+
# used to auto-invalidate the rollup's materialized cost when pricing changes.
|
|
189
|
+
# _lib_pricing is a pure stdlib leaf (no sibling imports), so binding it at
|
|
190
|
+
# module-load is circular-safe. Referenced as a module global by
|
|
191
|
+
# _arm_rollup_backfill_on_pricing_change so a test may monkeypatch it.
|
|
192
|
+
PRICING_SNAPSHOT_DATE = _load_lib("_lib_pricing").PRICING_SNAPSHOT_DATE
|
|
193
|
+
|
|
185
194
|
# Shared by the fused per-file walk AND backfill_conversation_messages so the
|
|
186
195
|
# column list, placeholders, and tuple order live in ONE place — a column
|
|
187
196
|
# add/reorder can't silently desync the two ingest paths (which would land
|
|
@@ -316,6 +325,96 @@ clear_conversation_messages = _cctally_db_sib.clear_conversation_messages
|
|
|
316
325
|
_set_cache_meta = _cctally_db_sib._set_cache_meta
|
|
317
326
|
|
|
318
327
|
|
|
328
|
+
# cache.db WAL hardening (#297). See
|
|
329
|
+
# docs/superpowers/specs/2026-07-13-cache-db-wal-hardening-design.md.
|
|
330
|
+
# `journal_size_limit` bounds the *persistent* WAL file so a checkpoint that
|
|
331
|
+
# actually resets the WAL truncates the file back down to this cap — it is
|
|
332
|
+
# containment/recovery under transient reader contention, NOT a reader-proof
|
|
333
|
+
# hard cap (a pinned long-lived reader still defeats it until it releases).
|
|
334
|
+
CACHE_WAL_SIZE_LIMIT_BYTES = 128 * 1024 * 1024 # 134217728
|
|
335
|
+
# End-of-sync physical-size shrink trigger: only force a TRUNCATE checkpoint
|
|
336
|
+
# once the -wal file has grown past this, so normal small syncs stay cheap.
|
|
337
|
+
CACHE_WAL_CHECKPOINT_TRIGGER_BYTES = 64 * 1024 * 1024 # 67108864
|
|
338
|
+
# Near-nonblocking busy_timeout for the auto end-of-sync checkpoint. It runs
|
|
339
|
+
# BEFORE the ingest flock is released, so a long wait here would stall every
|
|
340
|
+
# above-threshold sync's flock under the heavy-reader contention that motivates
|
|
341
|
+
# the fix — fail fast and rely on journal_size_limit + the next checkpoint.
|
|
342
|
+
CHECKPOINT_AUTO_BUSY_TIMEOUT_MS = 100
|
|
343
|
+
# The explicit `cctally db checkpoint` command may wait this long.
|
|
344
|
+
CHECKPOINT_CMD_BUSY_TIMEOUT_MS = 15000
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
class CheckpointResult(NamedTuple):
|
|
348
|
+
"""Outcome of a single ``PRAGMA wal_checkpoint(TRUNCATE)`` (#297).
|
|
349
|
+
|
|
350
|
+
``busy`` is the checkpoint's own busy flag (a reader/writer held it off);
|
|
351
|
+
``truncated`` means the WAL was actually reset AND the -wal file is now
|
|
352
|
+
zero-length/absent — a checkpoint can copy some frames yet still report
|
|
353
|
+
``busy`` (partial), which is NOT ``truncated``.
|
|
354
|
+
"""
|
|
355
|
+
|
|
356
|
+
db: str
|
|
357
|
+
wal_bytes_before: int
|
|
358
|
+
wal_bytes_after: int
|
|
359
|
+
frames_checkpointed: int
|
|
360
|
+
busy: bool
|
|
361
|
+
truncated: bool
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _wal_file_size(db_path) -> int:
|
|
365
|
+
"""Best-effort size of the -wal sidecar in bytes; 0 if absent/unreadable."""
|
|
366
|
+
try:
|
|
367
|
+
return os.path.getsize(f"{db_path}-wal")
|
|
368
|
+
except OSError:
|
|
369
|
+
return 0
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _run_wal_truncate(conn, db_path, *, db_label: str) -> "CheckpointResult":
|
|
373
|
+
"""Run a best-effort TRUNCATE checkpoint on an already-open connection.
|
|
374
|
+
|
|
375
|
+
PRECONDITION: ``conn`` has NO active transaction (autocommit). The
|
|
376
|
+
``db checkpoint`` command passes a fresh raw connection; the end-of-sync
|
|
377
|
+
caller has committed all ingest work first. ``PRAGMA
|
|
378
|
+
wal_checkpoint(TRUNCATE)`` returns ``(busy, log, checkpointed)``: ``busy=0``
|
|
379
|
+
means the WAL was reset. Measures the -wal size before and after so callers
|
|
380
|
+
can report the shrink without re-deriving it.
|
|
381
|
+
"""
|
|
382
|
+
before = _wal_file_size(db_path)
|
|
383
|
+
row = conn.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone()
|
|
384
|
+
busy = bool(row[0]) if row else True
|
|
385
|
+
frames = int(row[2]) if (row and row[2] is not None) else 0
|
|
386
|
+
after = _wal_file_size(db_path)
|
|
387
|
+
truncated = (not busy) and after == 0
|
|
388
|
+
return CheckpointResult(db_label, before, after, frames, busy, truncated)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _maybe_truncate_wal(conn, db_path) -> None:
|
|
392
|
+
"""End-of-sync best-effort WAL drain (#297).
|
|
393
|
+
|
|
394
|
+
Threshold-gated (only once the -wal file has grown past
|
|
395
|
+
``CACHE_WAL_CHECKPOINT_TRIGGER_BYTES``) and run under a near-nonblocking
|
|
396
|
+
short busy_timeout so it NEVER stalls the held sync flock — the checkpoint
|
|
397
|
+
runs BEFORE the flock is released, so a long busy wait here would stall
|
|
398
|
+
every above-threshold sync under exactly the heavy-reader contention that
|
|
399
|
+
motivates the fix. Restores the prior busy_timeout. Fail-soft: a checkpoint
|
|
400
|
+
error must never fail the sync (observability/hygiene, not correctness).
|
|
401
|
+
|
|
402
|
+
PRECONDITION: ``conn`` has no active transaction — the caller has committed
|
|
403
|
+
all ingest work by this point.
|
|
404
|
+
"""
|
|
405
|
+
try:
|
|
406
|
+
if _wal_file_size(db_path) <= CACHE_WAL_CHECKPOINT_TRIGGER_BYTES:
|
|
407
|
+
return
|
|
408
|
+
prior = conn.execute("PRAGMA busy_timeout").fetchone()[0]
|
|
409
|
+
try:
|
|
410
|
+
conn.execute(f"PRAGMA busy_timeout={CHECKPOINT_AUTO_BUSY_TIMEOUT_MS}")
|
|
411
|
+
_run_wal_truncate(conn, db_path, db_label="cache.db")
|
|
412
|
+
finally:
|
|
413
|
+
conn.execute(f"PRAGMA busy_timeout={prior}")
|
|
414
|
+
except sqlite3.DatabaseError:
|
|
415
|
+
pass # best-effort; observability/hygiene must never fail a sync
|
|
416
|
+
|
|
417
|
+
|
|
319
418
|
_PARSE_HEALTH_SCHEMA = 1
|
|
320
419
|
|
|
321
420
|
|
|
@@ -499,6 +598,361 @@ def _resolve_project_key(
|
|
|
499
598
|
# === Region 2: Codex sessions-dir helpers (was bin/cctally:2072-2099) ===
|
|
500
599
|
|
|
501
600
|
|
|
601
|
+
@dataclass(frozen=True)
|
|
602
|
+
class CodexProviderRoot:
|
|
603
|
+
"""One configured Codex provider boundary and its JSONL walk directory."""
|
|
604
|
+
|
|
605
|
+
provider_root: pathlib.Path
|
|
606
|
+
walk_root: pathlib.Path
|
|
607
|
+
source_root_key: str
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
@dataclass(frozen=True)
|
|
611
|
+
class CodexDiscoveredFile:
|
|
612
|
+
"""One physical rollout paired with its first matching provider root.
|
|
613
|
+
|
|
614
|
+
``physical_path`` is only the de-duplication identity. ``source_path``
|
|
615
|
+
keeps the configured walk spelling because reporting resolves it against
|
|
616
|
+
the configured ``$CODEX_HOME`` roots.
|
|
617
|
+
"""
|
|
618
|
+
|
|
619
|
+
source_path: pathlib.Path
|
|
620
|
+
physical_path: pathlib.Path
|
|
621
|
+
provider_root: pathlib.Path
|
|
622
|
+
walk_root: pathlib.Path
|
|
623
|
+
source_root_key: str
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _canonical_codex_path(path: pathlib.Path) -> pathlib.Path:
|
|
627
|
+
"""Resolve an absolute Codex path, retaining a safe absolute spelling on I/O failure."""
|
|
628
|
+
try:
|
|
629
|
+
return path.resolve()
|
|
630
|
+
except OSError:
|
|
631
|
+
return path.absolute()
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _codex_provider_roots() -> list[CodexProviderRoot]:
|
|
635
|
+
"""Return configured provider roots with their sessions/direct walk roots.
|
|
636
|
+
|
|
637
|
+
Provider identity follows the configured root, not necessarily the walked
|
|
638
|
+
``sessions/`` child. Canonical duplicate provider aliases collapse here;
|
|
639
|
+
overlapping *distinct* configured roots remain ordered so discovery can
|
|
640
|
+
honor the first configured match.
|
|
641
|
+
"""
|
|
642
|
+
roots: list[CodexProviderRoot] = []
|
|
643
|
+
seen: set[pathlib.Path] = set()
|
|
644
|
+
for configured in _cctally()._codex_home_roots():
|
|
645
|
+
provider_root = _canonical_codex_path(configured)
|
|
646
|
+
if provider_root in seen:
|
|
647
|
+
continue
|
|
648
|
+
sessions = configured / "sessions"
|
|
649
|
+
if sessions.is_dir():
|
|
650
|
+
walk_root = sessions
|
|
651
|
+
elif configured.is_dir():
|
|
652
|
+
walk_root = configured
|
|
653
|
+
else:
|
|
654
|
+
continue
|
|
655
|
+
seen.add(provider_root)
|
|
656
|
+
roots.append(CodexProviderRoot(
|
|
657
|
+
provider_root=provider_root,
|
|
658
|
+
walk_root=walk_root,
|
|
659
|
+
source_root_key=source_root_key(str(provider_root)),
|
|
660
|
+
))
|
|
661
|
+
return roots
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _discover_codex_files_with_roots() -> list[CodexDiscoveredFile]:
|
|
665
|
+
"""Discover each physical rollout once with the first matching root facts."""
|
|
666
|
+
discovered: list[CodexDiscoveredFile] = []
|
|
667
|
+
seen: set[pathlib.Path] = set()
|
|
668
|
+
for root in _codex_provider_roots():
|
|
669
|
+
for candidate in root.walk_root.glob("**/*.jsonl"):
|
|
670
|
+
if not candidate.is_file():
|
|
671
|
+
continue
|
|
672
|
+
physical_path = _canonical_codex_path(candidate)
|
|
673
|
+
if physical_path in seen:
|
|
674
|
+
continue
|
|
675
|
+
seen.add(physical_path)
|
|
676
|
+
discovered.append(CodexDiscoveredFile(
|
|
677
|
+
source_path=candidate,
|
|
678
|
+
physical_path=physical_path,
|
|
679
|
+
provider_root=root.provider_root,
|
|
680
|
+
walk_root=root.walk_root,
|
|
681
|
+
source_root_key=root.source_root_key,
|
|
682
|
+
))
|
|
683
|
+
return discovered
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _delete_codex_file_derived_rows(
|
|
687
|
+
conn: sqlite3.Connection,
|
|
688
|
+
path_str: str,
|
|
689
|
+
*,
|
|
690
|
+
source_root_key: str | None = None,
|
|
691
|
+
match_source_root: bool = False,
|
|
692
|
+
) -> None:
|
|
693
|
+
"""Drop Codex rows for one file, optionally qualified to one source root."""
|
|
694
|
+
root_clause = " AND source_root_key IS ?" if match_source_root else ""
|
|
695
|
+
params: tuple[str, ...] | tuple[str, str | None]
|
|
696
|
+
params = (path_str, source_root_key) if match_source_root else (path_str,)
|
|
697
|
+
conn.execute(
|
|
698
|
+
"DELETE FROM codex_session_entries WHERE source_path = ?" + root_clause,
|
|
699
|
+
params,
|
|
700
|
+
)
|
|
701
|
+
conn.execute(
|
|
702
|
+
"DELETE FROM quota_window_snapshots WHERE source = 'codex' "
|
|
703
|
+
"AND source_path = ?" + root_clause,
|
|
704
|
+
params,
|
|
705
|
+
)
|
|
706
|
+
conn.execute(
|
|
707
|
+
"DELETE FROM codex_conversation_events WHERE source_path = ?" + root_clause,
|
|
708
|
+
params,
|
|
709
|
+
)
|
|
710
|
+
conn.execute(
|
|
711
|
+
"DELETE FROM codex_conversation_threads WHERE source_path = ?" + root_clause,
|
|
712
|
+
params,
|
|
713
|
+
)
|
|
714
|
+
conn.execute(
|
|
715
|
+
"DELETE FROM codex_session_files WHERE path = ?" + root_clause,
|
|
716
|
+
params,
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def _clear_codex_derived_rows(conn: sqlite3.Connection) -> None:
|
|
721
|
+
"""Clear every re-derivable Codex row family in child-before-root order."""
|
|
722
|
+
conn.execute("DELETE FROM codex_session_entries")
|
|
723
|
+
conn.execute("DELETE FROM quota_window_snapshots WHERE source = 'codex'")
|
|
724
|
+
conn.execute("DELETE FROM codex_conversation_threads")
|
|
725
|
+
conn.execute("DELETE FROM codex_conversation_events")
|
|
726
|
+
conn.execute("DELETE FROM codex_session_files")
|
|
727
|
+
conn.execute("DELETE FROM codex_source_roots")
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def _bump_codex_physical_mutation_seq(conn: sqlite3.Connection) -> None:
|
|
731
|
+
"""Advance the dashboard's Codex physical-identity sequence in this txn."""
|
|
732
|
+
conn.execute(
|
|
733
|
+
"INSERT INTO cache_meta(key, value) VALUES "
|
|
734
|
+
"('codex_physical_mutation_seq', '1') "
|
|
735
|
+
"ON CONFLICT(key) DO UPDATE SET value=CAST(value AS INTEGER) + 1"
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _collect_inactive_codex_paths_and_roots(
|
|
740
|
+
conn: sqlite3.Connection,
|
|
741
|
+
current_file_identities: set[tuple[str, str]],
|
|
742
|
+
active_root_keys: set[str],
|
|
743
|
+
) -> tuple[list[tuple[str, str | None]], set[str]]:
|
|
744
|
+
"""Return stale real source identities and their candidate root keys.
|
|
745
|
+
|
|
746
|
+
A failed/partial prior write can leave any S1 child family without its
|
|
747
|
+
terminal ``codex_session_files`` row. Scope pruning must therefore use
|
|
748
|
+
every physical family, compare each row's path AND provider root, and leave
|
|
749
|
+
relative fixture rows alone.
|
|
750
|
+
"""
|
|
751
|
+
stale_identities: set[tuple[str, str | None]] = set()
|
|
752
|
+
stale_root_keys: set[str] = set()
|
|
753
|
+
current_paths = {path for path, _root_key in current_file_identities}
|
|
754
|
+
terminal_file_identities = {
|
|
755
|
+
(path, root_key)
|
|
756
|
+
for path, root_key in conn.execute(
|
|
757
|
+
"SELECT path, source_root_key FROM codex_session_files"
|
|
758
|
+
)
|
|
759
|
+
}
|
|
760
|
+
family_queries = (
|
|
761
|
+
"SELECT path, source_root_key FROM codex_session_files",
|
|
762
|
+
"SELECT source_path, source_root_key FROM codex_session_entries",
|
|
763
|
+
"SELECT source_path, source_root_key FROM quota_window_snapshots "
|
|
764
|
+
"WHERE source = 'codex'",
|
|
765
|
+
"SELECT source_path, source_root_key FROM codex_conversation_threads",
|
|
766
|
+
"SELECT source_path, source_root_key FROM codex_conversation_events",
|
|
767
|
+
)
|
|
768
|
+
for query in family_queries:
|
|
769
|
+
for source_path, root_key in conn.execute(query):
|
|
770
|
+
identity = (source_path, root_key)
|
|
771
|
+
if (
|
|
772
|
+
not os.path.isabs(source_path)
|
|
773
|
+
or identity in current_file_identities
|
|
774
|
+
# An old terminal file at a currently discovered path must
|
|
775
|
+
# reach the normal requalification loop, which resets every
|
|
776
|
+
# family as one file transaction and records the reset stat.
|
|
777
|
+
or (
|
|
778
|
+
source_path in current_paths
|
|
779
|
+
and identity in terminal_file_identities
|
|
780
|
+
)
|
|
781
|
+
):
|
|
782
|
+
continue
|
|
783
|
+
stale_identities.add(identity)
|
|
784
|
+
if root_key is not None:
|
|
785
|
+
stale_root_keys.add(root_key)
|
|
786
|
+
stale_root_keys.update(
|
|
787
|
+
root_key
|
|
788
|
+
for (root_key,) in conn.execute(
|
|
789
|
+
"SELECT source_root_key FROM codex_source_roots"
|
|
790
|
+
)
|
|
791
|
+
if root_key not in active_root_keys
|
|
792
|
+
)
|
|
793
|
+
return sorted(stale_identities, key=lambda item: (item[0], item[1] or "")), stale_root_keys
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def _prune_inactive_codex_source_roots(
|
|
797
|
+
conn: sqlite3.Connection,
|
|
798
|
+
active_root_keys: set[str],
|
|
799
|
+
*,
|
|
800
|
+
candidate_root_keys: set[str] | None = None,
|
|
801
|
+
) -> None:
|
|
802
|
+
"""Remove inactive roots only after every child family has been pruned."""
|
|
803
|
+
if candidate_root_keys is not None and not candidate_root_keys:
|
|
804
|
+
return
|
|
805
|
+
predicates: list[str] = []
|
|
806
|
+
params: list[str] = []
|
|
807
|
+
if active_root_keys:
|
|
808
|
+
placeholders = ",".join("?" for _ in active_root_keys)
|
|
809
|
+
predicates.append("roots.source_root_key NOT IN (" + placeholders + ")")
|
|
810
|
+
params.extend(active_root_keys)
|
|
811
|
+
if candidate_root_keys is not None:
|
|
812
|
+
placeholders = ",".join("?" for _ in candidate_root_keys)
|
|
813
|
+
predicates.append("roots.source_root_key IN (" + placeholders + ")")
|
|
814
|
+
params.extend(candidate_root_keys)
|
|
815
|
+
inactive = " AND ".join(predicates) if predicates else "1"
|
|
816
|
+
conn.execute(
|
|
817
|
+
f"""DELETE FROM codex_source_roots AS roots
|
|
818
|
+
WHERE {inactive}
|
|
819
|
+
AND NOT EXISTS (
|
|
820
|
+
SELECT 1 FROM codex_session_files AS files
|
|
821
|
+
WHERE files.source_root_key = roots.source_root_key
|
|
822
|
+
)
|
|
823
|
+
AND NOT EXISTS (
|
|
824
|
+
SELECT 1 FROM codex_session_entries AS entries
|
|
825
|
+
WHERE entries.source_root_key = roots.source_root_key
|
|
826
|
+
)
|
|
827
|
+
AND NOT EXISTS (
|
|
828
|
+
SELECT 1 FROM quota_window_snapshots AS quotas
|
|
829
|
+
WHERE quotas.source = 'codex'
|
|
830
|
+
AND quotas.source_root_key = roots.source_root_key
|
|
831
|
+
)
|
|
832
|
+
AND NOT EXISTS (
|
|
833
|
+
SELECT 1 FROM codex_conversation_threads AS threads
|
|
834
|
+
WHERE threads.source_root_key = roots.source_root_key
|
|
835
|
+
)
|
|
836
|
+
AND NOT EXISTS (
|
|
837
|
+
SELECT 1 FROM codex_conversation_events AS events
|
|
838
|
+
WHERE events.source_root_key = roots.source_root_key
|
|
839
|
+
)""",
|
|
840
|
+
tuple(params),
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def _write_codex_file_batch(
|
|
845
|
+
conn: sqlite3.Connection,
|
|
846
|
+
*,
|
|
847
|
+
discovered: CodexDiscoveredFile,
|
|
848
|
+
path_str: str,
|
|
849
|
+
size: int,
|
|
850
|
+
mtime_ns: int,
|
|
851
|
+
final_offset: int,
|
|
852
|
+
last_session_id: str | None,
|
|
853
|
+
last_model: str | None,
|
|
854
|
+
last_total_tokens: int | None,
|
|
855
|
+
last_native_thread_id: str | None,
|
|
856
|
+
last_root_thread_id: str | None,
|
|
857
|
+
last_parent_thread_id: str | None,
|
|
858
|
+
last_conversation_key: str | None,
|
|
859
|
+
reset_file: bool,
|
|
860
|
+
accounting_rows: list[tuple[Any, ...]],
|
|
861
|
+
quota_rows: list[tuple[Any, ...]],
|
|
862
|
+
thread_rows: list[tuple[Any, ...]],
|
|
863
|
+
event_rows: list[tuple[Any, ...]],
|
|
864
|
+
active_root_keys: set[str],
|
|
865
|
+
) -> int:
|
|
866
|
+
"""Write one fully-buffered Codex file atomically and return entry changes."""
|
|
867
|
+
now_iso = dt.datetime.now(dt.timezone.utc).isoformat()
|
|
868
|
+
if reset_file:
|
|
869
|
+
_delete_codex_file_derived_rows(conn, path_str)
|
|
870
|
+
conn.execute(
|
|
871
|
+
"""INSERT INTO codex_source_roots
|
|
872
|
+
(source_root_key, canonical_root_path, first_seen_utc, last_seen_utc)
|
|
873
|
+
VALUES (?,?,?,?)
|
|
874
|
+
ON CONFLICT(source_root_key) DO UPDATE SET
|
|
875
|
+
canonical_root_path=excluded.canonical_root_path,
|
|
876
|
+
last_seen_utc=excluded.last_seen_utc""",
|
|
877
|
+
(discovered.source_root_key, str(discovered.provider_root), now_iso, now_iso),
|
|
878
|
+
)
|
|
879
|
+
rows_changed = 0
|
|
880
|
+
if accounting_rows:
|
|
881
|
+
before = conn.total_changes
|
|
882
|
+
conn.executemany(
|
|
883
|
+
"""INSERT OR IGNORE INTO codex_session_entries
|
|
884
|
+
(source_path, line_offset, timestamp_utc, session_id, model,
|
|
885
|
+
input_tokens, cached_input_tokens, output_tokens,
|
|
886
|
+
reasoning_output_tokens, total_tokens, source_root_key,
|
|
887
|
+
conversation_key)
|
|
888
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
889
|
+
accounting_rows,
|
|
890
|
+
)
|
|
891
|
+
rows_changed = conn.total_changes - before
|
|
892
|
+
if quota_rows:
|
|
893
|
+
conn.executemany(
|
|
894
|
+
"""INSERT OR IGNORE INTO quota_window_snapshots
|
|
895
|
+
(source, source_root_key, source_path, line_offset,
|
|
896
|
+
captured_at_utc, observed_slot, logical_limit_key, limit_id,
|
|
897
|
+
limit_name, window_minutes, used_percent, resets_at_utc,
|
|
898
|
+
plan_type, individual_limit_json, reached_type)
|
|
899
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
900
|
+
quota_rows,
|
|
901
|
+
)
|
|
902
|
+
if thread_rows:
|
|
903
|
+
conn.executemany(
|
|
904
|
+
"""INSERT INTO codex_conversation_threads
|
|
905
|
+
(conversation_key, source_root_key, native_thread_id,
|
|
906
|
+
root_thread_id, parent_thread_id, source_path, cwd, git_json,
|
|
907
|
+
source_kind, thread_source_json, model_provider, context_window,
|
|
908
|
+
first_seen_utc, last_seen_utc)
|
|
909
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
910
|
+
ON CONFLICT(conversation_key) DO UPDATE SET
|
|
911
|
+
source_root_key=excluded.source_root_key,
|
|
912
|
+
native_thread_id=excluded.native_thread_id,
|
|
913
|
+
root_thread_id=excluded.root_thread_id,
|
|
914
|
+
parent_thread_id=excluded.parent_thread_id,
|
|
915
|
+
source_path=excluded.source_path, cwd=excluded.cwd,
|
|
916
|
+
git_json=excluded.git_json, source_kind=excluded.source_kind,
|
|
917
|
+
thread_source_json=excluded.thread_source_json,
|
|
918
|
+
model_provider=excluded.model_provider,
|
|
919
|
+
context_window=excluded.context_window,
|
|
920
|
+
last_seen_utc=excluded.last_seen_utc""",
|
|
921
|
+
[(*row, now_iso, now_iso) for row in thread_rows],
|
|
922
|
+
)
|
|
923
|
+
if event_rows:
|
|
924
|
+
conn.executemany(
|
|
925
|
+
"""INSERT OR IGNORE INTO codex_conversation_events
|
|
926
|
+
(source_path, line_offset, source_root_key, conversation_key,
|
|
927
|
+
native_thread_id, root_thread_id, parent_thread_id,
|
|
928
|
+
timestamp_utc, record_type, event_type, turn_id, call_id,
|
|
929
|
+
payload_json)
|
|
930
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
931
|
+
event_rows,
|
|
932
|
+
)
|
|
933
|
+
conn.execute(
|
|
934
|
+
"""INSERT OR REPLACE INTO codex_session_files
|
|
935
|
+
(path, size_bytes, mtime_ns, last_byte_offset, last_ingested_at,
|
|
936
|
+
last_session_id, last_model, last_total_tokens, source_root_key,
|
|
937
|
+
last_native_thread_id, last_root_thread_id, last_parent_thread_id,
|
|
938
|
+
last_conversation_key)
|
|
939
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
940
|
+
(
|
|
941
|
+
path_str, size, mtime_ns, final_offset, now_iso, last_session_id,
|
|
942
|
+
last_model, last_total_tokens, discovered.source_root_key,
|
|
943
|
+
last_native_thread_id, last_root_thread_id, last_parent_thread_id,
|
|
944
|
+
last_conversation_key,
|
|
945
|
+
),
|
|
946
|
+
)
|
|
947
|
+
_prune_inactive_codex_source_roots(conn, active_root_keys)
|
|
948
|
+
# A file batch owns accounting, quota, thread, event, root, and cursor
|
|
949
|
+
# facts as one physical unit. Keep the version bump in that same commit so
|
|
950
|
+
# a rolled-back batch never appears newer to the dashboard signature.
|
|
951
|
+
_bump_codex_physical_mutation_seq(conn)
|
|
952
|
+
conn.commit()
|
|
953
|
+
return rows_changed
|
|
954
|
+
|
|
955
|
+
|
|
502
956
|
def _iter_codex_jsonl_paths(roots: list[pathlib.Path]) -> Iterator[pathlib.Path]:
|
|
503
957
|
"""Yield each existing *.jsonl under the given roots, de-duped by RESOLVED
|
|
504
958
|
path (first occurrence wins — collapses overlapping/prefix roots and
|
|
@@ -1832,6 +2286,12 @@ def sync_cache(
|
|
|
1832
2286
|
# rows the rail's old live aggregate read, so the rollup stays
|
|
1833
2287
|
# byte-identical to that aggregate.
|
|
1834
2288
|
with _perf.phase("recompute.conversation_sessions"):
|
|
2289
|
+
# #302: auto-invalidate the rollup's MATERIALIZED cost when the
|
|
2290
|
+
# embedded pricing snapshot changed since it was last derived. Runs
|
|
2291
|
+
# BEFORE the pending check so a mismatch arms the same durable flag
|
|
2292
|
+
# the full-recompute path already consumes below (self-heal on a
|
|
2293
|
+
# pricing sync / cctally upgrade, no manual `cache-sync --rebuild`).
|
|
2294
|
+
_arm_rollup_backfill_on_pricing_change(conn)
|
|
1835
2295
|
if _conversation_sessions_backfill_pending(conn):
|
|
1836
2296
|
_recompute_conversation_sessions(conn)
|
|
1837
2297
|
conn.execute(
|
|
@@ -1875,6 +2335,11 @@ def sync_cache(
|
|
|
1875
2335
|
# write has materialized the -wal/-shm sidecars. open_cache_db hardens
|
|
1876
2336
|
# cache.db + the data dir; this finishes the job for the sidecars.
|
|
1877
2337
|
_harden_cache_sidecars()
|
|
2338
|
+
# #297: forced end-of-sync WAL drain. Threshold-gated + short-timeout +
|
|
2339
|
+
# best-effort. Runs here — all ingest work is committed (no active txn)
|
|
2340
|
+
# and the flock is still held, so the short busy_timeout keeps it from
|
|
2341
|
+
# stalling the lock under heavy-reader contention.
|
|
2342
|
+
_maybe_truncate_wal(conn, _cctally_core.CACHE_DB_PATH)
|
|
1878
2343
|
return stats
|
|
1879
2344
|
finally:
|
|
1880
2345
|
try:
|
|
@@ -2120,6 +2585,37 @@ def _conversation_sessions_backfill_pending(conn) -> bool:
|
|
|
2120
2585
|
return False
|
|
2121
2586
|
|
|
2122
2587
|
|
|
2588
|
+
def _arm_rollup_backfill_on_pricing_change(conn) -> None:
|
|
2589
|
+
"""Arm the conversation_sessions full backfill when the embedded pricing
|
|
2590
|
+
snapshot changed since the rollup's stored cost was last derived (#302). The
|
|
2591
|
+
rail now reads MATERIALIZED cost off the rollup, so a pricing sync / cctally
|
|
2592
|
+
upgrade would otherwise leave untouched sessions' cost (and the cost
|
|
2593
|
+
filter/sort axis) stale until a manual `cache-sync --rebuild`. This self-heals
|
|
2594
|
+
it: compares a stored cache_meta fingerprint against the current
|
|
2595
|
+
PRICING_SNAPSHOT_DATE and, on mismatch, arms
|
|
2596
|
+
conversation_sessions_backfill_pending + advances the stored fingerprint (one
|
|
2597
|
+
committed txn). The existing full-recompute-then-drop-flag-last machinery then
|
|
2598
|
+
re-derives every session's cost + enrichment.
|
|
2599
|
+
|
|
2600
|
+
Crash-safety is unchanged: the DURABLE backfill flag remains the recompute
|
|
2601
|
+
signal, so advancing the fingerprint here cannot strand stale cost (a crash
|
|
2602
|
+
after arming leaves the flag set -> next sync recomputes regardless of the
|
|
2603
|
+
fingerprint). No-op when cache_meta is unavailable (path-less / degraded
|
|
2604
|
+
conn). Caller path holds the cache.db.lock flock."""
|
|
2605
|
+
try:
|
|
2606
|
+
row = conn.execute(
|
|
2607
|
+
"SELECT value FROM cache_meta "
|
|
2608
|
+
"WHERE key='conversation_sessions_pricing_fp'"
|
|
2609
|
+
).fetchone()
|
|
2610
|
+
except sqlite3.OperationalError:
|
|
2611
|
+
return
|
|
2612
|
+
if row is not None and row[0] == PRICING_SNAPSHOT_DATE:
|
|
2613
|
+
return
|
|
2614
|
+
_set_cache_meta(conn, "conversation_sessions_backfill_pending", "1")
|
|
2615
|
+
_set_cache_meta(conn, "conversation_sessions_pricing_fp", PRICING_SNAPSHOT_DATE)
|
|
2616
|
+
conn.commit()
|
|
2617
|
+
|
|
2618
|
+
|
|
2123
2619
|
def _recompute_conversation_sessions(conn, session_ids=None) -> None:
|
|
2124
2620
|
"""Recompute the ``conversation_sessions`` browse-rail rollup from
|
|
2125
2621
|
``conversation_messages``. The caller holds the cache.db.lock flock and owns
|
|
@@ -2170,26 +2666,34 @@ def _recompute_conversation_sessions(conn, session_ids=None) -> None:
|
|
|
2170
2666
|
|
|
2171
2667
|
def _fill_conversation_sessions_filter_columns(conn, session_ids):
|
|
2172
2668
|
"""Fill the rollup's browse-FILTER columns (project_label / cost_usd /
|
|
2173
|
-
cache_rebuild_count, migration 015)
|
|
2669
|
+
cache_rebuild_count, migration 015) AND the #302 DISPLAYED-enrichment columns
|
|
2670
|
+
(git_branch / models_json / title) for the given sessions, or ALL when
|
|
2174
2671
|
``session_ids is None``. The structural COUNT/MIN/MAX columns are filled by
|
|
2175
2672
|
the INSERT in _recompute_conversation_sessions; this is the second pass that
|
|
2176
|
-
materializes the
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2673
|
+
materializes the filter axes (pure-SQL predicates) AND the displayed
|
|
2674
|
+
enrichment the rail reads straight off the rollup instead of re-scanning
|
|
2675
|
+
conversation_messages per session on every cold page.
|
|
2676
|
+
|
|
2677
|
+
Every value reuses the query kernel's batch maps — the SAME
|
|
2678
|
+
_project_label / _session_cost_map / _session_latest_meta_map /
|
|
2679
|
+
_session_models_map / _session_first_prompt_titles_map the rail's live path
|
|
2680
|
+
uses — so a materialized value equals what the live rail produces for that
|
|
2681
|
+
session (byte-identity by construction; #302 Section 1). cost is rounded to
|
|
2682
|
+
6dp to match list_conversations' per-row rounding. cache_rebuild_count is a
|
|
2683
|
+
per-session lightweight rebuild-count via the query kernel's
|
|
2684
|
+
single-source-of-truth helper (no full assembly — U1). git_branch is the
|
|
2685
|
+
latest non-null branch (already computed in ``meta`` for project_label, so
|
|
2686
|
+
zero extra query). models_json stores the ordered raw model-ID list
|
|
2687
|
+
(_models_main_first order) as ``json.dumps(models) if models else None`` ->
|
|
2688
|
+
NULL when the session used no non-null model (read back ``[]`` on NULL). title
|
|
2689
|
+
stores ONLY the stable first-prompt title; the volatile AI title is overlaid
|
|
2690
|
+
live by list_conversations (#302 Q2-B), so it is NOT stored here.
|
|
2691
|
+
|
|
2692
|
+
No-op when any of the columns is absent (a pre-015 / pre-023 cache.db being
|
|
2693
|
+
re-derived before _apply_cache_schema adds them), so an early/partial sync
|
|
2694
|
+
never raises ``no such column``. The CALLER owns the commit (never commits)."""
|
|
2191
2695
|
cols = {r[1] for r in conn.execute("PRAGMA table_info(conversation_sessions)")}
|
|
2192
|
-
if "cache_rebuild_count"
|
|
2696
|
+
if not {"cache_rebuild_count", "git_branch", "models_json", "title"} <= cols:
|
|
2193
2697
|
return
|
|
2194
2698
|
lq = _load_lib("_lib_conversation_query")
|
|
2195
2699
|
if session_ids is None:
|
|
@@ -2201,13 +2705,21 @@ def _fill_conversation_sessions_filter_columns(conn, session_ids):
|
|
|
2201
2705
|
return
|
|
2202
2706
|
cost = lq._session_cost_map(conn, ids)
|
|
2203
2707
|
meta = lq._session_latest_meta_map(conn, ids)
|
|
2708
|
+
models = lq._session_models_map(conn, ids)
|
|
2709
|
+
first_titles = lq._session_first_prompt_titles_map(conn, ids)
|
|
2204
2710
|
for sid in ids:
|
|
2205
2711
|
proj = lq._project_label(meta.get(sid, (None, None))[0])
|
|
2712
|
+
branch = meta.get(sid, (None, None))[1]
|
|
2206
2713
|
rebuilds = lq.session_cache_rebuild_count(conn, sid)
|
|
2714
|
+
m = models.get(sid) or []
|
|
2715
|
+
models_json = json.dumps(m) if m else None
|
|
2716
|
+
title = first_titles.get(sid)
|
|
2207
2717
|
conn.execute(
|
|
2208
2718
|
"UPDATE conversation_sessions SET project_label=?, cost_usd=?, "
|
|
2209
|
-
"cache_rebuild_count
|
|
2210
|
-
|
|
2719
|
+
"cache_rebuild_count=?, git_branch=?, models_json=?, title=? "
|
|
2720
|
+
"WHERE session_id=?",
|
|
2721
|
+
(proj, round(cost.get(sid, 0.0), 6), rebuilds, branch, models_json,
|
|
2722
|
+
title, sid),
|
|
2211
2723
|
)
|
|
2212
2724
|
|
|
2213
2725
|
|
|
@@ -2964,6 +3476,7 @@ def sync_codex_cache(
|
|
|
2964
3476
|
progress: Callable[[CodexIngestStats], None] | None = None,
|
|
2965
3477
|
rebuild: bool = False,
|
|
2966
3478
|
lock_timeout: "float | None" = None,
|
|
3479
|
+
_on_first_file_rollback: Callable[[], None] | None = None,
|
|
2967
3480
|
) -> CodexIngestStats:
|
|
2968
3481
|
"""Read-through delta ingest of ~/.codex/sessions/**/*.jsonl.
|
|
2969
3482
|
|
|
@@ -2977,6 +3490,7 @@ def sync_codex_cache(
|
|
|
2977
3490
|
untouched and the caller sees `lock_contended=True`.
|
|
2978
3491
|
"""
|
|
2979
3492
|
stats = CodexIngestStats()
|
|
3493
|
+
project_after_unlock = False
|
|
2980
3494
|
c = _cctally()
|
|
2981
3495
|
_cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
|
|
2982
3496
|
_cctally_core.CACHE_LOCK_CODEX_PATH.touch()
|
|
@@ -2995,18 +3509,19 @@ def sync_codex_cache(
|
|
|
2995
3509
|
# Clear INSIDE the lock — see sync_cache() for the full
|
|
2996
3510
|
# rationale. Done before the existing SELECT so delta
|
|
2997
3511
|
# detection sees an empty baseline.
|
|
2998
|
-
conn.
|
|
2999
|
-
conn
|
|
3512
|
+
before_clear = conn.total_changes
|
|
3513
|
+
_clear_codex_derived_rows(conn)
|
|
3514
|
+
if conn.total_changes != before_clear:
|
|
3515
|
+
_bump_codex_physical_mutation_seq(conn)
|
|
3000
3516
|
conn.commit()
|
|
3001
3517
|
eprint("[cache-sync] rebuild: cleared Codex cached entries")
|
|
3002
3518
|
|
|
3003
|
-
roots = _cctally()._codex_session_roots()
|
|
3004
3519
|
# Pure read (glob + is_file only); safe to run before the SELECT and
|
|
3005
3520
|
# the per-file loop, where no cache.db write lock may be held.
|
|
3006
3521
|
with _perf.phase("discover") as _p_disc:
|
|
3007
|
-
|
|
3008
|
-
stats.files_total = len(
|
|
3009
|
-
_p_disc.set_count(len(
|
|
3522
|
+
files = _discover_codex_files_with_roots()
|
|
3523
|
+
stats.files_total = len(files)
|
|
3524
|
+
_p_disc.set_count(len(files))
|
|
3010
3525
|
|
|
3011
3526
|
# Scope the cache to the CURRENT root set: drop rows ingested under a
|
|
3012
3527
|
# prior $CODEX_HOME (issue #108). iter_codex_entries() has NO root
|
|
@@ -3022,7 +3537,9 @@ def sync_codex_cache(
|
|
|
3022
3537
|
# processes with different $CODEX_HOME would prune each other; the
|
|
3023
3538
|
# flock serializes them and that is a pathological configuration.
|
|
3024
3539
|
if not rebuild: # --rebuild already cleared both tables above
|
|
3025
|
-
|
|
3540
|
+
current_file_identities = {
|
|
3541
|
+
(str(item.source_path), item.source_root_key) for item in files
|
|
3542
|
+
}
|
|
3026
3543
|
# Only prune ABSOLUTE source_paths. _codex_home_roots() makes
|
|
3027
3544
|
# every real root absolute (via .absolute()), so a real ingested
|
|
3028
3545
|
# row always stores an absolute str(jp) — INCLUDING a relative
|
|
@@ -3031,22 +3548,27 @@ def sync_codex_cache(
|
|
|
3031
3548
|
# synthetic baked-cache fixture row (e.g. build-speed-fixtures.py)
|
|
3032
3549
|
# with no on-disk JSONL to scope against; pruning it would wipe a
|
|
3033
3550
|
# cache meant to be read as-is (issue #108).
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3551
|
+
active_root_keys = {item.source_root_key for item in files}
|
|
3552
|
+
orphan_sources, orphan_root_keys = _collect_inactive_codex_paths_and_roots(
|
|
3553
|
+
conn, current_file_identities, active_root_keys,
|
|
3554
|
+
)
|
|
3555
|
+
if orphan_sources or orphan_root_keys:
|
|
3556
|
+
before_prune = conn.total_changes
|
|
3557
|
+
for orphan_path, orphan_root_key in orphan_sources:
|
|
3558
|
+
_delete_codex_file_derived_rows(
|
|
3559
|
+
conn,
|
|
3560
|
+
orphan_path,
|
|
3561
|
+
source_root_key=orphan_root_key,
|
|
3562
|
+
match_source_root=True,
|
|
3563
|
+
)
|
|
3564
|
+
_prune_inactive_codex_source_roots(
|
|
3565
|
+
conn, active_root_keys,
|
|
3566
|
+
candidate_root_keys=orphan_root_keys,
|
|
3047
3567
|
)
|
|
3568
|
+
if conn.total_changes != before_prune:
|
|
3569
|
+
_bump_codex_physical_mutation_seq(conn)
|
|
3048
3570
|
conn.commit()
|
|
3049
|
-
stats.files_pruned = len(
|
|
3571
|
+
stats.files_pruned = len({path for path, _root in orphan_sources})
|
|
3050
3572
|
|
|
3051
3573
|
# This SELECT does NOT open an implicit transaction (Python's
|
|
3052
3574
|
# sqlite3 module only BEGINs on DML). Do NOT add any INSERT/
|
|
@@ -3059,10 +3581,15 @@ def sync_codex_cache(
|
|
|
3059
3581
|
# append-only, so a size change is a sufficient signal and mtime
|
|
3060
3582
|
# is prone to clock-skew false-positives).
|
|
3061
3583
|
existing = {
|
|
3062
|
-
row[0]: (
|
|
3584
|
+
row[0]: (
|
|
3585
|
+
row[1], row[2], row[3], row[4], row[5], row[6], row[7],
|
|
3586
|
+
row[8], row[9], row[10], row[11],
|
|
3587
|
+
)
|
|
3063
3588
|
for row in conn.execute(
|
|
3064
3589
|
"SELECT path, size_bytes, mtime_ns, last_byte_offset, "
|
|
3065
|
-
"last_session_id, last_model, last_total_tokens "
|
|
3590
|
+
"last_session_id, last_model, last_total_tokens, source_root_key, "
|
|
3591
|
+
"last_native_thread_id, last_root_thread_id, last_parent_thread_id, "
|
|
3592
|
+
"last_conversation_key "
|
|
3066
3593
|
"FROM codex_session_files"
|
|
3067
3594
|
)
|
|
3068
3595
|
}
|
|
@@ -3072,7 +3599,8 @@ def sync_codex_cache(
|
|
|
3072
3599
|
# the loop stays flat, mirroring sync_cache's walk seam.
|
|
3073
3600
|
_p_walk = _perf.phase("walk")
|
|
3074
3601
|
_p_walk.__enter__()
|
|
3075
|
-
for
|
|
3602
|
+
for discovered in files:
|
|
3603
|
+
jp = discovered.source_path
|
|
3076
3604
|
path_str = str(jp)
|
|
3077
3605
|
try:
|
|
3078
3606
|
st = jp.stat()
|
|
@@ -3089,17 +3617,25 @@ def sync_codex_cache(
|
|
|
3089
3617
|
initial_model: str | None = None
|
|
3090
3618
|
initial_total_tokens = 0
|
|
3091
3619
|
prev_total_tokens: int | None = None
|
|
3620
|
+
prev_native_thread_id: str | None = None
|
|
3621
|
+
prev_root_thread_id: str | None = None
|
|
3622
|
+
prev_parent_thread_id: str | None = None
|
|
3623
|
+
prev_conversation_key: str | None = None
|
|
3624
|
+
requalified = False
|
|
3092
3625
|
if prev is not None:
|
|
3093
3626
|
(
|
|
3094
3627
|
prev_size, _, prev_offset, prev_sid, prev_model, prev_ttot,
|
|
3628
|
+
prev_root_key, prev_native_thread_id, prev_root_thread_id,
|
|
3629
|
+
prev_parent_thread_id, prev_conversation_key,
|
|
3095
3630
|
) = prev
|
|
3096
3631
|
prev_total_tokens = (
|
|
3097
3632
|
int(prev_ttot) if prev_ttot is not None else None
|
|
3098
3633
|
)
|
|
3099
|
-
|
|
3634
|
+
requalified = prev_root_key != discovered.source_root_key
|
|
3635
|
+
if not requalified and size == prev_size:
|
|
3100
3636
|
stats.files_skipped_unchanged += 1
|
|
3101
3637
|
continue
|
|
3102
|
-
if size > prev_size:
|
|
3638
|
+
if not requalified and size > prev_size:
|
|
3103
3639
|
start_offset = prev_offset
|
|
3104
3640
|
initial_session_id = prev_sid
|
|
3105
3641
|
initial_model = prev_model
|
|
@@ -3112,7 +3648,10 @@ def sync_codex_cache(
|
|
|
3112
3648
|
initial_total_tokens = 0
|
|
3113
3649
|
prev_total_tokens = None
|
|
3114
3650
|
|
|
3115
|
-
|
|
3651
|
+
accounting_rows: list[tuple[Any, ...]] = []
|
|
3652
|
+
quota_rows: list[tuple[Any, ...]] = []
|
|
3653
|
+
thread_rows: list[tuple[Any, ...]] = []
|
|
3654
|
+
event_rows: list[tuple[Any, ...]] = []
|
|
3116
3655
|
final_offset = start_offset
|
|
3117
3656
|
# Mutable tracker that the iterator updates on every
|
|
3118
3657
|
# session_meta / turn_context record, regardless of whether a
|
|
@@ -3133,21 +3672,76 @@ def sync_codex_cache(
|
|
|
3133
3672
|
model=initial_model,
|
|
3134
3673
|
total_tokens=initial_total_tokens,
|
|
3135
3674
|
)
|
|
3675
|
+
if (
|
|
3676
|
+
prev is not None and not truncated and not requalified
|
|
3677
|
+
and prev_native_thread_id is not None
|
|
3678
|
+
and prev_root_thread_id is not None
|
|
3679
|
+
):
|
|
3680
|
+
iter_state.thread = _lib_jsonl.CodexThreadMetadata(
|
|
3681
|
+
source_root_key=discovered.source_root_key,
|
|
3682
|
+
source_path=path_str,
|
|
3683
|
+
native_thread_id=prev_native_thread_id,
|
|
3684
|
+
root_thread_id=prev_root_thread_id,
|
|
3685
|
+
parent_thread_id=prev_parent_thread_id,
|
|
3686
|
+
conversation_key=prev_conversation_key,
|
|
3687
|
+
cwd=None,
|
|
3688
|
+
git_json=None,
|
|
3689
|
+
source_kind=None,
|
|
3690
|
+
thread_source_json=None,
|
|
3691
|
+
model_provider=None,
|
|
3692
|
+
context_window=None,
|
|
3693
|
+
)
|
|
3136
3694
|
yielded_count = 0
|
|
3137
3695
|
try:
|
|
3138
|
-
with open(jp, "
|
|
3696
|
+
with open(jp, "rb") as fh:
|
|
3139
3697
|
fh.seek(start_offset)
|
|
3140
|
-
for
|
|
3698
|
+
for emission in _iter_codex_fused_records_with_offsets(
|
|
3141
3699
|
fh,
|
|
3142
3700
|
path_str,
|
|
3143
3701
|
initial_session_id=initial_session_id,
|
|
3144
3702
|
initial_model=initial_model,
|
|
3145
3703
|
initial_total_tokens=initial_total_tokens,
|
|
3704
|
+
source_root_key=discovered.source_root_key,
|
|
3146
3705
|
state=iter_state,
|
|
3147
3706
|
):
|
|
3148
|
-
|
|
3707
|
+
event = emission.event
|
|
3708
|
+
event_rows.append((
|
|
3709
|
+
event.source_path, event.line_offset,
|
|
3710
|
+
event.source_root_key, event.conversation_key,
|
|
3711
|
+
event.native_thread_id, event.root_thread_id,
|
|
3712
|
+
event.parent_thread_id, event.timestamp_utc,
|
|
3713
|
+
event.record_type, event.event_type, event.turn_id,
|
|
3714
|
+
event.call_id, event.payload_json,
|
|
3715
|
+
))
|
|
3716
|
+
for quota in emission.quotas:
|
|
3717
|
+
quota_rows.append((
|
|
3718
|
+
quota.source, quota.source_root_key,
|
|
3719
|
+
quota.source_path, quota.line_offset,
|
|
3720
|
+
quota.captured_at_utc, quota.observed_slot,
|
|
3721
|
+
quota.logical_limit_key, quota.limit_id,
|
|
3722
|
+
quota.limit_name, quota.window_minutes,
|
|
3723
|
+
quota.used_percent, quota.resets_at_utc,
|
|
3724
|
+
quota.plan_type, quota.individual_limit_json,
|
|
3725
|
+
quota.reached_type,
|
|
3726
|
+
))
|
|
3727
|
+
if (thread := emission.thread) is not None and (
|
|
3728
|
+
thread.conversation_key is not None
|
|
3729
|
+
and thread.native_thread_id is not None
|
|
3730
|
+
and thread.root_thread_id is not None
|
|
3731
|
+
):
|
|
3732
|
+
thread_rows.append((
|
|
3733
|
+
thread.conversation_key, thread.source_root_key,
|
|
3734
|
+
thread.native_thread_id, thread.root_thread_id,
|
|
3735
|
+
thread.parent_thread_id, thread.source_path,
|
|
3736
|
+
thread.cwd, thread.git_json, thread.source_kind,
|
|
3737
|
+
thread.thread_source_json, thread.model_provider,
|
|
3738
|
+
thread.context_window,
|
|
3739
|
+
))
|
|
3740
|
+
if (entry := emission.accounting) is None:
|
|
3741
|
+
continue
|
|
3742
|
+
accounting_rows.append((
|
|
3149
3743
|
path_str,
|
|
3150
|
-
|
|
3744
|
+
emission.line_offset,
|
|
3151
3745
|
entry.timestamp.astimezone(dt.timezone.utc).isoformat(),
|
|
3152
3746
|
entry.session_id,
|
|
3153
3747
|
entry.model,
|
|
@@ -3156,6 +3750,8 @@ def sync_codex_cache(
|
|
|
3156
3750
|
entry.output_tokens,
|
|
3157
3751
|
entry.reasoning_output_tokens,
|
|
3158
3752
|
entry.total_tokens,
|
|
3753
|
+
discovered.source_root_key,
|
|
3754
|
+
event.conversation_key,
|
|
3159
3755
|
))
|
|
3160
3756
|
yielded_count += 1
|
|
3161
3757
|
final_offset = fh.tell()
|
|
@@ -3197,49 +3793,69 @@ def sync_codex_cache(
|
|
|
3197
3793
|
new_last_total_tokens: int | None = (
|
|
3198
3794
|
iter_state.total_tokens if yielded_count > 0 else prev_total_tokens
|
|
3199
3795
|
)
|
|
3796
|
+
terminal_thread = iter_state.thread
|
|
3797
|
+
new_last_native_thread_id = (
|
|
3798
|
+
terminal_thread.native_thread_id
|
|
3799
|
+
if terminal_thread is not None else prev_native_thread_id
|
|
3800
|
+
)
|
|
3801
|
+
new_last_root_thread_id = (
|
|
3802
|
+
terminal_thread.root_thread_id
|
|
3803
|
+
if terminal_thread is not None else prev_root_thread_id
|
|
3804
|
+
)
|
|
3805
|
+
new_last_parent_thread_id = (
|
|
3806
|
+
terminal_thread.parent_thread_id
|
|
3807
|
+
if terminal_thread is not None else prev_parent_thread_id
|
|
3808
|
+
)
|
|
3809
|
+
new_last_conversation_key = (
|
|
3810
|
+
terminal_thread.conversation_key
|
|
3811
|
+
if terminal_thread is not None else prev_conversation_key
|
|
3812
|
+
)
|
|
3200
3813
|
|
|
3201
|
-
#
|
|
3202
|
-
#
|
|
3203
|
-
#
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3814
|
+
# Every derived row above was buffered before the first DML. A
|
|
3815
|
+
# late database failure therefore rolls the whole file back and
|
|
3816
|
+
# retries that same in-memory batch exactly once.
|
|
3817
|
+
committed = False
|
|
3818
|
+
for attempt in range(2):
|
|
3819
|
+
try:
|
|
3820
|
+
file_rows_changed = _write_codex_file_batch(
|
|
3821
|
+
conn,
|
|
3822
|
+
discovered=discovered,
|
|
3823
|
+
path_str=path_str,
|
|
3824
|
+
size=size,
|
|
3825
|
+
mtime_ns=mtime_ns,
|
|
3826
|
+
final_offset=final_offset,
|
|
3827
|
+
last_session_id=new_last_session_id,
|
|
3828
|
+
last_model=new_last_model,
|
|
3829
|
+
last_total_tokens=new_last_total_tokens,
|
|
3830
|
+
last_native_thread_id=new_last_native_thread_id,
|
|
3831
|
+
last_root_thread_id=new_last_root_thread_id,
|
|
3832
|
+
last_parent_thread_id=new_last_parent_thread_id,
|
|
3833
|
+
last_conversation_key=new_last_conversation_key,
|
|
3834
|
+
reset_file=truncated or requalified,
|
|
3835
|
+
accounting_rows=accounting_rows,
|
|
3836
|
+
quota_rows=quota_rows,
|
|
3837
|
+
thread_rows=thread_rows,
|
|
3838
|
+
event_rows=event_rows,
|
|
3839
|
+
active_root_keys={item.source_root_key for item in files},
|
|
3211
3840
|
)
|
|
3841
|
+
except sqlite3.DatabaseError as exc:
|
|
3842
|
+
conn.rollback()
|
|
3843
|
+
if attempt == 0:
|
|
3844
|
+
# Private test seam: the callback runs after the
|
|
3845
|
+
# failed file transaction has rolled back, and before
|
|
3846
|
+
# the sole in-memory-batch retry starts.
|
|
3847
|
+
if _on_first_file_rollback is not None:
|
|
3848
|
+
_on_first_file_rollback()
|
|
3849
|
+
continue
|
|
3850
|
+
eprint(f"[codex-cache] db error on {jp}: {exc}")
|
|
3851
|
+
break
|
|
3852
|
+
stats.rows_changed += file_rows_changed
|
|
3853
|
+
if truncated or requalified:
|
|
3212
3854
|
stats.files_reset_truncated += 1
|
|
3213
|
-
if rows:
|
|
3214
|
-
before = conn.total_changes
|
|
3215
|
-
conn.executemany(
|
|
3216
|
-
"""INSERT OR IGNORE INTO codex_session_entries
|
|
3217
|
-
(source_path, line_offset, timestamp_utc, session_id,
|
|
3218
|
-
model, input_tokens, cached_input_tokens,
|
|
3219
|
-
output_tokens, reasoning_output_tokens,
|
|
3220
|
-
total_tokens)
|
|
3221
|
-
VALUES (?,?,?,?,?,?,?,?,?,?)""",
|
|
3222
|
-
rows,
|
|
3223
|
-
)
|
|
3224
|
-
stats.rows_changed += conn.total_changes - before
|
|
3225
|
-
conn.execute(
|
|
3226
|
-
"""INSERT OR REPLACE INTO codex_session_files
|
|
3227
|
-
(path, size_bytes, mtime_ns, last_byte_offset,
|
|
3228
|
-
last_ingested_at, last_session_id, last_model,
|
|
3229
|
-
last_total_tokens)
|
|
3230
|
-
VALUES (?,?,?,?,?,?,?,?)""",
|
|
3231
|
-
(
|
|
3232
|
-
path_str, size, mtime_ns, final_offset,
|
|
3233
|
-
dt.datetime.now(dt.timezone.utc).isoformat(),
|
|
3234
|
-
new_last_session_id, new_last_model,
|
|
3235
|
-
new_last_total_tokens,
|
|
3236
|
-
),
|
|
3237
|
-
)
|
|
3238
|
-
conn.commit()
|
|
3239
3855
|
stats.files_processed += 1
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3856
|
+
committed = True
|
|
3857
|
+
break
|
|
3858
|
+
if not committed:
|
|
3243
3859
|
continue
|
|
3244
3860
|
|
|
3245
3861
|
if progress is not None:
|
|
@@ -3262,7 +3878,21 @@ def sync_codex_cache(
|
|
|
3262
3878
|
skip_reasons=stats.skip_reasons,
|
|
3263
3879
|
rebuild=rebuild,
|
|
3264
3880
|
)
|
|
3265
|
-
|
|
3881
|
+
# Codex creates/extends cache.db sidecars independently of Claude's
|
|
3882
|
+
# sync path. Harden them while the Codex flock is still held and after
|
|
3883
|
+
# all Codex writes, before the optional checkpoint can rotate a WAL.
|
|
3884
|
+
_harden_cache_sidecars()
|
|
3885
|
+
# #297: forced end-of-sync WAL drain (Codex half). Claude and Codex
|
|
3886
|
+
# ingests use SEPARATE flocks but commit into the SAME cache.db WAL, so
|
|
3887
|
+
# the fail-fast short timeout naturally dedupes concurrent attempts —
|
|
3888
|
+
# whoever wins truncates; the other gets `busy` immediately and moves
|
|
3889
|
+
# on. All Codex ingest work is committed here (no active txn).
|
|
3890
|
+
_maybe_truncate_wal(conn, _cctally_core.CACHE_DB_PATH)
|
|
3891
|
+
# Projection intentionally runs only after this function releases the
|
|
3892
|
+
# Codex cache flock in ``finally`` below. cache.db and stats.db are not
|
|
3893
|
+
# cross-database atomic: after this committed ingest, a projection
|
|
3894
|
+
# interruption is repaired by the next full reconciliation.
|
|
3895
|
+
project_after_unlock = True
|
|
3266
3896
|
finally:
|
|
3267
3897
|
try:
|
|
3268
3898
|
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
@@ -3270,6 +3900,11 @@ def sync_codex_cache(
|
|
|
3270
3900
|
pass
|
|
3271
3901
|
lock_fh.close()
|
|
3272
3902
|
|
|
3903
|
+
if project_after_unlock:
|
|
3904
|
+
from _cctally_quota import reconcile_codex_quota_projection
|
|
3905
|
+
reconcile_codex_quota_projection()
|
|
3906
|
+
return stats
|
|
3907
|
+
|
|
3273
3908
|
|
|
3274
3909
|
def iter_codex_entries(
|
|
3275
3910
|
conn: sqlite3.Connection,
|
|
@@ -3344,24 +3979,27 @@ def get_codex_entries(
|
|
|
3344
3979
|
except (sqlite3.DatabaseError, OSError) as exc:
|
|
3345
3980
|
eprint(f"[cache] unavailable ({exc}); falling back to direct JSONL parse")
|
|
3346
3981
|
return _collect_codex_entries_direct(range_start, range_end)
|
|
3347
|
-
|
|
3982
|
+
try:
|
|
3983
|
+
if skip_sync:
|
|
3984
|
+
return iter_codex_entries(conn, range_start, range_end)
|
|
3985
|
+
stats = sync_codex_cache(conn)
|
|
3986
|
+
if stats.lock_contended:
|
|
3987
|
+
# Sync commits file-by-file, so contention on the ingest lock
|
|
3988
|
+
# (e.g. a concurrent --rebuild, or a first-run sync still in
|
|
3989
|
+
# flight) can leave the cache PARTIALLY populated — some files
|
|
3990
|
+
# ingested, others pending. An "is the table empty?" guard passes
|
|
3991
|
+
# in that window and we'd silently return results missing the
|
|
3992
|
+
# caller's range. Fall back to a direct JSONL parse unconditionally
|
|
3993
|
+
# on contention; correctness > speed in the rare-but-real window
|
|
3994
|
+
# where cache state does not match disk.
|
|
3995
|
+
eprint(
|
|
3996
|
+
"[cache] concurrent codex ingest in progress; "
|
|
3997
|
+
"falling back to direct JSONL parse for correctness"
|
|
3998
|
+
)
|
|
3999
|
+
return _collect_codex_entries_direct(range_start, range_end)
|
|
3348
4000
|
return iter_codex_entries(conn, range_start, range_end)
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
# Sync commits file-by-file, so contention on the ingest lock
|
|
3352
|
-
# (e.g. a concurrent --rebuild, or a first-run sync still in
|
|
3353
|
-
# flight) can leave the cache PARTIALLY populated — some files
|
|
3354
|
-
# ingested, others pending. An "is the table empty?" guard passes
|
|
3355
|
-
# in that window and we'd silently return results missing the
|
|
3356
|
-
# caller's range. Fall back to a direct JSONL parse unconditionally
|
|
3357
|
-
# on contention; correctness > speed in the rare-but-real window
|
|
3358
|
-
# where cache state does not match disk.
|
|
3359
|
-
eprint(
|
|
3360
|
-
"[cache] concurrent codex ingest in progress; "
|
|
3361
|
-
"falling back to direct JSONL parse for correctness"
|
|
3362
|
-
)
|
|
3363
|
-
return _collect_codex_entries_direct(range_start, range_end)
|
|
3364
|
-
return iter_codex_entries(conn, range_start, range_end)
|
|
4001
|
+
finally:
|
|
4002
|
+
conn.close()
|
|
3365
4003
|
|
|
3366
4004
|
|
|
3367
4005
|
def _sum_codex_cost_for_range(
|
|
@@ -3513,7 +4151,14 @@ def open_cache_db() -> sqlite3.Connection:
|
|
|
3513
4151
|
eprint(f"[cache] could not chmod cache.db 0600 ({exc}); continuing")
|
|
3514
4152
|
|
|
3515
4153
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
3516
|
-
|
|
4154
|
+
# #297: 15 s (was 5 s) lets a writer wait out a slow-but-normal sync (>5 s)
|
|
4155
|
+
# instead of instantly erroring with "database is locked". Covers plain
|
|
4156
|
+
# writer-vs-writer SQLITE_BUSY, not SQLITE_BUSY_SNAPSHOT (defended by
|
|
4157
|
+
# write-lock-up-front at the write sites, #87).
|
|
4158
|
+
conn.execute("PRAGMA busy_timeout=15000")
|
|
4159
|
+
# #297: bound the persistent WAL file so a resetting checkpoint truncates it
|
|
4160
|
+
# back to this cap instead of leaving it at its multi-GB high-water size.
|
|
4161
|
+
conn.execute(f"PRAGMA journal_size_limit={CACHE_WAL_SIZE_LIMIT_BYTES}")
|
|
3517
4162
|
# Re-derivable DB under WAL: NORMAL risks at most the tail transaction on
|
|
3518
4163
|
# power loss, and cache.db can always be rebuilt (cache-sync --rebuild).
|
|
3519
4164
|
# Fewer ingest fsyncs than the default FULL. Matches stats.db
|