cctally 1.97.0 → 1.99.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.
@@ -342,6 +342,25 @@ def _derive_desired_events_into(records, cache_conn, scratch_path) -> list[dict]
342
342
  conn.close()
343
343
 
344
344
 
345
+ def _planner_op_kinds() -> "set[str]":
346
+ """Every `op` kind the planner validates against `_OP_CLASSIFICATIONS`.
347
+
348
+ One definition, so a test can assert what the planner ACTUALLY validates
349
+ rather than re-deriving the same union beside it (review round 2, finding
350
+ R2-6). A test that rebuilds the expression locally is a membership check
351
+ against a set it constructed itself: it passes whatever this module does.
352
+
353
+ `sync_week` is named literally because it is journaled as an `op` with no
354
+ fold applier and no accounts machinery entry, so neither registry carries
355
+ it, and an unclassified op kind raises `RederiveConflict`.
356
+ """
357
+ return (
358
+ set(_journal.FOLD_APPLIERS)
359
+ | set(_journal._ACCOUNTS_MACHINERY_KINDS)
360
+ | {"sync_week"}
361
+ )
362
+
363
+
345
364
  def plan_claude_usage(
346
365
  records,
347
366
  *,
@@ -360,11 +379,7 @@ def plan_claude_usage(
360
379
  _normalize_legacy_accounts(records)
361
380
  report = _lib_rederive.validate_family_registry(
362
381
  evt_kinds=set(_journal._EVT_SPECS),
363
- op_kinds=(
364
- set(_journal.FOLD_APPLIERS)
365
- | set(_journal._ACCOUNTS_MACHINERY_KINDS)
366
- | {"sync_week"}
367
- ),
382
+ op_kinds=_planner_op_kinds(),
368
383
  )
369
384
  if report.unclassified_evt_kinds or report.unclassified_op_kinds:
370
385
  raise _lib_rederive.RederiveConflict(
@@ -737,6 +752,143 @@ def _rederive_locks(*, apply: bool, timeout: float):
737
752
  release_cache_writer_flocks(held)
738
753
 
739
754
 
755
+ # --------------------------------------------------------------------------
756
+ # #500 §8/§8.1 — the operator-attribution apply lock set
757
+ # --------------------------------------------------------------------------
758
+ #
759
+ # `_rederive_locks` above yields no handles and releases its whole set together,
760
+ # so there is no way to drop only the two cache flocks at the end of the cache
761
+ # transaction while retaining stats maintenance and the ingest lock for the
762
+ # stats transaction that follows. `account attribute` needs exactly that: its
763
+ # cache work must be committed and unlocked before the stats transaction begins,
764
+ # which is the repository law that all cache work precedes the stats transaction
765
+ # (`docs/journal-gotchas.md`), while the ingest lock has to be held ACROSS both
766
+ # so nothing can consume the appended prefix in between.
767
+ #
768
+ # ORDERED PARTIAL RELEASE is the whole mechanism: the owner below releases a
769
+ # SUFFIX of the acquired set in reverse acquisition order and nothing else. A
770
+ # release that skipped an inner lock, or that took them in acquisition order,
771
+ # would be the lock-order violation this exists to avoid rather than a
772
+ # convenience on top of it.
773
+
774
+
775
+ class _OrderedApplyLockOwner:
776
+ """A held ordered flock set that can release a suffix of itself."""
777
+
778
+ __slots__ = ("_paths", "_held", "_cache_flock_count", "_cache_flocks_noted")
779
+
780
+ def __init__(self, paths, held, *, cache_flock_count: int) -> None:
781
+ self._paths = tuple(paths)
782
+ self._held = list(held)
783
+ self._cache_flock_count = int(cache_flock_count)
784
+ self._cache_flocks_noted = True
785
+ _cctally_core.note_attribution_apply_cache_flocks_acquired()
786
+
787
+ @property
788
+ def held_paths(self) -> tuple:
789
+ return tuple(self._paths[:len(self._held)])
790
+
791
+ def _note_cache_flocks_released(self) -> None:
792
+ if self._cache_flocks_noted:
793
+ self._cache_flocks_noted = False
794
+ _cctally_core.note_attribution_apply_cache_flocks_released()
795
+
796
+ def release_cache_flocks(self) -> None:
797
+ """Drop the global cache writer flock and the Codex provider flock.
798
+
799
+ Idempotent, because the apply sequence releases them at the end of its
800
+ cache transaction and the context manager's ``finally`` releases
801
+ whatever is left — the second call must not close a descriptor twice.
802
+
803
+ The release is RECORDED as well as performed, and that is what turns the
804
+ repository lock-order law into something the code enforces:
805
+ `_run_stats_ingest_once(locks_held=True)` refuses while this context
806
+ still holds them, so a caller that forgets this call gets a loud refusal
807
+ instead of a stats transaction opened underneath live cache flocks.
808
+ """
809
+ from _lib_cache_writer_lock import release_cache_writer_flocks
810
+
811
+ if len(self._held) <= len(self._paths) - self._cache_flock_count:
812
+ self._note_cache_flocks_released()
813
+ return
814
+ keep = len(self._paths) - self._cache_flock_count
815
+ # `release_cache_writer_flocks` releases the list it is given in REVERSE
816
+ # order, which is what makes this a suffix release rather than an
817
+ # arbitrary one.
818
+ release_cache_writer_flocks(self._held[keep:])
819
+ del self._held[keep:]
820
+ self._note_cache_flocks_released()
821
+
822
+ def release_all(self) -> None:
823
+ from _lib_cache_writer_lock import release_cache_writer_flocks
824
+
825
+ release_cache_writer_flocks(self._held)
826
+ self._held.clear()
827
+ self._note_cache_flocks_released()
828
+
829
+
830
+ @contextlib.contextmanager
831
+ def codex_attribution_apply_locks(*, timeout: float = _REDERIVE_LOCK_TIMEOUT_SECONDS):
832
+ """Acquire the #500 §8 apply order and yield an ordered-partial-release owner.
833
+
834
+ The order is ``_rederive_locks``' applying order extended by the Codex
835
+ provider flock, exactly as the spec states:
836
+
837
+ 1. stats maintenance, exclusive
838
+ 2. cache maintenance, shared
839
+ 3. ``journal.ingest.lock``, exclusive
840
+ 4. global ``cache.db.lock``, exclusive
841
+ 5. the Codex provider flock, exclusive
842
+
843
+ ``owner.release_cache_flocks()`` drops 5 then 4 and retains 1-3, so the
844
+ stats transaction runs with the ingest lock still held while every cache
845
+ writer is free again.
846
+
847
+ Do not ``os.fork()`` and do not start a thread inside this block. The
848
+ lock-order guard behind ``release_cache_flocks()`` is a ContextVar counter,
849
+ so a child context starts from a COPY of this one and never observes the
850
+ later release — a forked or spawned worker would either refuse a stats
851
+ ingest that is legitimately unblocked, or, if it were created before the
852
+ acquisition, run one while the cache flocks are still held. The flocks
853
+ themselves are process-wide and unaffected; the guard is what is advisory
854
+ across a context boundary.
855
+ """
856
+ from _lib_cache_writer_lock import acquire_ordered_flocks
857
+
858
+ _cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
859
+ locks = [
860
+ (_cctally_core.STATS_LOCK_MAINTENANCE_PATH, fcntl.LOCK_EX),
861
+ (_cctally_core.CACHE_LOCK_MAINTENANCE_PATH, fcntl.LOCK_SH),
862
+ (_cctally_core.JOURNAL_INGEST_LOCK_PATH, fcntl.LOCK_EX),
863
+ (_cctally_core.CACHE_LOCK_PATH, fcntl.LOCK_EX),
864
+ (_cctally_core.CACHE_LOCK_CODEX_PATH, fcntl.LOCK_EX),
865
+ ]
866
+ held = acquire_ordered_flocks(locks, timeout=timeout)
867
+ if held is None:
868
+ raise RederiveBusy(
869
+ "another database sync or maintenance operation holds the "
870
+ "attribution apply lock set; retry shortly"
871
+ )
872
+ owner = _OrderedApplyLockOwner(
873
+ [path for path, _mode in locks], held, cache_flock_count=2)
874
+ # #386, the same declaration `_rederive_locks` makes: record the stats
875
+ # maintenance hold so a nested live `open_db()` does not request SHARED on a
876
+ # second fd of this same file and self-deadlock, and declare the sanctioned
877
+ # write regime plus the ingest hold so a heal reached from in here
878
+ # recognises itself as the serialized writer.
879
+ import _cctally_store
880
+
881
+ _cctally_core.note_stats_maintenance_acquired()
882
+ try:
883
+ with _cctally_store.stats_write_scope(
884
+ "codex-window-attribution", ingest_lock=True,
885
+ ):
886
+ yield owner
887
+ finally:
888
+ _cctally_core.note_stats_maintenance_released()
889
+ owner.release_all()
890
+
891
+
740
892
  def preview_db_rederive(
741
893
  family: str,
742
894
  *,
@@ -99,7 +99,8 @@ _QUALIFIED_CODEX_ENTRIES_SQL = """
99
99
  entries.output_tokens, entries.reasoning_output_tokens,
100
100
  entries.total_tokens, threads.cwd, threads.git_json,
101
101
  threads.conversation_key AS joined_conversation_key,
102
- threads.source_root_key AS joined_source_root_key
102
+ threads.source_root_key AS joined_source_root_key,
103
+ entries.id AS cache_entry_id
103
104
  FROM codex_session_entries AS entries
104
105
  INDEXED BY idx_codex_entries_ts_root_conversation
105
106
  LEFT JOIN codex_conversation_threads AS threads
@@ -112,6 +113,21 @@ _QUALIFIED_CODEX_ENTRIES_SQL = """
112
113
  """
113
114
 
114
115
 
116
+ def _qualified_codex_path_entries_sql(identity_count: int) -> str:
117
+ """Return the qualified read constrained to physical cache identities."""
118
+ predicates = " OR ".join(
119
+ "(entries.source_root_key = ? AND entries.source_path = ?)"
120
+ for _ in range(identity_count)
121
+ )
122
+ return _QUALIFIED_CODEX_ENTRIES_SQL.replace(
123
+ "INDEXED BY idx_codex_entries_ts_root_conversation",
124
+ "INDEXED BY idx_codex_entries_root_path",
125
+ ).replace(
126
+ " ORDER BY entries.timestamp_utc ASC",
127
+ f" AND ({predicates})\n ORDER BY entries.timestamp_utc ASC",
128
+ )
129
+
130
+
115
131
  _INHERITED_CODEX_PROJECT_METADATA_SQL = """
116
132
  SELECT files.source_root_key, files.path, inherited.cwd, inherited.git_json
117
133
  FROM codex_session_files AS files
@@ -124,6 +140,21 @@ _INHERITED_CODEX_PROJECT_METADATA_SQL = """
124
140
  """
125
141
 
126
142
 
143
+ def _inherited_codex_path_metadata_sql(identity_count: int) -> str:
144
+ """Constrain inherited file-alias metadata to dirty physical paths."""
145
+ if identity_count < 1:
146
+ raise ValueError("identity_count must be positive")
147
+ predicates = " OR ".join(
148
+ "(files.source_root_key = ? AND files.path = ?)"
149
+ for _ in range(identity_count)
150
+ )
151
+ return _INHERITED_CODEX_PROJECT_METADATA_SQL.replace(
152
+ " WHERE files.last_native_thread_id IS NOT NULL",
153
+ f" WHERE ({predicates})\n"
154
+ " AND files.last_native_thread_id IS NOT NULL",
155
+ )
156
+
157
+
127
158
  _CODEX_ACCOUNTING_ENTRIES_SQL = """
128
159
  SELECT timestamp_utc, source_root_key, conversation_key, model,
129
160
  input_tokens, cached_input_tokens, output_tokens,
@@ -458,6 +489,7 @@ def load_qualified_codex_entries(
458
489
  sync: bool = True,
459
490
  group: str = "git-root",
460
491
  cache_conn: sqlite3.Connection | None = None,
492
+ source_identities: Iterable[tuple[str, str]] | None = None,
461
493
  ) -> tuple[QualifiedCodexEntry, ...]:
462
494
  """Load exactly one bounded, root-qualified Codex accounting read.
463
495
 
@@ -475,6 +507,15 @@ def load_qualified_codex_entries(
475
507
  if cache_conn is not None and sync:
476
508
  raise ValueError("cache_conn requires sync=False")
477
509
 
510
+ identities: tuple[tuple[str, str], ...] | None = None
511
+ if source_identities is not None:
512
+ identities = tuple(sorted({
513
+ (str(root), str(path)) for root, path in source_identities
514
+ if str(root) and str(path)
515
+ }))
516
+ if not identities:
517
+ return ()
518
+
478
519
  c = _cctally()
479
520
  owns_conn = cache_conn is None
480
521
  if owns_conn:
@@ -496,13 +537,25 @@ def load_qualified_codex_entries(
496
537
  if stats.lock_contended:
497
538
  raise QualifiedMetadataUnavailable("Codex qualified project metadata is unavailable")
498
539
  conn.row_factory = sqlite3.Row
499
- rows = tuple(conn.execute(
500
- _QUALIFIED_CODEX_ENTRIES_SQL,
501
- (start.astimezone(UTC).isoformat(), end.astimezone(UTC).isoformat()),
502
- ))
540
+ sql = (
541
+ _QUALIFIED_CODEX_ENTRIES_SQL if identities is None
542
+ else _qualified_codex_path_entries_sql(len(identities))
543
+ )
544
+ params: tuple[object, ...] = (
545
+ start.astimezone(UTC).isoformat(), end.astimezone(UTC).isoformat(),
546
+ *(value for identity in (identities or ()) for value in identity),
547
+ )
548
+ rows = tuple(conn.execute(sql, params))
503
549
  inherited_metadata: dict[tuple[str, str], sqlite3.Row] = {}
504
550
  if _supports_native_file_aliases(conn):
505
- for inherited in conn.execute(_INHERITED_CODEX_PROJECT_METADATA_SQL):
551
+ inherited_sql = (
552
+ _INHERITED_CODEX_PROJECT_METADATA_SQL if identities is None
553
+ else _inherited_codex_path_metadata_sql(len(identities))
554
+ )
555
+ inherited_params = tuple(
556
+ value for identity in (identities or ()) for value in identity
557
+ )
558
+ for inherited in conn.execute(inherited_sql, inherited_params):
506
559
  identity = (str(inherited["source_root_key"] or ""), str(inherited["path"] or ""))
507
560
  if all(identity):
508
561
  inherited_metadata.setdefault(identity, inherited)
@@ -580,6 +633,7 @@ def load_qualified_codex_entries(
580
633
  # NULL ≡ unattributed — the cache-read rule (#416 §5.2). Carried,
581
634
  # never grouped; see the field's comment on `QualifiedCodexEntry`.
582
635
  account_key=str(row["account_key"] or _lib_accounts.UNATTRIBUTED),
636
+ cache_entry_id=int(row["cache_entry_id"]),
583
637
  ))
584
638
  return tuple(result)
585
639