cctally 1.88.2 → 1.89.1

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 (37) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/bin/_cctally_cache.py +827 -37
  3. package/bin/_cctally_config.py +125 -0
  4. package/bin/_cctally_core.py +86 -2
  5. package/bin/_cctally_dashboard_cache_report.py +31 -0
  6. package/bin/_cctally_dashboard_conversation.py +26 -7
  7. package/bin/_cctally_dashboard_sources.py +51 -0
  8. package/bin/_cctally_db.py +626 -0
  9. package/bin/_cctally_doctor.py +84 -1
  10. package/bin/_cctally_journal.py +2 -1
  11. package/bin/_cctally_parser.py +42 -0
  12. package/bin/_cctally_quota.py +1358 -112
  13. package/bin/_cctally_record.py +249 -9
  14. package/bin/_cctally_setup.py +14 -5
  15. package/bin/_cctally_store.py +16 -1
  16. package/bin/_cctally_tui.py +16 -2
  17. package/bin/_cctally_update.py +9 -2
  18. package/bin/_lib_background_mcp.py +168 -0
  19. package/bin/_lib_cache_report.py +19 -2
  20. package/bin/_lib_codex_conversation.py +8 -0
  21. package/bin/_lib_codex_conversation_query.py +8 -7
  22. package/bin/_lib_conversation.py +105 -5
  23. package/bin/_lib_conversation_dispatch.py +15 -4
  24. package/bin/_lib_conversation_query.py +294 -2
  25. package/bin/_lib_dashboard_sources.py +5 -1
  26. package/bin/_lib_doctor.py +202 -1
  27. package/bin/_lib_jsonl.py +12 -0
  28. package/bin/_lib_quota_alert_axes.py +188 -0
  29. package/bin/_lib_quota_ledger.py +274 -0
  30. package/bin/_lib_snapshot_cache.py +36 -0
  31. package/bin/cctally +6 -3
  32. package/dashboard/static/assets/index-BgoYXdus.js +92 -0
  33. package/dashboard/static/assets/index-Ub8vwz1M.css +1 -0
  34. package/dashboard/static/dashboard.html +2 -2
  35. package/package.json +4 -1
  36. package/dashboard/static/assets/index-B0ZCsoxI.css +0 -1
  37. package/dashboard/static/assets/index-Bvp8mxtz.js +0 -92
@@ -3100,6 +3100,259 @@ def _is_transient_sqlite_error(exc: sqlite3.OperationalError) -> bool:
3100
3100
  return False
3101
3101
 
3102
3102
 
3103
+ # Public #5: every column of ``quota_window_snapshots`` that feeds INTERPRETATION.
3104
+ # The UPDATE trigger fires ``AFTER UPDATE OF`` exactly this list, and the list is
3105
+ # the mechanism's one silent-skip hazard: a column omitted here lets its mutation
3106
+ # commit while the ledger stays quiet, so the projector never learns the window
3107
+ # is dirty and the stale block survives indefinitely.
3108
+ #
3109
+ # ``source`` is included because it is the trigger's own scope predicate — a row
3110
+ # flipped from 'claude' to 'codex' mints a Codex observation, and without this
3111
+ # entry no trigger would fire for it.
3112
+ #
3113
+ # ``source_path`` / ``line_offset`` are included even though no writer SETs them
3114
+ # today (ingest INSERTs OR IGNOREs; requalification DELETEs and re-INSERTs, both
3115
+ # ledgered; the migrations that UPDATE this table touch only ``observed_model``
3116
+ # and ``canonical_resets_at_utc``). They feed
3117
+ # ``quota_window_blocks.last_source_path``/``last_line_offset``, milestone
3118
+ # provenance and the per-group digest, so a future in-place rewrite of either
3119
+ # would be a silent skip. ``AFTER UPDATE OF`` fires on the statement's SET list,
3120
+ # so listing a column nobody sets costs exactly zero ledger rows — this buys the
3121
+ # removal of a whole hazard class for nothing.
3122
+ #
3123
+ # The one column NOT here is ``id``: the AUTOINCREMENT surrogate key, which no
3124
+ # interpretation reads.
3125
+ _QUOTA_WINDOW_SEMANTIC_COLUMNS = (
3126
+ "source",
3127
+ "source_root_key",
3128
+ "source_path",
3129
+ "line_offset",
3130
+ "logical_limit_key",
3131
+ "observed_slot",
3132
+ "window_minutes",
3133
+ "resets_at_utc",
3134
+ "canonical_resets_at_utc",
3135
+ "captured_at_utc",
3136
+ "used_percent",
3137
+ "limit_id",
3138
+ "limit_name",
3139
+ "plan_type",
3140
+ "individual_limit_json",
3141
+ "reached_type",
3142
+ "observed_model",
3143
+ "account_key",
3144
+ )
3145
+
3146
+ # The physical group coordinates a ledger entry records, in row-image order.
3147
+ _QUOTA_WINDOW_GROUP_COLUMNS = (
3148
+ "source_root_key",
3149
+ "logical_limit_key",
3150
+ "observed_slot",
3151
+ "window_minutes",
3152
+ "resets_at_utc",
3153
+ "canonical_resets_at_utc",
3154
+ )
3155
+
3156
+
3157
+ def _codex_quota_ledger_ddl() -> tuple[str, ...]:
3158
+ """DDL for the Codex quota change ledger and its three triggers.
3159
+
3160
+ Public #5 spec §1. The ledger records, per mutation, the RAW physical group
3161
+ coordinates of the affected rows — for the old row image, the new one, or
3162
+ both. The projector expands those coordinates to the group's complete
3163
+ current membership and re-interprets it through the existing Python read
3164
+ path.
3165
+
3166
+ The triggers must NEVER compute an interpreted key. Interpretation snaps a
3167
+ jittered ``window_minutes``, rewrites ``logical_limit_key`` from
3168
+ ``observed_model``, and folds the account over the window's whole
3169
+ population — the last of which is population-dependent and cannot be
3170
+ expressed per-row in SQL at all. Keeping exactly one implementation of the
3171
+ interpretation rules, in Python, is the point of recording raw scope.
3172
+
3173
+ Triggers rather than writer discipline is also the point: the entry commits
3174
+ in the same transaction as the mutation, so ordinary migration DML and
3175
+ manual repair are captured automatically, with no rule for a future author
3176
+ to remember.
3177
+
3178
+ ``seq`` is AUTOINCREMENT, not a bare rowid alias: the projector's watermark
3179
+ needs a value that is never reused, or a pruned ledger could reissue a seq
3180
+ at or below the watermark and that entry would be skipped forever.
3181
+
3182
+ The three triggers are DROPped before they are created, while the table
3183
+ keeps ``IF NOT EXISTS``. A trigger is stateless, so re-creating it costs
3184
+ nothing and loses nothing; ``CREATE TRIGGER IF NOT EXISTS`` alone would
3185
+ silently keep an older body after ``_QUOTA_WINDOW_SEMANTIC_COLUMNS`` grows,
3186
+ and a stale ``UPDATE OF`` list is exactly the silent-skip this mechanism
3187
+ exists to remove. The ledger ROWS are never dropped.
3188
+ """
3189
+ old_cols = ", ".join(f"old_{name}" for name in _QUOTA_WINDOW_GROUP_COLUMNS)
3190
+ new_cols = ", ".join(f"new_{name}" for name in _QUOTA_WINDOW_GROUP_COLUMNS)
3191
+ old_vals = ", ".join(f"OLD.{name}" for name in _QUOTA_WINDOW_GROUP_COLUMNS)
3192
+ new_vals = ", ".join(f"NEW.{name}" for name in _QUOTA_WINDOW_GROUP_COLUMNS)
3193
+ update_of = ", ".join(_QUOTA_WINDOW_SEMANTIC_COLUMNS)
3194
+ return (
3195
+ """
3196
+ CREATE TABLE IF NOT EXISTS quota_window_change_log (
3197
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
3198
+ op TEXT NOT NULL
3199
+ CHECK(op IN ('insert','delete','update')),
3200
+ old_source_root_key TEXT,
3201
+ old_logical_limit_key TEXT,
3202
+ old_observed_slot TEXT,
3203
+ old_window_minutes INTEGER,
3204
+ old_resets_at_utc TEXT,
3205
+ old_canonical_resets_at_utc TEXT,
3206
+ new_source_root_key TEXT,
3207
+ new_logical_limit_key TEXT,
3208
+ new_observed_slot TEXT,
3209
+ new_window_minutes INTEGER,
3210
+ new_resets_at_utc TEXT,
3211
+ new_canonical_resets_at_utc TEXT
3212
+ )
3213
+ """,
3214
+ "DROP TRIGGER IF EXISTS trg_qws_ledger_ins",
3215
+ f"""
3216
+ CREATE TRIGGER trg_qws_ledger_ins
3217
+ AFTER INSERT ON quota_window_snapshots
3218
+ WHEN NEW.source = 'codex'
3219
+ BEGIN
3220
+ INSERT INTO quota_window_change_log (op, {new_cols})
3221
+ VALUES ('insert', {new_vals});
3222
+ END
3223
+ """,
3224
+ "DROP TRIGGER IF EXISTS trg_qws_ledger_del",
3225
+ f"""
3226
+ CREATE TRIGGER trg_qws_ledger_del
3227
+ AFTER DELETE ON quota_window_snapshots
3228
+ WHEN OLD.source = 'codex'
3229
+ BEGIN
3230
+ INSERT INTO quota_window_change_log (op, {old_cols})
3231
+ VALUES ('delete', {old_vals});
3232
+ END
3233
+ """,
3234
+ "DROP TRIGGER IF EXISTS trg_qws_ledger_upd",
3235
+ f"""
3236
+ CREATE TRIGGER trg_qws_ledger_upd
3237
+ AFTER UPDATE OF {update_of} ON quota_window_snapshots
3238
+ WHEN OLD.source = 'codex' OR NEW.source = 'codex'
3239
+ BEGIN
3240
+ INSERT INTO quota_window_change_log (op, {old_cols}, {new_cols})
3241
+ VALUES ('update', {old_vals}, {new_vals});
3242
+ END
3243
+ """,
3244
+ )
3245
+
3246
+
3247
+ #: The physical group's five members, in seek order, plus the expression the
3248
+ #: reader actually matches on. ``unixepoch(COALESCE(...))`` is indexed VERBATIM
3249
+ #: because SQLite only uses an expression index when the query's expression
3250
+ #: matches the indexed one — indexing the bare ``COALESCE`` while the reader
3251
+ #: wraps it in ``unixepoch`` silently buys nothing.
3252
+ _QUOTA_GROUP_INDEX_DDL = (
3253
+ "CREATE INDEX IF NOT EXISTS idx_qws_physical_group "
3254
+ "ON quota_window_snapshots("
3255
+ " source_root_key, logical_limit_key, observed_slot, window_minutes,"
3256
+ " unixepoch(COALESCE(canonical_resets_at_utc, resets_at_utc)))"
3257
+ " WHERE source='codex'"
3258
+ )
3259
+
3260
+
3261
+ def _apply_codex_quota_group_index(conn: sqlite3.Connection) -> None:
3262
+ """Create the physical-group seek index, idempotently.
3263
+
3264
+ Public #5 Task 5. This REPLACES ``idx_qws_window_ident``, which Task 1 built
3265
+ to the plan's prescribed column list and which measurement then disqualified
3266
+ on both counts it was meant to serve. Against a 211K-row / 608-group store:
3267
+
3268
+ * The group filter's reset member is ``unixepoch(COALESCE(
3269
+ canonical_resets_at_utc, resets_at_utc))``, and a b-tree over the two raw
3270
+ reset columns cannot seek an equality on that expression. Only the
3271
+ five-column prefix was usable, so one group's query still walked every
3272
+ reset under its limit key — 19.7ms. With this expression index the same
3273
+ query seeks all five members: 0.60ms, a 33x difference that is the
3274
+ difference between "proportional to the change" and "proportional to
3275
+ history".
3276
+ * The full-sweep load reads nine columns the identity index does not carry
3277
+ (``source_path``, ``line_offset``, ``limit_id``, ``limit_name``,
3278
+ ``plan_type``, ``individual_limit_json``, ``reached_type``,
3279
+ ``observed_model``, ``account_key``), so it was never covering and the
3280
+ planner scanned anyway — 451ms with the index present, 451ms without it.
3281
+ It also did not change the plan for ``_first_block_physical_tuple``
3282
+ (28.2ms scanning either way). It earned nothing and charged ten columns of
3283
+ write cost on every ingested observation.
3284
+
3285
+ The partial ``WHERE source='codex'`` keeps Claude quota rows out of it, and
3286
+ makes the four leading members sufficient on their own for a Codex-scoped
3287
+ identity read.
3288
+
3289
+ Guarded on ``canonical_resets_at_utc`` for the same reason the ledger is: a
3290
+ legacy-shape cache whose schema apply took the FTS early-return before that
3291
+ column add would raise ``no such column`` here.
3292
+ """
3293
+ cols = {
3294
+ str(row[1]) for row in conn.execute(
3295
+ "PRAGMA table_info(quota_window_snapshots)")
3296
+ }
3297
+ if not cols or "canonical_resets_at_utc" not in cols:
3298
+ return
3299
+ conn.execute(_QUOTA_GROUP_INDEX_DDL)
3300
+ conn.execute("DROP INDEX IF EXISTS idx_qws_window_ident")
3301
+
3302
+
3303
+ #: Partial index over the rows the standing `observed_model` resolution can
3304
+ #: still change. Public #5 Task 10a Step 14: the resolution runs at the tail of
3305
+ #: EVERY Codex sync (that is what closes the #373 Spark-pool hole on a
3306
+ #: `db skip 039` install and on a cache repopulated from the journal), and its
3307
+ #: `WHERE source='codex' AND observed_model IS NULL` had only the
3308
+ #: `source`-leading unique index to work with — measured at 39.8ms per tick on a
3309
+ #: 212K-row store that has NOTHING left to resolve, against a 264ms steady-state
3310
+ #: tick. The index body holds only unresolved rows (6 on that store), so it
3311
+ #: costs essentially nothing to maintain and turns the recurring scan into a
3312
+ #: seek. Gating the call behind a completion marker was the alternative and is
3313
+ #: strictly worse: a marker cannot see a raw `observed_model` rewrite, which is
3314
+ #: exactly the case migration 028 performs.
3315
+ _QUOTA_UNRESOLVED_MODEL_INDEX_DDL = (
3316
+ "CREATE INDEX IF NOT EXISTS idx_qws_unresolved_model"
3317
+ " ON quota_window_snapshots(source)"
3318
+ " WHERE observed_model IS NULL"
3319
+ )
3320
+
3321
+
3322
+ def _apply_codex_quota_unresolved_model_index(conn: sqlite3.Connection) -> None:
3323
+ """Create the unresolved-model partial index, idempotently.
3324
+
3325
+ Guarded on the column for the same reason the ledger and the group index
3326
+ are: a legacy-shape cache whose schema apply took the FTS early-return
3327
+ before `observed_model` existed would raise `no such column` here.
3328
+ """
3329
+ cols = {
3330
+ str(row[1]) for row in conn.execute(
3331
+ "PRAGMA table_info(quota_window_snapshots)")
3332
+ }
3333
+ if not cols or "observed_model" not in cols:
3334
+ return
3335
+ conn.execute(_QUOTA_UNRESOLVED_MODEL_INDEX_DDL)
3336
+
3337
+
3338
+ def _apply_codex_quota_change_ledger(conn: sqlite3.Connection) -> None:
3339
+ """Create the Codex quota change ledger + triggers, idempotently.
3340
+
3341
+ Guarded on ``canonical_resets_at_utc``: a legacy-shape cache whose schema
3342
+ apply took the FTS early-return before that column add would make every
3343
+ trigger body fail to resolve. Such a cache keeps today's whole-history
3344
+ sweep, which is correct, just not incremental.
3345
+ """
3346
+ cols = {
3347
+ str(row[1]) for row in conn.execute(
3348
+ "PRAGMA table_info(quota_window_snapshots)")
3349
+ }
3350
+ if not cols or "canonical_resets_at_utc" not in cols:
3351
+ return
3352
+ for statement in _codex_quota_ledger_ddl():
3353
+ conn.execute(statement)
3354
+
3355
+
3103
3356
  # === Region 7b2: Eager cache-migration trigger (V4 — same-invocation 008 apply) ===
3104
3357
 
3105
3358
 
@@ -3573,6 +3826,29 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
3573
3826
  add_column_if_missing(conn, "session_files", "account_key", "TEXT")
3574
3827
  add_column_if_missing(conn, "codex_session_entries", "account_key", "TEXT")
3575
3828
  add_column_if_missing(conn, "codex_session_files", "account_key", "TEXT")
3829
+ # Public #5: whether ingestion actually reached the stored scan target.
3830
+ #
3831
+ # `_write_codex_file_batch` persists the file's full observed `st_size`
3832
+ # alongside whatever `final_offset` ingestion reached, and the delta
3833
+ # detector skips on `size == prev_size` without consulting the offset. A
3834
+ # hook-budgeted mid-file stop under that representation would make the
3835
+ # unread suffix permanently invisible on any rollout that never grows
3836
+ # again. This flag is what lets detection consult completeness first.
3837
+ #
3838
+ # DEFAULT 1 so every pre-existing row reads as complete, preserving today's
3839
+ # behaviour exactly — only a budgeted partial stop writes 0. NOT NULL with a
3840
+ # non-null default is a metadata-only ALTER in SQLite, so this does not
3841
+ # rewrite a large codex_session_files.
3842
+ #
3843
+ # It sits HERE, immediately after the last previously-added
3844
+ # codex_session_files column, and NOT beside last_turn_id where it reads
3845
+ # more naturally: ALTER TABLE ADD COLUMN appends, so an existing cache would
3846
+ # receive it after account_key while a fresh one built it before, and the
3847
+ # two would disagree on ordinal (the #195 hazard, stated there in the same
3848
+ # words).
3849
+ add_column_if_missing(
3850
+ conn, "codex_session_files", "ingest_complete",
3851
+ "INTEGER NOT NULL DEFAULT 1")
3576
3852
  add_column_if_missing(conn, "quota_window_snapshots", "account_key", "TEXT")
3577
3853
  # #195: the cache-write TTL split. NULLable with NO DEFAULT — NULL is the
3578
3854
  # "split unknown" sentinel a pre-#195 row produces for free, and a real
@@ -3692,6 +3968,15 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
3692
3968
  # backfill over existing history.
3693
3969
  add_column_if_missing(
3694
3970
  conn, "quota_window_snapshots", "canonical_resets_at_utc", "TEXT")
3971
+ # Public #5: the index the exact physical-group filter seeks. Kept OUT of
3972
+ # the top executescript for the same reason as idx_entries_mutation_seq — on
3973
+ # an existing DB the CREATE TABLE there is a no-op, so observed_model /
3974
+ # canonical_resets_at_utc do not exist yet and an index over them would
3975
+ # raise; it must follow the add_column_if_missing calls above. Still BEFORE
3976
+ # the legacy-FTS early-return, so an old-shape cache.db receives it.
3977
+ _apply_codex_quota_group_index(conn)
3978
+ _apply_codex_quota_unresolved_model_index(conn)
3979
+ _apply_codex_quota_change_ledger(conn)
3695
3980
  conn.execute(
3696
3981
  "CREATE INDEX IF NOT EXISTS idx_session_files_session_id "
3697
3982
  "ON session_files(session_id)"
@@ -3844,6 +4129,12 @@ def _apply_conversations_schema(conn: sqlite3.Connection) -> None:
3844
4129
  DROP TABLE IF EXISTS codex_session_entries;
3845
4130
  DROP TABLE IF EXISTS codex_session_files;
3846
4131
  DROP TABLE IF EXISTS quota_window_snapshots;
4132
+ -- Public #5: the quota change ledger is an accounting concern that
4133
+ -- rides in via _apply_cache_schema above. Dropping the snapshots table
4134
+ -- already removed its triggers (SQLite drops a table's triggers with
4135
+ -- it), so this only clears the now-orphan ledger and keeps
4136
+ -- conversations.db transcripts-only.
4137
+ DROP TABLE IF EXISTS quota_window_change_log;
3847
4138
  DROP TABLE IF EXISTS codex_conversation_threads;
3848
4139
  DROP TABLE IF EXISTS codex_source_roots;
3849
4140
  -- #416: the Codex attribution map is a cache.db accounting concern; it
@@ -3973,6 +4264,51 @@ def _conv_002_codex_thread_source_inference_replay(
3973
4264
  _release_cache_db_writer_flocks(held)
3974
4265
 
3975
4266
 
4267
+ @conversations_migration("003_background_mcp_result_replay")
4268
+ def _conv_003_background_mcp_result_replay(conn: sqlite3.Connection) -> None:
4269
+ """Arm the byte-zero replay that recovers backgrounded-MCP results.
4270
+
4271
+ Spec:
4272
+ ``docs/superpowers/specs/2026-07-31-background-mcp-result-recovery-design.md``
4273
+ §4.
4274
+
4275
+ Claude Code moved MCP dispatch to the background after 120s, and the parser
4276
+ dropped the resulting attachment record — so every such response is missing
4277
+ from already-ingested history. The parser now promotes it, but only for
4278
+ bytes read AFTER the fix; this marker makes the next non-targeted
4279
+ ``sync_claude_conversations`` re-walk the Claude JSONL corpus from offset
4280
+ zero so existing sessions recover too.
4281
+
4282
+ This belongs in conversations.db, NOT cache.db: transcript rows and their
4283
+ replay markers live here, and the Claude conversation synchronizer never
4284
+ writes cache.db — a cache migration would arm a flag the conversation walker
4285
+ never reads.
4286
+
4287
+ Writes a marker ONLY; it clears no table. The key is DISTINCT from every
4288
+ existing reingest flag so a partially-completed replay stays recoverable —
4289
+ reusing one would conflate two enrichments.
4290
+
4291
+ Takes the Claude provider flock first and DEFERS on contention, the way
4292
+ conversations 002 does for Codex. Idempotency comes from physical uniqueness
4293
+ on ``(source_path, byte_offset)`` plus the replay's delete-then-reinsert per
4294
+ source file; re-running this handler rewrites the same marker. NO self-stamp
4295
+ — the dispatcher central-stamps on a clean return (#140).
4296
+
4297
+ A ``--no-sync`` reader stays pre-backfill until the next full conversation
4298
+ sync; the cost is one full re-walk of the Claude JSONL corpus.
4299
+ """
4300
+ import _cctally_cache
4301
+
4302
+ held = _acquire_conversations_db_claude_provider_flock(
4303
+ conn, migration="conversations 003 background_mcp replay")
4304
+ try:
4305
+ _set_cache_meta(
4306
+ conn, _cctally_cache.CONVERSATION_BACKGROUND_MCP_REINGEST_KEY, "1")
4307
+ conn.commit()
4308
+ finally:
4309
+ _release_cache_db_writer_flocks(held)
4310
+
4311
+
3976
4312
  # #177 S6: the consolidated multi-column external-content FTS5 table that
3977
4313
  # replaces the old conversation_fts(text) + conversation_fts_aux(search_aux)
3978
4314
  # pair. The three column names MUST match the conversation_messages columns BY
@@ -4769,6 +5105,47 @@ def _acquire_conversations_db_codex_provider_flock(
4769
5105
  return held
4770
5106
 
4771
5107
 
5108
+ def _acquire_conversations_db_claude_provider_flock(
5109
+ conn: sqlite3.Connection,
5110
+ *,
5111
+ migration: str,
5112
+ ) -> list[int]:
5113
+ """Take the ``<conversations.db>.lock`` sibling, or DEFER.
5114
+
5115
+ The CLAUDE analogue of ``_acquire_conversations_db_codex_provider_flock``.
5116
+ It derives ``<main-db-file>.lock`` (via ``_cache_db_lock_path_for_conn``,
5117
+ which is store-agnostic) rather than the ``.codex.lock`` sibling — for a
5118
+ conversations connection that resolves to exactly the path
5119
+ ``sync_claude_conversations`` serializes on
5120
+ (``_cctally_core.CONVERSATIONS_LOCK_PATH``).
5121
+
5122
+ The conversations dispatcher runs inside ``_conversations_open_guarded``,
5123
+ which holds ``CONVERSATIONS_LOCK_MAINTENANCE_PATH`` only SHARED, so without
5124
+ this a marker can be armed in the middle of a walk that already read it as
5125
+ absent — and that walk's completion clears EVERY reingest flag, consuming a
5126
+ backfill it never performed. Deferring leaves the migration pending, so it
5127
+ arms cleanly at the next open.
5128
+ """
5129
+ provider_path = _cache_db_lock_path_for_conn(conn)
5130
+ if provider_path is None:
5131
+ return []
5132
+
5133
+ from _lib_cache_writer_lock import acquire_ordered_flocks
5134
+
5135
+ try:
5136
+ held = acquire_ordered_flocks([(provider_path, fcntl.LOCK_EX)])
5137
+ except OSError as exc:
5138
+ raise MigrationGateNotMet(
5139
+ f"conversations.db Claude lock unavailable; deferring {migration}"
5140
+ ) from exc
5141
+ if held is None:
5142
+ raise MigrationGateNotMet(
5143
+ f"conversations.db Claude lock held by a concurrent Claude "
5144
+ f"conversation sync; deferring {migration}"
5145
+ )
5146
+ return held
5147
+
5148
+
4772
5149
  def _release_cache_db_writer_flocks(held: list[int]) -> None:
4773
5150
  from _lib_cache_writer_lock import release_cache_writer_flocks
4774
5151
 
@@ -6257,6 +6634,255 @@ def _035_codex_thread_source_inference_replay(conn: sqlite3.Connection) -> None:
6257
6634
  conn.commit()
6258
6635
 
6259
6636
 
6637
+ @cache_migration("036_codex_quota_window_identity_index")
6638
+ def _036_codex_quota_window_identity_index(conn: sqlite3.Connection) -> None:
6639
+ """Public #5: land the Codex quota window-identity covering index.
6640
+
6641
+ The index itself is created by ``_apply_cache_schema`` (the repo's
6642
+ index-addition rule). This migration exists because that schema apply is
6643
+ VERSION-GATED: a steady-state open compares ``PRAGMA user_version`` against
6644
+ ``len(_CACHE_MIGRATIONS)`` and skips the whole DDL pass when they match
6645
+ (``_cctally_store.schema_current``). Registering here bumps the head, so an
6646
+ already-current install re-runs the schema apply and gains the index. Same
6647
+ mechanism migrations 029/031/032 rely on.
6648
+
6649
+ The idempotent DDL below is the handler's self-contained copy, and it is
6650
+ what the per-migration golden exercises (the golden's ``pre.sqlite`` is a
6651
+ genuine 035-head install that predates the index).
6652
+
6653
+ The two columns the index reaches beyond the original table DDL —
6654
+ ``observed_model`` and ``canonical_resets_at_utc`` — are guarded rather than
6655
+ assumed: a legacy-shape cache whose FTS early-return fired before those
6656
+ ``add_column_if_missing`` calls would otherwise raise ``no such column``
6657
+ here and fail the migration. Only ``canonical_resets_at_utc`` is in the
6658
+ index, so that is the one probed.
6659
+
6660
+ SUPERSEDED by 040. Measurement against a real-scale store disqualified this
6661
+ index on both counts it was meant to serve — it cannot seek the group
6662
+ filter's ``unixepoch(COALESCE(...))`` reset member, and it is not covering
6663
+ for the full-sweep load either — so 040 replaces it with an expression index
6664
+ and drops it. The handler is kept verbatim because an install that has not
6665
+ yet reached 040 still runs it, and its golden pins that behaviour; the end
6666
+ state after 040 is what matters.
6667
+
6668
+ Re-running is a no-op. NO self-stamp — the dispatcher central-stamps on a
6669
+ clean return (#140).
6670
+ """
6671
+ cols = {
6672
+ str(row[1]) for row in conn.execute(
6673
+ "PRAGMA table_info(quota_window_snapshots)")
6674
+ }
6675
+ if "canonical_resets_at_utc" not in cols:
6676
+ return
6677
+ conn.execute(
6678
+ "CREATE INDEX IF NOT EXISTS idx_qws_window_ident "
6679
+ "ON quota_window_snapshots("
6680
+ " source, source_root_key, logical_limit_key, observed_slot,"
6681
+ " window_minutes, resets_at_utc, canonical_resets_at_utc,"
6682
+ " captured_at_utc, used_percent, id)"
6683
+ )
6684
+ conn.commit()
6685
+
6686
+
6687
+ @cache_migration("037_codex_quota_change_ledger")
6688
+ def _037_codex_quota_change_ledger(conn: sqlite3.Connection) -> None:
6689
+ """Public #5: land the Codex quota change ledger and its triggers.
6690
+
6691
+ Same version-gate reason as 036: ``_apply_cache_schema`` creates both, but a
6692
+ steady-state open at the registry head skips that whole DDL pass, so an
6693
+ install that never re-runs it would silently keep mutating
6694
+ ``quota_window_snapshots`` with no ledger behind it — and the projector
6695
+ would then believe nothing had changed. Registering here bumps the head and
6696
+ forces the apply.
6697
+
6698
+ Delegates to the same ``_apply_codex_quota_change_ledger`` production uses;
6699
+ re-implementing the DDL here would let the migration and the schema drift,
6700
+ and a drifted trigger is a silent-skip, not an error.
6701
+
6702
+ The ledger deliberately starts EMPTY. It is a change log, not a snapshot of
6703
+ existing state — an install whose watermark has never been set has no
6704
+ consumed range, and the projector's first pass on a fresh watermark is a
6705
+ full sweep anyway.
6706
+
6707
+ Re-running is a no-op (every statement is IF NOT EXISTS). NO self-stamp —
6708
+ the dispatcher central-stamps on a clean return (#140).
6709
+ """
6710
+ _apply_codex_quota_change_ledger(conn)
6711
+ conn.commit()
6712
+
6713
+
6714
+ @cache_migration("038_codex_session_files_ingest_complete")
6715
+ def _038_codex_session_files_ingest_complete(conn: sqlite3.Connection) -> None:
6716
+ """Public #5: land ``codex_session_files.ingest_complete``.
6717
+
6718
+ The column itself is a plain addition, so it lands through
6719
+ ``add_column_if_missing`` in ``_apply_cache_schema`` (the idempotent guard
6720
+ pattern — no marker, no version). This migration exists to bump the registry
6721
+ head so a steady-state install re-runs the version-gated schema apply and
6722
+ actually gains it, the same mechanism 029/031/032/036 rely on.
6723
+
6724
+ There is deliberately NO backfill. ``DEFAULT 1`` already reads every
6725
+ pre-existing row as complete, which is exactly today's behaviour: before
6726
+ the budgeted ingest exists, every committed file WAS scanned to its stored
6727
+ target. Writing 0 anywhere here would strand real history behind a resume
6728
+ that never happens.
6729
+
6730
+ Re-running is a no-op. NO self-stamp — the dispatcher central-stamps on a
6731
+ clean return (#140).
6732
+ """
6733
+ add_column_if_missing(
6734
+ conn, "codex_session_files", "ingest_complete",
6735
+ "INTEGER NOT NULL DEFAULT 1")
6736
+ conn.commit()
6737
+
6738
+
6739
+ # The read-time fallback ``load_codex_quota_observations`` used before public #5:
6740
+ # the nearest preceding accounting model at or before the snapshot's byte offset,
6741
+ # within the SAME rollout. Reproduced here VERBATIM — the backfill is only
6742
+ # provably equivalent to the behaviour it replaces if it computes the identical
6743
+ # expression, so this string is the contract, not a paraphrase of it.
6744
+ _QUOTA_OBSERVED_MODEL_LOOKUP_SQL = """
6745
+ (SELECT entries.model FROM codex_session_entries AS entries
6746
+ WHERE entries.source_path = quota_window_snapshots.source_path
6747
+ AND entries.line_offset <= quota_window_snapshots.line_offset
6748
+ ORDER BY entries.line_offset DESC LIMIT 1)
6749
+ """
6750
+
6751
+ # The ``IS NOT NULL`` guard repeats the same expression rather than widening the
6752
+ # UPDATE to every unstamped row. Without it a row the lookup cannot resolve is
6753
+ # "updated" from NULL to NULL: the result is identical, but the UPDATE trigger
6754
+ # fires on it, so the handler dirties a window it did not change AND stops being
6755
+ # a true no-op on re-run — it would append fresh ledger entries on every
6756
+ # markerless retry. Equivalent by construction: a row skipped by the guard is
6757
+ # exactly a row the SET would have written NULL to.
6758
+ _QUOTA_OBSERVED_MODEL_BACKFILL_SQL = f"""
6759
+ UPDATE quota_window_snapshots
6760
+ SET observed_model = {_QUOTA_OBSERVED_MODEL_LOOKUP_SQL}
6761
+ WHERE source = 'codex' AND observed_model IS NULL
6762
+ AND {_QUOTA_OBSERVED_MODEL_LOOKUP_SQL} IS NOT NULL
6763
+ """
6764
+
6765
+
6766
+ def backfill_codex_quota_observed_model(conn: sqlite3.Connection) -> int:
6767
+ """Resolve unstamped ``quota_window_snapshots.observed_model`` in place.
6768
+
6769
+ Returns the number of rows the resolution actually changed, so a caller can
6770
+ decide whether the Codex physical mutation sequence has to advance.
6771
+
6772
+ Shared by cache migration 039 and by the tail of ``sync_codex_cache``. The
6773
+ migration alone is not sufficient, because the #373 Spark-pool guarantee
6774
+ then holds only where 039 ran and two supported paths skip it:
6775
+ ``cctally db skip 039_…``, and a fresh cache repopulated from the journal
6776
+ (a fresh install fast-stamps every migration handler without invoking it,
6777
+ and the journal cache leg re-materializes rows carrying whatever
6778
+ ``observed_model`` was journaled — NULL for anything captured before the
6779
+ column existed). Running it at the end of every Codex sync closes both:
6780
+ the statement only considers rows that are still NULL AND whose lookup
6781
+ resolves, so on a healthy store it is a near-no-op.
6782
+
6783
+ Does NOT commit — the caller owns the transaction boundary.
6784
+ """
6785
+ quota_cols = {
6786
+ str(row[1]) for row in conn.execute(
6787
+ "PRAGMA table_info(quota_window_snapshots)")
6788
+ }
6789
+ if "observed_model" not in quota_cols:
6790
+ # A legacy-shape cache whose schema apply never reached the column.
6791
+ # Nothing to backfill; the reader's unscoped default is unchanged.
6792
+ return 0
6793
+ entry_cols = {
6794
+ str(row[1]) for row in conn.execute(
6795
+ "PRAGMA table_info(codex_session_entries)")
6796
+ }
6797
+ if not {"source_path", "line_offset", "model"} <= entry_cols:
6798
+ # No accounting corpus to resolve against — the fallback could not have
6799
+ # resolved anything either, so leaving every row NULL IS the equivalent
6800
+ # result.
6801
+ return 0
6802
+ return int(conn.execute(_QUOTA_OBSERVED_MODEL_BACKFILL_SQL).rowcount)
6803
+
6804
+
6805
+ @cache_migration("039_codex_quota_observed_model_backfill")
6806
+ def _039_codex_quota_observed_model_backfill(conn: sqlite3.Connection) -> None:
6807
+ """Public #5: make ``quota_window_snapshots`` the complete dependency set.
6808
+
6809
+ The change ledger records mutations of ``quota_window_snapshots``, which is
6810
+ sufficient only if nothing outside that table can change how a window is
6811
+ interpreted. Until now something could: with a NULL ``observed_model`` the
6812
+ loader fell back to the nearest preceding ``codex_session_entries.model`` at
6813
+ or before the snapshot's ``line_offset``, so an accounting row arriving
6814
+ later could move a window into a different model pool with no quota-row
6815
+ mutation to observe.
6816
+
6817
+ The dependency is ELIMINATED rather than ledgered — extending the triggers
6818
+ to a second table would grow the mechanism's surface for one legacy case.
6819
+ This backfill materializes exactly what the fallback resolved, ingest
6820
+ already stamps the same sticky model forward onto every quota row it emits,
6821
+ and the read-time COALESCE is removed in the same change.
6822
+
6823
+ A row with no determinable model stays NULL and reads as unscoped — which
6824
+ is today's behaviour when both sources are NULL. Nothing is fabricated.
6825
+
6826
+ Idempotent by construction: only rows whose ``observed_model`` is still NULL
6827
+ AND whose lookup actually resolves are considered, so a re-run over its own
6828
+ output writes nothing at all — not even a NULL-to-NULL update, which would
6829
+ fire the ledger trigger and dirty a window that never changed. Its real DML
6830
+ IS ledgered, which is the mechanism working as designed: a migration that
6831
+ rewrites this column no longer has to remember to announce it.
6832
+
6833
+ The migration is the ONE-TIME leg. It is not the whole guarantee: `db skip`
6834
+ and a fresh journal-repopulated cache both bypass it, so ``sync_codex_cache``
6835
+ runs the same helper on every Codex sync.
6836
+
6837
+ NO self-stamp — the dispatcher central-stamps on a clean return (#140).
6838
+ """
6839
+ backfill_codex_quota_observed_model(conn)
6840
+ conn.commit()
6841
+
6842
+
6843
+ @cache_migration("040_codex_quota_physical_group_index")
6844
+ def _040_codex_quota_physical_group_index(conn: sqlite3.Connection) -> None:
6845
+ """Public #5: seek the physical group, and stop paying for what does not.
6846
+
6847
+ Creates ``idx_qws_physical_group`` and drops 036's
6848
+ ``idx_qws_window_ident``. The reasoning and the numbers live on
6849
+ ``_apply_codex_quota_group_index``, which this delegates to so the migration
6850
+ and the schema apply cannot drift; in short, an expression index over
6851
+ ``unixepoch(COALESCE(canonical_resets_at_utc, resets_at_utc))`` takes one
6852
+ group's load from 19.7ms to 0.60ms on a 211K-row store, and the index it
6853
+ replaces changed no query plan at all while charging ten columns of write
6854
+ cost per ingested observation.
6855
+
6856
+ Same version-gate reason as 036/037/038: the schema apply that creates the
6857
+ index is skipped outright by a steady-state open, so registering here is
6858
+ what makes an already-current install pick it up.
6859
+
6860
+ Re-running is a no-op (``IF NOT EXISTS`` / ``IF EXISTS``). NO self-stamp —
6861
+ the dispatcher central-stamps on a clean return (#140).
6862
+ """
6863
+ _apply_codex_quota_group_index(conn)
6864
+ conn.commit()
6865
+
6866
+
6867
+ @cache_migration("041_codex_quota_unresolved_model_index")
6868
+ def _041_codex_quota_unresolved_model_index(conn: sqlite3.Connection) -> None:
6869
+ """Public #5: stop rescanning 212K rows for six unresolved ones.
6870
+
6871
+ Creates ``idx_qws_unresolved_model``. The reasoning and the numbers live on
6872
+ ``_apply_codex_quota_unresolved_model_index``, which this delegates to so
6873
+ the migration and the schema apply cannot drift.
6874
+
6875
+ Same version-gate reason as 036/037/038/040: the schema apply that creates
6876
+ the index is skipped outright by a steady-state open, so registering here is
6877
+ what makes an already-current install pick it up.
6878
+
6879
+ Re-running is a no-op (``IF NOT EXISTS``). NO self-stamp — the dispatcher
6880
+ central-stamps on a clean return (#140).
6881
+ """
6882
+ _apply_codex_quota_unresolved_model_index(conn)
6883
+ conn.commit()
6884
+
6885
+
6260
6886
  # === Region 7d: Stats migration 008_recompute_weekly_cost_snapshots_dedup_fix ===
6261
6887
 
6262
6888
  @stats_migration("008_recompute_weekly_cost_snapshots_dedup_fix")