cctally 1.80.3 → 1.81.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/bin/_cctally_account.py +397 -0
  3. package/bin/_cctally_alerts.py +58 -2
  4. package/bin/_cctally_cache.py +696 -166
  5. package/bin/_cctally_cache_report.py +21 -3
  6. package/bin/_cctally_config.py +119 -8
  7. package/bin/_cctally_core.py +343 -48
  8. package/bin/_cctally_dashboard.py +32 -7
  9. package/bin/_cctally_dashboard_conversation.py +6 -2
  10. package/bin/_cctally_dashboard_envelope.py +49 -0
  11. package/bin/_cctally_dashboard_share.py +78 -1
  12. package/bin/_cctally_dashboard_sources.py +459 -59
  13. package/bin/_cctally_db.py +659 -152
  14. package/bin/_cctally_diff.py +9 -0
  15. package/bin/_cctally_doctor.py +201 -26
  16. package/bin/_cctally_five_hour.py +110 -69
  17. package/bin/_cctally_forecast.py +112 -43
  18. package/bin/_cctally_journal.py +591 -80
  19. package/bin/_cctally_milestones.py +180 -67
  20. package/bin/_cctally_parser.py +87 -0
  21. package/bin/_cctally_percent_breakdown.py +24 -15
  22. package/bin/_cctally_project.py +12 -1
  23. package/bin/_cctally_quota.py +150 -39
  24. package/bin/_cctally_record.py +491 -141
  25. package/bin/_cctally_reporting.py +66 -14
  26. package/bin/_cctally_setup.py +9 -1
  27. package/bin/_cctally_source_analytics.py +56 -7
  28. package/bin/_cctally_statusline.py +55 -8
  29. package/bin/_cctally_store.py +20 -8
  30. package/bin/_cctally_sync_week.py +30 -10
  31. package/bin/_cctally_tui.py +99 -18
  32. package/bin/_cctally_weekrefs.py +38 -15
  33. package/bin/_lib_accounts.py +325 -0
  34. package/bin/_lib_alerts_payload.py +25 -4
  35. package/bin/_lib_cache_writer_lock.py +77 -0
  36. package/bin/_lib_codex_hooks.py +40 -1
  37. package/bin/_lib_diff_kernel.py +28 -14
  38. package/bin/_lib_doctor.py +160 -0
  39. package/bin/_lib_journal.py +65 -2
  40. package/bin/_lib_quota.py +81 -4
  41. package/bin/_lib_render.py +19 -5
  42. package/bin/_lib_share.py +25 -0
  43. package/bin/_lib_snapshot_cache.py +8 -0
  44. package/bin/_lib_subscription_weeks.py +16 -2
  45. package/bin/_lib_view_models.py +18 -9
  46. package/bin/cctally +43 -4
  47. package/dashboard/static/assets/index-DJP4gEB7.js +92 -0
  48. package/dashboard/static/assets/index-Dk1nplOz.css +1 -0
  49. package/dashboard/static/dashboard.html +2 -2
  50. package/package.json +4 -1
  51. package/dashboard/static/assets/index-B3y14l1o.js +0 -92
  52. package/dashboard/static/assets/index-Cc2ykqF_.css +0 -1
@@ -60,6 +60,7 @@ Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md
60
60
  from __future__ import annotations
61
61
 
62
62
  import argparse
63
+ import contextvars
63
64
  import datetime as dt
64
65
  import enum
65
66
  import fcntl
@@ -99,6 +100,18 @@ from _cctally_core import (
99
100
  # CCTALLY_DEBUG verbosity is decided in one place.
100
101
  import _lib_log
101
102
 
103
+
104
+ # Production cache dispatchers hold maintenance-exclusive + the global cache
105
+ # writer flock across schema apply, every migration handler, and ledger/version
106
+ # stamps. Older destructive handlers still acquire the global flock when
107
+ # invoked directly by their unit/golden tests; this context flag prevents those
108
+ # handlers from trying to re-lock the same flock through a second descriptor
109
+ # during a production dispatcher walk.
110
+ _CACHE_DISPATCH_WRITER_LOCK_HELD = contextvars.ContextVar(
111
+ "_CACHE_DISPATCH_WRITER_LOCK_HELD",
112
+ default=False,
113
+ )
114
+
102
115
  # Stats migration 008 needs the same per-entry cost computation used by
103
116
  # the live cost-report path. Direct import keeps the kernel single-sourced
104
117
  # (no shim drift); _lib_pricing is a stdlib-only leaf module so no cycle
@@ -732,13 +745,172 @@ def write_corruption_forensics(
732
745
  return out
733
746
 
734
747
 
735
- def quarantine_db_family(db_path, *, ts: "str | None" = None) -> pathlib.Path:
748
+ def _quarantine_pending_path(db_path: pathlib.Path) -> pathlib.Path:
749
+ return db_path.with_name(f"{db_path.name}.quarantine-pending.json")
750
+
751
+
752
+ def _atomic_write_private_json(path: pathlib.Path, payload: dict) -> None:
753
+ """Durably replace one private JSON control record."""
754
+ token = f"{os.getpid()}-{time.time_ns()}-{os.urandom(8).hex()}"
755
+ temp = path.with_name(f".{path.name}.{token}.tmp")
756
+ fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
757
+ try:
758
+ encoded = (
759
+ json.dumps(payload, indent=2, sort_keys=True) + "\n"
760
+ ).encode("utf-8")
761
+ view = memoryview(encoded)
762
+ while view:
763
+ written = os.write(fd, view)
764
+ if written <= 0:
765
+ raise OSError("short write while publishing control record")
766
+ view = view[written:]
767
+ os.fsync(fd)
768
+ finally:
769
+ os.close(fd)
770
+ try:
771
+ os.replace(temp, path)
772
+ _fsync_directory(path.parent)
773
+ finally:
774
+ try:
775
+ temp.unlink()
776
+ except FileNotFoundError:
777
+ pass
778
+
779
+
780
+ def _load_pending_quarantine(db_path: pathlib.Path) -> "dict[str, Any] | None":
781
+ pending = _quarantine_pending_path(db_path)
782
+ if not pending.exists():
783
+ return None
784
+ try:
785
+ state = json.loads(pending.read_text(encoding="utf-8"))
786
+ except (OSError, json.JSONDecodeError) as exc:
787
+ raise OSError(f"invalid pending quarantine record {pending}: {exc}") from exc
788
+ if (
789
+ not isinstance(state, dict)
790
+ or state.get("schemaVersion") != 1
791
+ or state.get("originalPath") != str(db_path)
792
+ or not isinstance(state.get("incidentPath"), str)
793
+ or not isinstance(state.get("members"), list)
794
+ or not all(
795
+ isinstance(name, str)
796
+ and name in {
797
+ db_path.name,
798
+ f"{db_path.name}-wal",
799
+ f"{db_path.name}-shm",
800
+ }
801
+ for name in state["members"]
802
+ )
803
+ ):
804
+ raise OSError(f"invalid pending quarantine record {pending}")
805
+ incident = pathlib.Path(state["incidentPath"])
806
+ expected_root = _cctally_core.APP_DIR / "quarantine"
807
+ try:
808
+ if incident.parent.resolve() != expected_root.resolve():
809
+ raise OSError(
810
+ f"pending quarantine incident is outside {expected_root}: {incident}"
811
+ )
812
+ except OSError as exc:
813
+ raise OSError(f"invalid pending quarantine incident {incident}: {exc}") from exc
814
+ return state
815
+
816
+
817
+ def _quarantine_db_family_strict(
818
+ db_path: pathlib.Path, *, ts: "str | None" = None,
819
+ ) -> pathlib.Path:
820
+ """Resumably move every snapshotted family member or fail closed.
821
+
822
+ The durable pending record is published before the first rename. A killed
823
+ owner or individual rename failure therefore leaves enough information for
824
+ the next maintenance-exclusive opener to complete the exact same incident;
825
+ recreation is forbidden until every member is present at its destination.
826
+ """
827
+ db_path = pathlib.Path(db_path)
828
+ pending = _quarantine_pending_path(db_path)
829
+ state = _load_pending_quarantine(db_path)
830
+ if state is None:
831
+ ts = ts or _db_backup_timestamp()
832
+ root = _cctally_core.APP_DIR / "quarantine"
833
+ incident = root / f"{db_path.name}-{ts}"
834
+ incident.mkdir(parents=True, exist_ok=True)
835
+ for directory in (root, incident):
836
+ try:
837
+ os.chmod(directory, 0o700)
838
+ except OSError:
839
+ pass
840
+ # Move sidecars first and the main file last. The pending record blocks
841
+ # every opener throughout, but retaining the main file longest also
842
+ # leaves the most useful evidence in place after an abrupt first move.
843
+ members = [
844
+ candidate.name
845
+ for candidate in (
846
+ pathlib.Path(f"{db_path}-wal"),
847
+ pathlib.Path(f"{db_path}-shm"),
848
+ db_path,
849
+ )
850
+ if candidate.exists()
851
+ ]
852
+ if not members:
853
+ raise OSError(f"no database family exists to quarantine at {db_path}")
854
+ state = {
855
+ "schemaVersion": 1,
856
+ "originalPath": str(db_path),
857
+ "incidentPath": str(incident),
858
+ "members": members,
859
+ "createdAtUtc": _forensics_iso(dt.datetime.now(dt.timezone.utc)),
860
+ }
861
+ _atomic_write_private_json(pending, state)
862
+ else:
863
+ incident = pathlib.Path(state["incidentPath"])
864
+ incident.mkdir(parents=True, exist_ok=True)
865
+
866
+ moved: list[str] = []
867
+ for name in state["members"]:
868
+ src = db_path.parent / name
869
+ dst = incident / name
870
+ src_exists = src.exists()
871
+ dst_exists = dst.exists()
872
+ if src_exists and dst_exists:
873
+ raise OSError(
874
+ f"ambiguous quarantine member exists at source and destination: {name}"
875
+ )
876
+ if src_exists:
877
+ os.replace(src, dst)
878
+ _fsync_directory(src.parent)
879
+ _fsync_directory(dst.parent)
880
+ elif not dst_exists:
881
+ raise OSError(
882
+ f"pending quarantine member is missing at source and destination: {name}"
883
+ )
884
+ try:
885
+ os.chmod(dst, 0o600)
886
+ except OSError:
887
+ pass
888
+ moved.append(name)
889
+
890
+ manifest = {
891
+ "schemaVersion": 1,
892
+ "quarantinedAtUtc": _forensics_iso(dt.datetime.now(dt.timezone.utc)),
893
+ "originalPath": str(db_path),
894
+ "movedFiles": moved,
895
+ "complete": True,
896
+ }
897
+ _atomic_write_private_json(incident / "manifest.json", manifest)
898
+ pending.unlink()
899
+ _fsync_directory(pending.parent)
900
+ return incident
901
+
902
+
903
+ def quarantine_db_family(
904
+ db_path, *, ts: "str | None" = None, strict: bool = False,
905
+ ) -> pathlib.Path:
736
906
  """Move a damaged DB + its ``-wal``/``-shm`` sidecars into a single
737
907
  timestamped incident directory under ``quarantine/`` with a manifest (spec
738
908
  §6.3). NEVER deletes evidence — three renames under the caller's exclusion
739
909
  locks, not pretending to be one atomic op. ``0o700`` dir / ``0o600`` files.
740
910
  Returns the incident directory (which may be empty if nothing was present)."""
741
911
  db_path = pathlib.Path(db_path)
912
+ if strict:
913
+ return _quarantine_db_family_strict(db_path, ts=ts)
742
914
  ts = ts or _db_backup_timestamp()
743
915
  root = _cctally_core.APP_DIR / "quarantine"
744
916
  incident = root / f"{db_path.name}-{ts}"
@@ -2674,8 +2846,9 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
2674
2846
  ``add_column_if_missing``). Does NOT run the dispatcher and does NOT include
2675
2847
  the Codex ``last_total_tokens`` ALTER, which carries a one-time purge
2676
2848
  side-effect that stays in ``open_cache_db``. The eager dispatcher can run
2677
- 024, but that handler acquires its own Codex flock and only deletes
2678
- Codex-derived rows; it neither reads nor requires ``last_total_tokens``.
2849
+ 024 under the dispatcher's global writer flock, after which that handler
2850
+ acquires its Codex flock and only deletes Codex-derived rows; it neither
2851
+ reads nor requires ``last_total_tokens``.
2679
2852
  Normal ``open_cache_db`` remains responsible for the ALTER and its historic
2680
2853
  purge, so leaving it outside this shared schema is safe.
2681
2854
  """
@@ -3084,6 +3257,20 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
3084
3257
  # existing last_model/thread facts. A batch that ends after a turn_context
3085
3258
  # and resumes with a response_item stamps the correct effective turn.
3086
3259
  add_column_if_missing(conn, "codex_session_files", "last_turn_id", "TEXT")
3260
+ # account_key (#341, spec §2 cache.db): observe-and-stamp attribution rides a
3261
+ # NULLABLE column on every stamped-entry family — the read-side rule is
3262
+ # ``NULL ≡ unattributed`` (no NOT NULL, no default), so an unstamped legacy
3263
+ # row and a stably-unattributed ingest render identically. The Claude
3264
+ # families (session_entries/session_files) are backfilled from the cutover op
3265
+ # by cache migration 029; Codex families are stamped forward at ingest time
3266
+ # (per-root auth.json) and pre-feature Codex rows stay NULL per Q2. These
3267
+ # column-adds ride the migration-bumped head like every other cache column
3268
+ # addition (schema apply is version-gated, migrations-gotchas.md).
3269
+ add_column_if_missing(conn, "session_entries", "account_key", "TEXT")
3270
+ add_column_if_missing(conn, "session_files", "account_key", "TEXT")
3271
+ add_column_if_missing(conn, "codex_session_entries", "account_key", "TEXT")
3272
+ add_column_if_missing(conn, "codex_session_files", "account_key", "TEXT")
3273
+ add_column_if_missing(conn, "quota_window_snapshots", "account_key", "TEXT")
3087
3274
  conn.execute(
3088
3275
  "CREATE INDEX IF NOT EXISTS idx_codex_files_source_root "
3089
3276
  "ON codex_session_files(source_root_key)"
@@ -3846,7 +4033,47 @@ def _codex_conversation_fts_full_clear(conn: sqlite3.Connection) -> None:
3846
4033
  pass # table not yet created (pre-schema conn); nothing to clear
3847
4034
 
3848
4035
 
4036
+ def _run_pending_cache_migrations_under_writer_lock(
4037
+ conn: sqlite3.Connection,
4038
+ ) -> None:
4039
+ """Run the cache dispatcher while marking its global flock as owned."""
4040
+ token = _CACHE_DISPATCH_WRITER_LOCK_HELD.set(True)
4041
+ try:
4042
+ _run_pending_migrations(
4043
+ conn,
4044
+ registry=_CACHE_MIGRATIONS,
4045
+ db_label="cache.db",
4046
+ recover_version_ahead=True,
4047
+ )
4048
+ finally:
4049
+ _CACHE_DISPATCH_WRITER_LOCK_HELD.reset(token)
4050
+
4051
+
3849
4052
  def _eagerly_apply_cache_migrations() -> None:
4053
+ """Serialize the eager cache schema/migration path against all writers."""
4054
+ from _lib_cache_writer_lock import (
4055
+ acquire_ordered_flocks,
4056
+ release_cache_writer_flocks,
4057
+ )
4058
+
4059
+ _cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
4060
+ held = acquire_ordered_flocks(
4061
+ [
4062
+ (_cctally_core.CACHE_LOCK_MAINTENANCE_PATH, fcntl.LOCK_EX),
4063
+ (_cctally_core.CACHE_LOCK_PATH, fcntl.LOCK_EX),
4064
+ ]
4065
+ )
4066
+ if held is None:
4067
+ raise MigrationGateNotMet(
4068
+ "cache.db writer busy; deferring eager cache migrations"
4069
+ )
4070
+ try:
4071
+ _eagerly_apply_cache_migrations_under_writer_lock()
4072
+ finally:
4073
+ release_cache_writer_flocks(held)
4074
+
4075
+
4076
+ def _eagerly_apply_cache_migrations_under_writer_lock() -> None:
3850
4077
  """Open cache.db so its pending migrations (notably
3851
4078
  ``001_dedup_highest_wins``) apply BEFORE stats migration 008's gate
3852
4079
  check.
@@ -3953,10 +4180,7 @@ def _eagerly_apply_cache_migrations() -> None:
3953
4180
  # Dispatcher (cache.db side). Runs every pending cache
3954
4181
  # migration, including ``001_dedup_highest_wins``. Idempotent —
3955
4182
  # if 001 has already applied, this is a fast-path return.
3956
- _run_pending_migrations(
3957
- conn, registry=_CACHE_MIGRATIONS, db_label="cache.db",
3958
- recover_version_ahead=True,
3959
- )
4183
+ _run_pending_cache_migrations_under_writer_lock(conn)
3960
4184
  finally:
3961
4185
  # Close immediately so the WAL writer lock (if any) is
3962
4186
  # released before the stats 008 body opens its read-only
@@ -4103,6 +4327,43 @@ def _cache_db_codex_lock_path_for_conn(
4103
4327
  return None
4104
4328
 
4105
4329
 
4330
+ def _acquire_cache_db_codex_provider_flock(
4331
+ conn: sqlite3.Connection,
4332
+ *,
4333
+ migration: str,
4334
+ ) -> list[int]:
4335
+ """Take the Codex provider flock inside the dispatcher's global flock.
4336
+
4337
+ Production dispatchers already hold the global cache writer flock. The
4338
+ provider-local lock remains inside handlers 024–027 so direct migration
4339
+ tests and provider-scoped coordination retain their fail-closed behavior.
4340
+ """
4341
+ provider_path = _cache_db_codex_lock_path_for_conn(conn)
4342
+ if provider_path is None:
4343
+ return []
4344
+
4345
+ from _lib_cache_writer_lock import acquire_ordered_flocks
4346
+
4347
+ try:
4348
+ held = acquire_ordered_flocks([(provider_path, fcntl.LOCK_EX)])
4349
+ except OSError as exc:
4350
+ raise MigrationGateNotMet(
4351
+ f"cache.db Codex lock unavailable; deferring cache {migration}"
4352
+ ) from exc
4353
+ if held is None:
4354
+ raise MigrationGateNotMet(
4355
+ f"cache.db Codex lock held by a concurrent Codex sync; "
4356
+ f"deferring cache {migration}"
4357
+ )
4358
+ return held
4359
+
4360
+
4361
+ def _release_cache_db_writer_flocks(held: list[int]) -> None:
4362
+ from _lib_cache_writer_lock import release_cache_writer_flocks
4363
+
4364
+ release_cache_writer_flocks(held)
4365
+
4366
+
4106
4367
  @cache_migration("001_dedup_highest_wins")
4107
4368
  def _001_dedup_highest_wins(conn: sqlite3.Connection) -> None:
4108
4369
  """One-time re-ingest of session_entries with corrected msg_id+req_id dedup.
@@ -4151,9 +4412,10 @@ def _001_dedup_highest_wins(conn: sqlite3.Connection) -> None:
4151
4412
  handler time anyway. Interactive surfaces (``report``,
4152
4413
  ``weekly``, ``percent-breakdown``, etc.) still see it once.
4153
4414
  """
4154
- # #105 — mutual exclusion with ``sync_cache``. Acquire the SAME
4155
- # ``cache.db.lock`` fcntl flock ``sync_cache`` holds for its entire
4156
- # walk, BEFORE the ``BEGIN IMMEDIATE`` below. Both paths therefore
4415
+ # #105 — mutual exclusion with ``sync_cache``. Production dispatchers
4416
+ # already hold the SAME ``cache.db.lock`` fcntl flock ``sync_cache`` holds
4417
+ # for its entire walk. Direct handler calls (unit/golden tests) acquire it
4418
+ # here before the ``BEGIN IMMEDIATE`` below. Both paths therefore
4157
4419
  # acquire fcntl -> SQLite write lock in ONE consistent order, so there
4158
4420
  # is no opposite-order deadlock (the hazard that deferred this fix:
4159
4421
  # SQLite-then-fcntl in 001 vs fcntl-then-SQLite in sync_cache). With
@@ -4172,7 +4434,11 @@ def _001_dedup_highest_wins(conn: sqlite3.Connection) -> None:
4172
4434
  # non-blocking LOCK_NB-and-bail and the framework's "defer is the safe
4173
4435
  # side" contract. 008/009/010 already defer while 001 is pending, so the
4174
4436
  # system stays safe until a non-contended instant applies it.
4175
- lock_path = _cache_db_lock_path_for_conn(conn)
4437
+ lock_path = (
4438
+ None
4439
+ if _CACHE_DISPATCH_WRITER_LOCK_HELD.get()
4440
+ else _cache_db_lock_path_for_conn(conn)
4441
+ )
4176
4442
  lock_fh = None
4177
4443
  if lock_path is not None:
4178
4444
  lock_fh = open(lock_path, "w")
@@ -4786,8 +5052,10 @@ def _020_session_entries_physical_unique(conn: sqlite3.Connection) -> None:
4786
5052
  offset bookkeeping cannot produce them) with no principled winner; MIN(id)
4787
5053
  keeps the first-ingested row, matching the pre-bug state. cache.db is
4788
5054
  re-derivable — `cache-sync --rebuild` remains the escape hatch. Mirrors
4789
- cache 001's flock-then-BEGIN-IMMEDIATE pattern (#105): mutual exclusion with
4790
- a mid-walk sync_cache, deferring via MigrationGateNotMet on contention.
5055
+ cache 001's global-flock-then-BEGIN-IMMEDIATE pattern (#105): mutual
5056
+ exclusion with a mid-walk sync_cache. Production dispatchers already hold
5057
+ that flock; direct handler calls acquire it here and defer via
5058
+ MigrationGateNotMet on contention.
4791
5059
 
4792
5060
  Fresh installs never run this handler: the dispatcher stamps it WITHOUT
4793
5061
  running (the fresh-install branch), and `_apply_cache_schema` already created
@@ -4796,7 +5064,11 @@ def _020_session_entries_physical_unique(conn: sqlite3.Connection) -> None:
4796
5064
  on a second run) and the index create is `IF NOT EXISTS`; safe on an empty
4797
5065
  table.
4798
5066
  """
4799
- lock_path = _cache_db_lock_path_for_conn(conn)
5067
+ lock_path = (
5068
+ None
5069
+ if _CACHE_DISPATCH_WRITER_LOCK_HELD.get()
5070
+ else _cache_db_lock_path_for_conn(conn)
5071
+ )
4800
5072
  lock_fh = None
4801
5073
  if lock_path is not None:
4802
5074
  lock_fh = open(lock_path, "w")
@@ -4906,27 +5178,18 @@ def _024_codex_fused_ingest_rebuild(conn: sqlite3.Connection) -> None:
4906
5178
  sync rederives them from rollout source. Claude tables and Claude quota
4907
5179
  snapshots remain untouched.
4908
5180
 
4909
- The handler takes ``cache.db.codex.lock`` before ``BEGIN IMMEDIATE`` the
4910
- same fcntl-then-SQLite order as ``sync_codex_cache``. Contention raises
5181
+ The cache dispatcher holds maintenance + global writer exclusion; this
5182
+ handler then takes ``cache.db.codex.lock`` before ``BEGIN IMMEDIATE`` — the same
5183
+ fcntl-then-SQLite order as ``sync_codex_cache``. Contention raises
4911
5184
  ``MigrationGateNotMet`` before any DML, so both ordinary and eager cache
4912
5185
  dispatch defer without a partial clear. The DELETE-only transition is safe
4913
5186
  to retry after a crash between this commit and the dispatcher's separate
4914
- marker stamp: rerunning against its own empty Codex state is a no-op.
4915
- Never self-stamp; the dispatcher owns ``schema_migrations`` and
4916
- ``user_version``.
5187
+ marker stamp: rerunning against its own empty Codex state is a no-op. Never
5188
+ self-stamp; the dispatcher owns ``schema_migrations`` and ``user_version``.
4917
5189
  """
4918
- lock_path = _cache_db_codex_lock_path_for_conn(conn)
4919
- lock_fh = None
4920
- if lock_path is not None:
4921
- lock_fh = open(lock_path, "w")
4922
- try:
4923
- fcntl.flock(lock_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
4924
- except BlockingIOError:
4925
- lock_fh.close()
4926
- raise MigrationGateNotMet(
4927
- "cache.db.codex.lock held by a concurrent sync_codex_cache; "
4928
- "deferring cache 024 fused-ingest rebuild (#294 S1)"
4929
- )
5190
+ held = _acquire_cache_db_codex_provider_flock(
5191
+ conn, migration="024 fused-ingest rebuild (#294 S1)"
5192
+ )
4930
5193
  try:
4931
5194
  conn.execute("BEGIN IMMEDIATE")
4932
5195
  try:
@@ -4948,12 +5211,7 @@ def _024_codex_fused_ingest_rebuild(conn: sqlite3.Connection) -> None:
4948
5211
  conn.rollback()
4949
5212
  raise
4950
5213
  finally:
4951
- if lock_fh is not None:
4952
- try:
4953
- fcntl.flock(lock_fh, fcntl.LOCK_UN)
4954
- except OSError:
4955
- pass
4956
- lock_fh.close()
5214
+ _release_cache_db_writer_flocks(held)
4957
5215
 
4958
5216
 
4959
5217
  @cache_migration("025_codex_conversation_normalization")
@@ -4969,27 +5227,19 @@ def _025_codex_conversation_normalization(conn: sqlite3.Connection) -> None:
4969
5227
  turn_context sticky state as it goes. The physical event log and Codex
4970
5228
  accounting/thread/quota rows are the replay SOURCE and are never touched.
4971
5229
 
4972
- Takes ``cache.db.codex.lock`` with the same fcntl-then-``BEGIN IMMEDIATE``
4973
- order as ``sync_codex_cache``, raising ``MigrationGateNotMet`` on contention
4974
- (defer, never partially normalize) from both the ordinary and eager cache
4975
- dispatch paths. Deterministic replay order + the non-AUTOINCREMENT ``id``
4976
- rowid alias + the storm-free full-clear make a re-run (handler success, crash
4977
- before the central stamp) byte-idempotent INCLUDING the FTS shadow tables.
4978
- Never self-stamps; the dispatcher owns ``schema_migrations`` and
4979
- ``user_version``.
5230
+ The cache dispatcher holds maintenance + global writer exclusion; this
5231
+ handler then takes ``cache.db.codex.lock`` with the same
5232
+ fcntl-then-``BEGIN IMMEDIATE`` order as ``sync_codex_cache``, raising
5233
+ ``MigrationGateNotMet`` on contention (defer, never partially normalize)
5234
+ from both the ordinary and eager cache dispatch paths. Deterministic replay
5235
+ order + the non-AUTOINCREMENT ``id`` rowid alias + the storm-free full-clear
5236
+ make a re-run (handler success, crash before the central stamp)
5237
+ byte-idempotent INCLUDING the FTS shadow tables. Never self-stamps; the
5238
+ dispatcher owns ``schema_migrations`` and ``user_version``.
4980
5239
  """
4981
- lock_path = _cache_db_codex_lock_path_for_conn(conn)
4982
- lock_fh = None
4983
- if lock_path is not None:
4984
- lock_fh = open(lock_path, "w")
4985
- try:
4986
- fcntl.flock(lock_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
4987
- except BlockingIOError:
4988
- lock_fh.close()
4989
- raise MigrationGateNotMet(
4990
- "cache.db.codex.lock held by a concurrent sync_codex_cache; "
4991
- "deferring cache 025 conversation normalization (#294 S6)"
4992
- )
5240
+ held = _acquire_cache_db_codex_provider_flock(
5241
+ conn, migration="025 conversation normalization (#294 S6)"
5242
+ )
4993
5243
  try:
4994
5244
  conn.execute("BEGIN IMMEDIATE")
4995
5245
  try:
@@ -5003,12 +5253,7 @@ def _025_codex_conversation_normalization(conn: sqlite3.Connection) -> None:
5003
5253
  conn.rollback()
5004
5254
  raise
5005
5255
  finally:
5006
- if lock_fh is not None:
5007
- try:
5008
- fcntl.flock(lock_fh, fcntl.LOCK_UN)
5009
- except OSError:
5010
- pass
5011
- lock_fh.close()
5256
+ _release_cache_db_writer_flocks(held)
5012
5257
 
5013
5258
 
5014
5259
  @cache_migration("026_codex_conversation_key_backfill")
@@ -5022,25 +5267,17 @@ def _026_codex_conversation_key_backfill(conn: sqlite3.Connection) -> None:
5022
5267
  clear helper with ``sync_codex_cache(rebuild=True)`` so new or future Codex
5023
5268
  families cannot drift from the migration's destructive scope.
5024
5269
 
5025
- The Codex flock is acquired before ``BEGIN IMMEDIATE``. Contention defers
5026
- through ``MigrationGateNotMet`` before any DML; after a handler/data commit
5027
- but before the dispatcher's central stamp, a rerun against the empty state
5028
- is a byte-idempotent no-op. The physical mutation sequence advances only
5029
- when the shared clear actually changed persisted Codex state. The handler
5030
- never self-stamps.
5270
+ The cache dispatcher holds maintenance + global writer exclusion; this
5271
+ handler then takes the Codex flock before ``BEGIN IMMEDIATE``. Contention
5272
+ defers through ``MigrationGateNotMet``
5273
+ before any DML; after a handler/data commit but before the dispatcher's
5274
+ central stamp, a rerun against the empty state is a byte-idempotent no-op.
5275
+ The physical mutation sequence advances only when the shared clear actually
5276
+ changed persisted Codex state. The handler never self-stamps.
5031
5277
  """
5032
- lock_path = _cache_db_codex_lock_path_for_conn(conn)
5033
- lock_fh = None
5034
- if lock_path is not None:
5035
- lock_fh = open(lock_path, "w")
5036
- try:
5037
- fcntl.flock(lock_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
5038
- except BlockingIOError:
5039
- lock_fh.close()
5040
- raise MigrationGateNotMet(
5041
- "cache.db.codex.lock held by a concurrent sync_codex_cache; "
5042
- "deferring cache 026 conversation-key backfill (#312)"
5043
- )
5278
+ held = _acquire_cache_db_codex_provider_flock(
5279
+ conn, migration="026 conversation-key backfill (#312)"
5280
+ )
5044
5281
  try:
5045
5282
  conn.execute("BEGIN IMMEDIATE")
5046
5283
  try:
@@ -5053,12 +5290,7 @@ def _026_codex_conversation_key_backfill(conn: sqlite3.Connection) -> None:
5053
5290
  conn.rollback()
5054
5291
  raise
5055
5292
  finally:
5056
- if lock_fh is not None:
5057
- try:
5058
- fcntl.flock(lock_fh, fcntl.LOCK_UN)
5059
- except OSError:
5060
- pass
5061
- lock_fh.close()
5293
+ _release_cache_db_writer_flocks(held)
5062
5294
 
5063
5295
 
5064
5296
  @cache_migration("027_codex_fork_preamble_rebuild")
@@ -5072,24 +5304,17 @@ def _027_codex_fork_preamble_rebuild(conn: sqlite3.Connection) -> None:
5072
5304
  remain authoritative, so clear all re-derivable Codex families and let the
5073
5305
  corrected fused parser replay them from byte zero.
5074
5306
 
5075
- The Codex flock precedes ``BEGIN IMMEDIATE``. Contention defers before DML;
5076
- a markerless retry against already-cleared state is a no-op. The shared
5077
- clear helper keeps this migration aligned with runtime rebuild scope and
5078
- advances the physical mutation sequence only when state actually changed.
5079
- The dispatcher owns the migration marker.
5307
+ The cache dispatcher holds maintenance + global writer exclusion; this
5308
+ handler then takes the Codex flock before ``BEGIN IMMEDIATE``. Contention
5309
+ defers before DML; a markerless retry
5310
+ against already-cleared state is a no-op. The shared clear helper keeps this
5311
+ migration aligned with runtime rebuild scope and advances the physical
5312
+ mutation sequence only when state actually changed. The dispatcher owns the
5313
+ migration marker.
5080
5314
  """
5081
- lock_path = _cache_db_codex_lock_path_for_conn(conn)
5082
- lock_fh = None
5083
- if lock_path is not None:
5084
- lock_fh = open(lock_path, "w")
5085
- try:
5086
- fcntl.flock(lock_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
5087
- except BlockingIOError:
5088
- lock_fh.close()
5089
- raise MigrationGateNotMet(
5090
- "cache.db.codex.lock held by a concurrent sync_codex_cache; "
5091
- "deferring cache 027 fork-preamble rebuild"
5092
- )
5315
+ held = _acquire_cache_db_codex_provider_flock(
5316
+ conn, migration="027 fork-preamble rebuild"
5317
+ )
5093
5318
  try:
5094
5319
  conn.execute("BEGIN IMMEDIATE")
5095
5320
  try:
@@ -5102,12 +5327,7 @@ def _027_codex_fork_preamble_rebuild(conn: sqlite3.Connection) -> None:
5102
5327
  conn.rollback()
5103
5328
  raise
5104
5329
  finally:
5105
- if lock_fh is not None:
5106
- try:
5107
- fcntl.flock(lock_fh, fcntl.LOCK_UN)
5108
- except OSError:
5109
- pass
5110
- lock_fh.close()
5330
+ _release_cache_db_writer_flocks(held)
5111
5331
 
5112
5332
 
5113
5333
  @cache_migration("028_split_conversation_store")
@@ -5118,14 +5338,14 @@ def _028_split_conversation_store(conn: sqlite3.Connection) -> None:
5118
5338
  the independent current-schema store, arms provider-local byte-zero replay,
5119
5339
  then drops the legacy transcript tables from the hot cache. Compact Codex
5120
5340
  thread metadata stays in cache.db because accounting/project attribution
5121
- joins it directly.
5341
+ joins it directly. The dispatcher already holds cache maintenance exclusive
5342
+ + the global writer flock; this handler takes the remaining conversation
5343
+ maintenance and provider flocks in total lock order.
5122
5344
  """
5123
5345
  core = _cctally_core
5124
5346
  lock_paths = (
5125
- core.CACHE_LOCK_MAINTENANCE_PATH,
5126
- core.CACHE_LOCK_PATH,
5127
- core.CACHE_LOCK_CODEX_PATH,
5128
5347
  core.CONVERSATIONS_LOCK_MAINTENANCE_PATH,
5348
+ core.CACHE_LOCK_CODEX_PATH,
5129
5349
  core.CONVERSATIONS_LOCK_PATH,
5130
5350
  core.CONVERSATIONS_LOCK_CODEX_PATH,
5131
5351
  )
@@ -5222,6 +5442,62 @@ def _028_split_conversation_store(conn: sqlite3.Connection) -> None:
5222
5442
  fh.close()
5223
5443
 
5224
5444
 
5445
+ @cache_migration("029_backfill_claude_account")
5446
+ def _029_backfill_claude_account(conn: sqlite3.Connection) -> None:
5447
+ """Backfill Claude ``session_entries`` / ``session_files`` ``account_key``
5448
+ from the cutover op's recorded account (#341, spec §2 cache.db).
5449
+
5450
+ The ``account_key`` columns themselves are added by ``_apply_cache_schema``
5451
+ (CREATE-time + ``add_column_if_missing`` — the repo's column-addition rule),
5452
+ so they are present in BOTH pre and post; this migration bumps the cache head
5453
+ so an existing install re-runs that schema apply AND performs the one-time
5454
+ Claude row backfill. It reads ONLY the journal cutover op (``never re-reads
5455
+ auth`` — spec §2 epoch transition): Claude legacy rows attribute to the op's
5456
+ recorded account; Codex rows stay NULL (Q2). ``NULL ≡ unattributed`` on the
5457
+ read path, so a single-account / no-identity install is byte-stable.
5458
+
5459
+ Ordering: the cutover op is appended by the stats epoch transition, which may
5460
+ run AFTER this cache open. So when the op is ABSENT but legacy Claude rows
5461
+ exist, DEFER (``MigrationGateNotMet``) — the op appears on a later open and
5462
+ this backfill runs then. An op recording the ``unattributed`` sentinel is a
5463
+ resolved (not pending) outcome → no-op (leave NULL). Idempotent: only touches
5464
+ ``account_key IS NULL`` rows. Central stamp via the dispatcher (#140); the
5465
+ handler does NOT self-stamp.
5466
+ """
5467
+ import _cctally_journal as _jr
5468
+ import _lib_accounts as _acc
5469
+
5470
+ op_value = _jr.find_accounts_cutover_op()
5471
+ if op_value is None:
5472
+ # Cutover hasn't appended its op yet. Defer only if there is legacy Claude
5473
+ # history to attribute; a fresh install (no such rows) proceeds as a no-op.
5474
+ pending = conn.execute(
5475
+ "SELECT 1 FROM session_entries WHERE account_key IS NULL LIMIT 1"
5476
+ ).fetchone()
5477
+ if pending is not None:
5478
+ raise MigrationGateNotMet(
5479
+ "accounts cutover op not yet appended; deferring Claude account "
5480
+ "backfill until the epoch transition records it"
5481
+ )
5482
+ return
5483
+ if op_value == _acc.UNATTRIBUTED:
5484
+ return # resolved unattributed — leave NULL (== unattributed)
5485
+ conn.execute("BEGIN IMMEDIATE")
5486
+ try:
5487
+ conn.execute(
5488
+ "UPDATE session_entries SET account_key=? WHERE account_key IS NULL",
5489
+ (op_value,),
5490
+ )
5491
+ conn.execute(
5492
+ "UPDATE session_files SET account_key=? WHERE account_key IS NULL",
5493
+ (op_value,),
5494
+ )
5495
+ conn.commit()
5496
+ except Exception:
5497
+ conn.rollback()
5498
+ raise
5499
+
5500
+
5225
5501
  # === Region 7d: Stats migration 008_recompute_weekly_cost_snapshots_dedup_fix ===
5226
5502
 
5227
5503
  @stats_migration("008_recompute_weekly_cost_snapshots_dedup_fix")
@@ -6499,6 +6775,33 @@ def _db_path_for_label(db_label: str) -> pathlib.Path:
6499
6775
  raise ValueError(f"unknown db_label: {db_label}")
6500
6776
 
6501
6777
 
6778
+ def _acquire_cache_admin_writer_flocks(
6779
+ db_label: str,
6780
+ *,
6781
+ timeout: float = 15.0,
6782
+ ) -> "list[int] | None":
6783
+ """Serialize raw cache admin mutations without affecting other stores."""
6784
+ if db_label != "cache.db":
6785
+ return []
6786
+ from _lib_cache_writer_lock import acquire_ordered_flocks
6787
+
6788
+ return acquire_ordered_flocks(
6789
+ [
6790
+ (_cctally_core.CACHE_LOCK_MAINTENANCE_PATH, fcntl.LOCK_SH),
6791
+ (_cctally_core.CACHE_LOCK_PATH, fcntl.LOCK_EX),
6792
+ ],
6793
+ timeout=timeout,
6794
+ )
6795
+
6796
+
6797
+ def _release_cache_admin_writer_flocks(held: list[int]) -> None:
6798
+ if not held:
6799
+ return
6800
+ from _lib_cache_writer_lock import release_cache_writer_flocks
6801
+
6802
+ release_cache_writer_flocks(held)
6803
+
6804
+
6502
6805
  def cmd_db_skip(args: argparse.Namespace) -> int:
6503
6806
  """Mark a migration as skipped.
6504
6807
 
@@ -6524,7 +6827,15 @@ def cmd_db_skip(args: argparse.Namespace) -> int:
6524
6827
  # ~/.local/share/cctally/ yet, and sqlite3.connect() does NOT
6525
6828
  # create parent directories (only the DB file itself).
6526
6829
  path.parent.mkdir(parents=True, exist_ok=True)
6527
- conn = sqlite3.connect(path)
6830
+ held = _acquire_cache_admin_writer_flocks(db_label)
6831
+ if held is None:
6832
+ eprint("cctally: cache.db writer busy; could not record migration skip.")
6833
+ return 3
6834
+ try:
6835
+ conn = sqlite3.connect(path)
6836
+ except BaseException:
6837
+ _release_cache_admin_writer_flocks(held)
6838
+ raise
6528
6839
  try:
6529
6840
  conn.execute(
6530
6841
  """
@@ -6571,6 +6882,7 @@ def cmd_db_skip(args: argparse.Namespace) -> int:
6571
6882
  conn.commit()
6572
6883
  finally:
6573
6884
  conn.close()
6885
+ _release_cache_admin_writer_flocks(held)
6574
6886
  print(f"Skipped: {name}")
6575
6887
  return 0
6576
6888
 
@@ -6609,7 +6921,15 @@ def cmd_db_unskip(args: argparse.Namespace) -> int:
6609
6921
  if not path.exists():
6610
6922
  print(f"cctally: {name} is not skipped; nothing to do.")
6611
6923
  return 0
6612
- conn = sqlite3.connect(path)
6924
+ held = _acquire_cache_admin_writer_flocks(db_label)
6925
+ if held is None:
6926
+ eprint("cctally: cache.db writer busy; could not remove migration skip.")
6927
+ return 3
6928
+ try:
6929
+ conn = sqlite3.connect(path)
6930
+ except BaseException:
6931
+ _release_cache_admin_writer_flocks(held)
6932
+ raise
6613
6933
  try:
6614
6934
  try:
6615
6935
  cur = conn.execute(
@@ -6627,6 +6947,7 @@ def cmd_db_unskip(args: argparse.Namespace) -> int:
6627
6947
  conn.commit()
6628
6948
  finally:
6629
6949
  conn.close()
6950
+ _release_cache_admin_writer_flocks(held)
6630
6951
  print(f"Unskipped: {name} (will run on next open).")
6631
6952
  return 0
6632
6953
 
@@ -6665,7 +6986,15 @@ def cmd_db_recover(args: argparse.Namespace) -> int:
6665
6986
  print(f"cctally: {label} not present; nothing to recover.")
6666
6987
  return 0
6667
6988
 
6668
- conn = sqlite3.connect(path)
6989
+ held = _acquire_cache_admin_writer_flocks(label)
6990
+ if held is None:
6991
+ eprint("cctally: cache.db writer busy; could not recover schema state.")
6992
+ return 3
6993
+ try:
6994
+ conn = sqlite3.connect(path)
6995
+ except BaseException:
6996
+ _release_cache_admin_writer_flocks(held)
6997
+ raise
6669
6998
  try:
6670
6999
  cur_version = conn.execute("PRAGMA user_version").fetchone()[0]
6671
7000
  head = len(registry)
@@ -6688,6 +7017,7 @@ def cmd_db_recover(args: argparse.Namespace) -> int:
6688
7017
  return 0
6689
7018
  finally:
6690
7019
  conn.close()
7020
+ _release_cache_admin_writer_flocks(held)
6691
7021
 
6692
7022
 
6693
7023
  def _db_backup_timestamp() -> str:
@@ -6698,6 +7028,16 @@ def _repair_marker_path(path: pathlib.Path) -> pathlib.Path:
6698
7028
  return path.with_name(f"{path.name}.repairing")
6699
7029
 
6700
7030
 
7031
+ _REPAIR_OWNER_VERSION = 1
7032
+ _REPAIR_OWNER_UNKNOWN_IDENTITY_LEASE_SECONDS = 30 * 60
7033
+
7034
+
7035
+ @dataclass(frozen=True)
7036
+ class RepairMarkerClaim:
7037
+ marker: pathlib.Path
7038
+ claim_id: str
7039
+
7040
+
6701
7041
  def _pid_is_alive(pid: int) -> bool:
6702
7042
  if pid <= 0:
6703
7043
  return False
@@ -6710,40 +7050,177 @@ def _pid_is_alive(pid: int) -> bool:
6710
7050
  return True
6711
7051
 
6712
7052
 
6713
- def _claim_repair_marker(path: pathlib.Path) -> "tuple[bool, str]":
6714
- """Atomically block new cctally stats opens; reclaim dead-owner markers."""
7053
+ def _process_start_identity(pid: int) -> "str | None":
7054
+ """Return a kernel-derived process-start identity for PID-reuse defense."""
7055
+ if pid <= 0:
7056
+ return None
7057
+ proc_stat = pathlib.Path(f"/proc/{pid}/stat")
7058
+ try:
7059
+ raw = proc_stat.read_text()
7060
+ # Field 2 is parenthesized and may contain spaces. Fields after the
7061
+ # final ')' begin at field 3; process starttime is field 22.
7062
+ tail = raw.rsplit(")", 1)[1].strip().split()
7063
+ if len(tail) >= 20:
7064
+ return f"proc:{tail[19]}"
7065
+ except (OSError, IndexError):
7066
+ pass
7067
+ try:
7068
+ result = subprocess.run(
7069
+ ["ps", "-o", "lstart=", "-p", str(pid)],
7070
+ stdout=subprocess.PIPE,
7071
+ stderr=subprocess.DEVNULL,
7072
+ text=True,
7073
+ check=False,
7074
+ env={**os.environ, "LC_ALL": "C"},
7075
+ )
7076
+ except OSError:
7077
+ return None
7078
+ started = " ".join(result.stdout.split())
7079
+ return f"ps:{started}" if result.returncode == 0 and started else None
7080
+
7081
+
7082
+ def _encode_repair_owner(
7083
+ *,
7084
+ pid: int,
7085
+ process_start: str,
7086
+ claim_id: str,
7087
+ created_ns: "int | None" = None,
7088
+ ) -> str:
7089
+ return json.dumps(
7090
+ {
7091
+ "version": _REPAIR_OWNER_VERSION,
7092
+ "pid": pid,
7093
+ "process_start": process_start,
7094
+ "claim_id": claim_id,
7095
+ "created_ns": time.time_ns() if created_ns is None else created_ns,
7096
+ },
7097
+ sort_keys=True,
7098
+ separators=(",", ":"),
7099
+ ) + "\n"
7100
+
7101
+
7102
+ def _read_repair_owner(marker: pathlib.Path) -> "dict[str, Any] | None":
7103
+ try:
7104
+ raw = marker.read_text().strip()
7105
+ except OSError:
7106
+ return None
7107
+ def legacy_owner() -> "dict[str, Any] | None":
7108
+ # Legacy markers contained only a PID. They remain live for a bounded
7109
+ # lease when that PID exists, preventing a reused PID from wedging the
7110
+ # database forever.
7111
+ try:
7112
+ return {
7113
+ "version": 0,
7114
+ "pid": int(raw),
7115
+ "process_start": None,
7116
+ "claim_id": None,
7117
+ }
7118
+ except ValueError:
7119
+ return None
7120
+ try:
7121
+ decoded = json.loads(raw)
7122
+ except (json.JSONDecodeError, TypeError):
7123
+ return legacy_owner()
7124
+ if not isinstance(decoded, dict):
7125
+ return legacy_owner()
7126
+ if (
7127
+ decoded.get("version") != _REPAIR_OWNER_VERSION
7128
+ or not isinstance(decoded.get("pid"), int)
7129
+ or not isinstance(decoded.get("process_start"), str)
7130
+ or not decoded["process_start"]
7131
+ or not isinstance(decoded.get("claim_id"), str)
7132
+ or not decoded["claim_id"]
7133
+ ):
7134
+ return None
7135
+ return decoded
7136
+
7137
+
7138
+ def _repair_marker_is_live(marker: pathlib.Path) -> "tuple[bool, str]":
7139
+ owner = _read_repair_owner(marker)
7140
+ if owner is None:
7141
+ return False, "malformed owner record"
7142
+ pid = int(owner["pid"])
7143
+ if not _pid_is_alive(pid):
7144
+ return False, f"owner pid {pid} is not running"
7145
+ expected_start = owner.get("process_start")
7146
+ if expected_start:
7147
+ actual_start = _process_start_identity(pid)
7148
+ if actual_start is not None:
7149
+ if actual_start == expected_start:
7150
+ return True, f"pid {pid}"
7151
+ return False, f"pid {pid} was reused"
7152
+ try:
7153
+ age_seconds = max(0.0, time.time() - marker.stat().st_mtime)
7154
+ except OSError:
7155
+ return False, "owner record disappeared"
7156
+ if age_seconds <= _REPAIR_OWNER_UNKNOWN_IDENTITY_LEASE_SECONDS:
7157
+ return True, f"pid {pid} (identity unavailable; bounded lease)"
7158
+ return False, f"pid {pid} owner identity lease expired"
7159
+
7160
+
7161
+ def _remove_stale_repair_marker(path: pathlib.Path) -> "tuple[bool, str]":
7162
+ """Remove a provably stale marker; the caller owns maintenance-exclusive."""
7163
+ marker = _repair_marker_path(path)
7164
+ if not marker.exists():
7165
+ return True, ""
7166
+ live, reason = _repair_marker_is_live(marker)
7167
+ if live:
7168
+ return False, f"another {path.name} repair owns {marker} ({reason})"
7169
+ try:
7170
+ marker.unlink()
7171
+ except FileNotFoundError:
7172
+ return True, ""
7173
+ except OSError as exc:
7174
+ return False, f"could not remove stale repair marker {marker}: {exc}"
7175
+ _fsync_directory(marker.parent)
7176
+ return True, reason
7177
+
7178
+
7179
+ def _claim_repair_marker(
7180
+ path: pathlib.Path,
7181
+ ) -> "tuple[RepairMarkerClaim | None, str]":
7182
+ """Atomically publish a complete owner record; reclaim stale ownership."""
6715
7183
  marker = _repair_marker_path(path)
6716
- for _attempt in range(2):
7184
+ process_start = _process_start_identity(os.getpid())
7185
+ if process_start is None:
7186
+ raise OSError("could not establish repair-owner process identity")
7187
+ for _attempt in range(3):
7188
+ claim_id = f"{os.getpid()}-{time.time_ns()}-{os.urandom(8).hex()}"
7189
+ temp = marker.with_name(f".{marker.name}.{claim_id}.tmp")
7190
+ payload = _encode_repair_owner(
7191
+ pid=os.getpid(),
7192
+ process_start=process_start,
7193
+ claim_id=claim_id,
7194
+ )
7195
+ fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
7196
+ try:
7197
+ os.write(fd, payload.encode("utf-8"))
7198
+ os.fsync(fd)
7199
+ finally:
7200
+ os.close(fd)
6717
7201
  try:
6718
- fd = os.open(marker, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
6719
- except FileExistsError:
6720
7202
  try:
6721
- owner = int(marker.read_text().strip())
6722
- except (OSError, ValueError):
6723
- owner = -1
6724
- if _pid_is_alive(owner):
6725
- return False, (
6726
- f"another {path.name} repair owns {marker} (pid {owner})"
6727
- )
7203
+ os.link(temp, marker)
7204
+ except FileExistsError:
7205
+ removed, reason = _remove_stale_repair_marker(path)
7206
+ if not removed:
7207
+ return None, reason
7208
+ continue
7209
+ _fsync_directory(marker.parent)
7210
+ return RepairMarkerClaim(marker=marker, claim_id=claim_id), ""
7211
+ finally:
6728
7212
  try:
6729
- marker.unlink()
7213
+ temp.unlink()
6730
7214
  except FileNotFoundError:
6731
7215
  pass
6732
- except OSError as exc:
6733
- return False, f"could not remove stale repair marker {marker}: {exc}"
6734
- continue
6735
- try:
6736
- os.write(fd, f"{os.getpid()}\n".encode("ascii"))
6737
- os.fsync(fd)
6738
- finally:
6739
- os.close(fd)
6740
- _fsync_directory(marker.parent)
6741
- return True, ""
6742
- return False, f"could not claim repair marker {marker}"
7216
+ return None, f"could not claim repair marker {marker}"
6743
7217
 
6744
7218
 
6745
- def _release_repair_marker(path: pathlib.Path) -> None:
7219
+ def _release_repair_marker(path: pathlib.Path, claim: RepairMarkerClaim) -> None:
6746
7220
  marker = _repair_marker_path(path)
7221
+ owner = _read_repair_owner(marker)
7222
+ if owner is not None and owner.get("claim_id") != claim.claim_id:
7223
+ raise OSError(f"repair marker ownership changed before cleanup: {marker}")
6747
7224
  try:
6748
7225
  marker.unlink()
6749
7226
  except FileNotFoundError:
@@ -7031,11 +7508,11 @@ def cmd_db_repair(args: argparse.Namespace) -> int:
7031
7508
  return 2
7032
7509
 
7033
7510
  try:
7034
- claimed, reason = _claim_repair_marker(path)
7511
+ claim, reason = _claim_repair_marker(path)
7035
7512
  except OSError as exc:
7036
7513
  eprint(f"cctally: could not create stats.db repair marker: {exc}")
7037
7514
  return 3
7038
- if not claimed:
7515
+ if claim is None:
7039
7516
  eprint(f"cctally: {reason}")
7040
7517
  return 3
7041
7518
  try:
@@ -7047,7 +7524,7 @@ def cmd_db_repair(args: argparse.Namespace) -> int:
7047
7524
  eprint(f"cctally: unexpected stats.db repair failure: {exc}")
7048
7525
  rc = 3
7049
7526
  try:
7050
- _release_repair_marker(path)
7527
+ _release_repair_marker(path, claim)
7051
7528
  except OSError as exc:
7052
7529
  eprint(
7053
7530
  f"cctally: stats.db repair marker cleanup failed ({exc}); "
@@ -7381,9 +7858,12 @@ def cmd_db_checkpoint(args: argparse.Namespace) -> int:
7381
7858
  the main DB and truncates the -wal file — it changes no data, no schema, no
7382
7859
  user_version — so there is no prod guard and no --yes.
7383
7860
 
7384
- Exit 0 when drained / already-small / the DB is absent; 3 (staged) if the
7385
- target stayed busy or was not fully truncated through the timeout an
7386
- actionable "something is still holding it" signal.
7861
+ A cache checkpoint takes the same global writer flock as both provider
7862
+ syncs, using the command's timeout as one bounded wait, so it cannot overlap
7863
+ a cache write or another cache checkpoint. Exit 0 when drained /
7864
+ already-small / the DB is absent; 3 (staged) if the target stayed busy or
7865
+ was not fully truncated through the timeout — an actionable "something is
7866
+ still holding it" signal.
7387
7867
  """
7388
7868
  import _cctally_cache
7389
7869
  from _lib_json_envelope import stamp_schema_version
@@ -7410,12 +7890,39 @@ def cmd_db_checkpoint(args: argparse.Namespace) -> int:
7410
7890
  print(f"cctally: no {label} database file present; nothing to drain.")
7411
7891
  return 0
7412
7892
 
7413
- conn = sqlite3.connect(f"file:{path}?mode=rw", uri=True)
7893
+ held: list[int] = []
7414
7894
  try:
7415
- conn.execute(f"PRAGMA busy_timeout={timeout}")
7416
- result = _cctally_cache._run_wal_truncate(conn, path, db_label=label)
7895
+ if which == "cache":
7896
+ from _lib_cache_writer_lock import acquire_ordered_flocks
7897
+
7898
+ acquired = acquire_ordered_flocks(
7899
+ [
7900
+ (_cctally_core.CACHE_LOCK_MAINTENANCE_PATH, fcntl.LOCK_SH),
7901
+ (_cctally_core.CACHE_LOCK_PATH, fcntl.LOCK_EX),
7902
+ ],
7903
+ timeout=max(timeout, 0) / 1000,
7904
+ )
7905
+ if acquired is None:
7906
+ wal_bytes = _cctally_cache._wal_file_size(path)
7907
+ result = _cctally_cache.CheckpointResult(
7908
+ label, wal_bytes, wal_bytes, 0, True, False
7909
+ )
7910
+ else:
7911
+ held = acquired
7912
+ if which != "cache" or held:
7913
+ conn = sqlite3.connect(f"file:{path}?mode=rw", uri=True)
7914
+ try:
7915
+ conn.execute(f"PRAGMA busy_timeout={timeout}")
7916
+ result = _cctally_cache._run_wal_truncate(
7917
+ conn, path, db_label=label
7918
+ )
7919
+ finally:
7920
+ conn.close()
7417
7921
  finally:
7418
- conn.close()
7922
+ if held:
7923
+ from _lib_cache_writer_lock import release_cache_writer_flocks
7924
+
7925
+ release_cache_writer_flocks(held)
7419
7926
 
7420
7927
  if as_json:
7421
7928
  payload = {"db": result.db, "walBytesBefore": result.wal_bytes_before,