cctally 1.88.1 → 1.89.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.
@@ -542,6 +542,38 @@ CODEX_CONVERSATION_REPLAY_FROM_ZERO_KEY = (
542
542
  _lib_codex_conversation.CODEX_CONVERSATION_REPLAY_FROM_ZERO_KEY)
543
543
  CODEX_REPLAY_BLOCKED_KEY = (
544
544
  _lib_codex_conversation.CODEX_REPLAY_BLOCKED_KEY)
545
+ CODEX_REPLAY_DEFERRED_KEY = (
546
+ _lib_codex_conversation.CODEX_REPLAY_DEFERRED_KEY)
547
+
548
+ #: The hidden self-subcommand that performs the byte-zero replay a budgeted
549
+ #: tick declined (public #5). Deliberately its own worker rather than a mode of
550
+ #: `_codex-quota-verify`: they repair different things and must not share a
551
+ #: throttle marker or a failure mode (the `_update-check` / `_telemetry-beat`
552
+ #: precedent).
553
+ CODEX_REPLAY_DRAIN_COMMAND = "_codex-replay-drain"
554
+
555
+ #: Marker whose mtime throttles drain spawns, in ``APP_DIR`` rather than
556
+ #: cache.db because a spawn is process state and the decision is taken with no
557
+ #: cache write transaction open.
558
+ CODEX_REPLAY_DRAIN_MARKER_NAME = "codex-replay-drain.last-attempt"
559
+
560
+ #: Minimum spacing between drain spawns. Stamped on ATTEMPT, not on success:
561
+ #: the replay marker only disappears when a drain COMPLETES, so every tick in
562
+ #: between still reads as needing one and a success-stamped throttle would put
563
+ #: one worker on the box per 15-second Codex lifecycle tick. An hour, because a
564
+ #: drain re-reads the whole rollout tree (2.7 GB locally) and a persistently
565
+ #: failing one — a torn `auth.json` — would otherwise repeat that hourly cost
566
+ #: far more often than the `doctor` WARN it produces is actionable.
567
+ CODEX_REPLAY_DRAIN_SPAWN_THROTTLE_SECONDS = 3600
568
+
569
+ #: How long the drain worker waits for the cache writer flocks. The default
570
+ #: ``None`` is a single non-blocking attempt, which spends the whole hourly slot
571
+ #: on whichever ordinary sync happened to hold the lock at that instant. Nobody
572
+ #: is waiting on this process, and the throttle admits at most one of it per
573
+ #: hour, so waiting a couple of minutes is strictly better than forfeiting the
574
+ #: slot. Bounded rather than unbounded so a wedged lock cannot leave the worker
575
+ #: resident indefinitely.
576
+ CODEX_REPLAY_DRAIN_LOCK_TIMEOUT_SECONDS = 120.0
545
577
 
546
578
 
547
579
  # cache.db WAL hardening (#297). See
@@ -1453,7 +1485,7 @@ def _load_codex_session_files_rows(
1453
1485
  ) -> dict:
1454
1486
  """Cursor rows from ``codex_session_files`` for ONLY the given paths (spec
1455
1487
  §5.1 — the targeted preload must never load every row like the full-sync
1456
- path). Same 12-tuple value shape as ``sync_codex_cache``'s full ``existing``
1488
+ path). Same 13-tuple value shape as ``sync_codex_cache``'s full ``existing``
1457
1489
  map, so the per-file delta logic is byte-identical between the two modes."""
1458
1490
  out: dict = {}
1459
1491
  if not paths:
@@ -1462,7 +1494,7 @@ def _load_codex_session_files_rows(
1462
1494
  "path, size_bytes, mtime_ns, last_byte_offset, "
1463
1495
  "last_session_id, last_model, last_total_tokens, source_root_key, "
1464
1496
  "last_native_thread_id, last_root_thread_id, last_parent_thread_id, "
1465
- "last_conversation_key, last_turn_id"
1497
+ "last_conversation_key, last_turn_id, ingest_complete"
1466
1498
  )
1467
1499
  for i in range(0, len(paths), 400):
1468
1500
  chunk = paths[i:i + 400]
@@ -1473,7 +1505,7 @@ def _load_codex_session_files_rows(
1473
1505
  ):
1474
1506
  out[row[0]] = (
1475
1507
  row[1], row[2], row[3], row[4], row[5], row[6], row[7],
1476
- row[8], row[9], row[10], row[11], row[12],
1508
+ row[8], row[9], row[10], row[11], row[12], row[13],
1477
1509
  )
1478
1510
  return out
1479
1511
 
@@ -2023,6 +2055,7 @@ def _write_codex_file_batch(
2023
2055
  incarnation: "int | None" = None,
2024
2056
  file_account_decision: "tuple[int, str | None] | None" = None,
2025
2057
  anchor_resolver: "CodexResetAnchorResolver | None" = None,
2058
+ ingest_complete: bool = True,
2026
2059
  ) -> int:
2027
2060
  """Write one fully-buffered Codex file atomically and return entry changes.
2028
2061
 
@@ -2035,7 +2068,14 @@ def _write_codex_file_batch(
2035
2068
  durable attribution decision into THIS transaction, so the decision, the
2036
2069
  rows it stamped and the file watermark commit or roll back as one unit. The
2037
2070
  decision was already journaled (fail-closed) before this call, so a crash
2038
- between the two replays idempotently rather than losing it."""
2071
+ between the two replays idempotently rather than losing it.
2072
+
2073
+ ``ingest_complete`` (public #5) records whether ingestion actually reached
2074
+ the ``size`` it is about to persist. It MUST be listed in the cursor upsert
2075
+ below: ``INSERT OR REPLACE`` deletes and reinserts the row, so an omitted
2076
+ column silently reverts to its schema DEFAULT of 1 — a budgeted stop would
2077
+ have its own record erased by its own commit, the unread suffix would be
2078
+ skipped as unchanged forever, and nothing would raise."""
2039
2079
  now_iso = dt.datetime.now(dt.timezone.utc).isoformat()
2040
2080
  if reset_file:
2041
2081
  _delete_codex_file_derived_rows(conn, path_str)
@@ -2116,13 +2156,14 @@ def _write_codex_file_batch(
2116
2156
  (path, size_bytes, mtime_ns, last_byte_offset, last_ingested_at,
2117
2157
  last_session_id, last_model, last_total_tokens, source_root_key,
2118
2158
  last_native_thread_id, last_root_thread_id, last_parent_thread_id,
2119
- last_conversation_key, last_turn_id, account_key)
2120
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
2159
+ last_conversation_key, last_turn_id, account_key, ingest_complete)
2160
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
2121
2161
  (
2122
2162
  path_str, size, mtime_ns, final_offset, now_iso, last_session_id,
2123
2163
  last_model, last_total_tokens, discovered.source_root_key,
2124
2164
  last_native_thread_id, last_root_thread_id, last_parent_thread_id,
2125
2165
  last_conversation_key, last_turn_id, account_key,
2166
+ 1 if ingest_complete else 0,
2126
2167
  ),
2127
2168
  )
2128
2169
  if prune_roots:
@@ -2404,6 +2445,7 @@ _TARGETED_DECLINE_FLAGS = (
2404
2445
  "conversation_reingest_nested_agent_pending", # migration 017
2405
2446
  "conversation_title_fts_backfill_pending", # migration 018 (P1-2: HERE ONLY)
2406
2447
  "conversation_reingest_file_touches_pending", # migration 019 (P1-2: HERE ONLY)
2448
+ "conversation_background_mcp_reingest_pending", # conversations 003
2407
2449
  )
2408
2450
 
2409
2451
 
@@ -2810,6 +2852,7 @@ def sync_cache(
2810
2852
  " 'conversation_media_reingest_pending',"
2811
2853
  " 'conversation_queued_prompt_reingest_pending',"
2812
2854
  " 'conversation_reingest_nested_agent_pending',"
2855
+ " 'conversation_background_mcp_reingest_pending',"
2813
2856
  " 'conversation_title_fts_backfill_pending',"
2814
2857
  " 'conversation_reingest_file_touches_pending',"
2815
2858
  " 'conversation_file_touches_cursor',"
@@ -2981,7 +3024,8 @@ def sync_cache(
2981
3024
  " 'conversation_reingest_enrichment_pending',"
2982
3025
  " 'conversation_media_reingest_pending',"
2983
3026
  " 'conversation_queued_prompt_reingest_pending',"
2984
- " 'conversation_reingest_nested_agent_pending')"
3027
+ " 'conversation_reingest_nested_agent_pending',"
3028
+ " 'conversation_background_mcp_reingest_pending')"
2985
3029
  ).fetchone() is not None
2986
3030
  except sqlite3.OperationalError:
2987
3031
  _reingest = False
@@ -3717,6 +3761,14 @@ def backfill_ai_titles(conn: sqlite3.Connection) -> int:
3717
3761
  return n
3718
3762
 
3719
3763
 
3764
+ # Backgrounded-MCP result recovery (spec 2026-07-31 §4). DISTINCT from every
3765
+ # flag above on purpose: reusing one would conflate two enrichments and make a
3766
+ # partially-completed replay unrecoverable. Armed by conversations migration
3767
+ # 003 (in conversations.db — the Claude conversation synchronizer never writes
3768
+ # cache.db, so a cache migration would arm a flag nothing reads).
3769
+ CONVERSATION_BACKGROUND_MCP_REINGEST_KEY = (
3770
+ "conversation_background_mcp_reingest_pending")
3771
+
3720
3772
  _REINGEST_FLAG_KEYS = (
3721
3773
  "conversation_reingest_pending",
3722
3774
  "conversation_source_tool_use_reingest_pending",
@@ -3724,6 +3776,7 @@ _REINGEST_FLAG_KEYS = (
3724
3776
  "conversation_media_reingest_pending", # #177 S4 (migration 009)
3725
3777
  "conversation_queued_prompt_reingest_pending", # migration 014
3726
3778
  "conversation_reingest_nested_agent_pending", # #217 S1 (migration 017)
3779
+ CONVERSATION_BACKGROUND_MCP_REINGEST_KEY, # conversations 003
3727
3780
  )
3728
3781
 
3729
3782
 
@@ -3822,6 +3875,7 @@ def _resumable_reingest_conversation_messages(conn):
3822
3875
  " 'conversation_media_reingest_pending',"
3823
3876
  " 'conversation_queued_prompt_reingest_pending',"
3824
3877
  " 'conversation_reingest_nested_agent_pending',"
3878
+ " 'conversation_background_mcp_reingest_pending',"
3825
3879
  " 'conversation_reingest_cursor',"
3826
3880
  " 'conversation_reingest_cursor_gen')")
3827
3881
  conn.commit()
@@ -4828,6 +4882,12 @@ class CodexIngestStats:
4828
4882
  # Deferred WITHOUT advancing their cursor so the next sync re-reads and
4829
4883
  # re-stamps rather than guessing an account (spec §1 stable-read protocol).
4830
4884
  files_deferred_torn: int = 0
4885
+ # public #5 spec §4: what a budgeted (hook-path) walk left undone. Both stay
4886
+ # 0 for every unbudgeted caller, so `cache-sync` and the dashboard are
4887
+ # unaffected — an explicit sync still runs to completion.
4888
+ backlog_files: int = 0
4889
+ backlog_bytes: int = 0
4890
+ budget_exhausted: bool = False
4831
4891
 
4832
4892
  @property
4833
4893
  def targeted_clean(self) -> bool:
@@ -4865,6 +4925,357 @@ def _extend_codex_touched_span(
4865
4925
  spans[key] = (min(current[0], moment), max(current[1], moment))
4866
4926
 
4867
4927
 
4928
+ # public #5 spec §4. The budgeted walk's two persisted facts.
4929
+ #
4930
+ # The resume cursor exists because the discovered order is stable and sorted:
4931
+ # without it, the actively-appended files at the FRONT would consume every
4932
+ # budget forever and the tail would never drain. It records the file the walk
4933
+ # stopped before, by identity AND by ordinal — the identity is exact when the
4934
+ # file set is unchanged, and the ordinal is what keeps forward progress when
4935
+ # the cursor's target has been deleted or respelled. Falling back to 0 instead
4936
+ # would let a store that loses its cursor file each tick restart the cycle
4937
+ # forever.
4938
+ _CODEX_RESUME_CURSOR_KEY = "codex_ingest_resume_cursor"
4939
+ _CODEX_BACKLOG_KEY = "codex_ingest_backlog"
4940
+
4941
+ #: The active rollout gets at most this share of the budget, so priority cannot
4942
+ #: itself starve the backlog it precedes.
4943
+ _CODEX_ACTIVE_FIRST_BUDGET_FRACTION = 0.5
4944
+
4945
+
4946
+ def _walk_clock() -> float:
4947
+ """Monotonic clock for the budgeted Codex walk.
4948
+
4949
+ A named indirection rather than a bare ``time.monotonic()`` so the budget
4950
+ is deterministically testable: "expires after exactly one file" is not
4951
+ expressible against a real clock under variable CI load, and a convergence
4952
+ test that cannot pin where the budget stops is not testing convergence.
4953
+ Private test seam, in the same family as ``_on_file_committed``.
4954
+ """
4955
+ return time.monotonic()
4956
+
4957
+
4958
+ def _codex_walk_key(discovered: "CodexDiscoveredFile") -> "tuple[str, str]":
4959
+ """The resume cursor's identity: source root plus canonical physical path.
4960
+
4961
+ The canonical path, not the configured spelling: a `$CODEX_HOME` respelling
4962
+ or a symlink change must not read as a different file and silently rewind
4963
+ the walk.
4964
+ """
4965
+ return (str(discovered.source_root_key), str(discovered.physical_path))
4966
+
4967
+
4968
+ def _load_codex_resume_cursor(conn: sqlite3.Connection) -> "tuple | None":
4969
+ """``(root_key, physical_path, ordinal)`` or ``None``."""
4970
+ try:
4971
+ row = conn.execute(
4972
+ "SELECT value FROM cache_meta WHERE key = ? LIMIT 1",
4973
+ (_CODEX_RESUME_CURSOR_KEY,)).fetchone()
4974
+ except sqlite3.DatabaseError:
4975
+ return None
4976
+ if not row or not row[0]:
4977
+ return None
4978
+ try:
4979
+ payload = json.loads(str(row[0]))
4980
+ return (
4981
+ str(payload["root_key"]), str(payload["path"]),
4982
+ int(payload["ordinal"]),
4983
+ )
4984
+ except (ValueError, TypeError, KeyError):
4985
+ return None
4986
+
4987
+
4988
+ def _order_codex_walk(
4989
+ files: "list[CodexDiscoveredFile]",
4990
+ *,
4991
+ cursor: "tuple | None",
4992
+ active_path: "pathlib.Path | None",
4993
+ ) -> "list[CodexDiscoveredFile]":
4994
+ """The budgeted walk order: active rollout first, then a cursor rotation.
4995
+
4996
+ Rotation rather than truncation is what gives the cycle its wrap semantics:
4997
+ a file inserted BEFORE the cursor is visited at the end of the current
4998
+ cycle rather than jumping the queue, and every file is reached within one
4999
+ cycle no matter where the budget happens to stop.
5000
+ """
5001
+ if not files:
5002
+ return list(files)
5003
+ start = 0
5004
+ if cursor is not None:
5005
+ by_key = {_codex_walk_key(item): index
5006
+ for index, item in enumerate(files)}
5007
+ exact = by_key.get((cursor[0], cursor[1]))
5008
+ # A vanished cursor keeps its ORDINAL. Restarting at 0 instead would
5009
+ # let a store whose cursor file is deleted every tick re-walk the same
5010
+ # prefix forever and never converge.
5011
+ start = exact if exact is not None else min(cursor[2], len(files) - 1)
5012
+ start = max(0, start)
5013
+ ordered = files[start:] + files[:start]
5014
+ if active_path is not None:
5015
+ for index, item in enumerate(ordered):
5016
+ if item.physical_path == active_path:
5017
+ if index:
5018
+ ordered = [ordered[index]] + [
5019
+ entry for position, entry in enumerate(ordered)
5020
+ if position != index
5021
+ ]
5022
+ break
5023
+ return ordered
5024
+
5025
+
5026
+ def _load_codex_backlog_record(conn: sqlite3.Connection) -> "dict | None":
5027
+ """The stored backlog record, or ``None`` when absent or unreadable."""
5028
+ try:
5029
+ row = conn.execute(
5030
+ "SELECT value FROM cache_meta WHERE key = ? LIMIT 1",
5031
+ (_CODEX_BACKLOG_KEY,)).fetchone()
5032
+ except sqlite3.DatabaseError:
5033
+ return None
5034
+ if not row or not row[0]:
5035
+ return None
5036
+ try:
5037
+ parsed = json.loads(str(row[0]))
5038
+ except (ValueError, TypeError):
5039
+ return None
5040
+ return parsed if isinstance(parsed, dict) else None
5041
+
5042
+
5043
+ def _write_codex_backlog_record(
5044
+ conn: sqlite3.Connection, *, files: int, owed_bytes: int,
5045
+ since: "str | None",
5046
+ ) -> None:
5047
+ """Persist the backlog record, minting ``since`` only when it is absent.
5048
+
5049
+ ``since`` is the one-hour staleness clock doctor reads, so it is carried
5050
+ forward rather than re-stamped: re-minting it on every tick would keep the
5051
+ age below an hour forever and the WARN would never fire.
5052
+ """
5053
+ _set_cache_meta(conn, _CODEX_BACKLOG_KEY, json.dumps({
5054
+ "files": int(files),
5055
+ "bytes": int(owed_bytes),
5056
+ "since": since or dt.datetime.now(dt.timezone.utc).isoformat(
5057
+ timespec="seconds").replace("+00:00", "Z"),
5058
+ }, sort_keys=True))
5059
+
5060
+
5061
+ def _record_codex_replay_deferral(conn: sqlite3.Connection) -> None:
5062
+ """Record that a BUDGETED tick declined the byte-zero replay (public #5).
5063
+
5064
+ The decline returns before both backlog writes, so nothing else can show
5065
+ it: ``stats.backlog_files`` stays 0, the lifecycle line logs ``backlog=0``,
5066
+ ``doctor codex.ingest_backlog`` reports a drained store and the dashboard
5067
+ omits the field. ``codex_replay_from_zero_blocked`` cannot cover it either
5068
+ — only a walk that actually ran writes that one.
5069
+
5070
+ ``since`` carries forward, like the backlog record's: it is how long the
5071
+ freeze has stood, and re-minting it per tick would hold the age below the
5072
+ WARN threshold forever. Best-effort, like every other cache_meta
5073
+ bookkeeping write on this path — a store too damaged to take it has larger
5074
+ problems and must not turn a deferral into a raised hook tick.
5075
+ """
5076
+ now_iso = dt.datetime.now(dt.timezone.utc).isoformat(
5077
+ timespec="seconds").replace("+00:00", "Z")
5078
+ try:
5079
+ prior = None
5080
+ row = conn.execute(
5081
+ "SELECT value FROM cache_meta WHERE key = ? LIMIT 1",
5082
+ (CODEX_REPLAY_DEFERRED_KEY,)).fetchone()
5083
+ if row and row[0]:
5084
+ try:
5085
+ parsed = json.loads(str(row[0]))
5086
+ except (ValueError, TypeError):
5087
+ parsed = None
5088
+ if isinstance(parsed, dict):
5089
+ prior = parsed.get("since")
5090
+ _set_cache_meta(conn, CODEX_REPLAY_DEFERRED_KEY, json.dumps({
5091
+ "since": prior or now_iso,
5092
+ "at": now_iso,
5093
+ }, sort_keys=True))
5094
+ conn.commit()
5095
+ except sqlite3.DatabaseError as exc:
5096
+ # Classified family corruption belongs to the shared recovery boundary,
5097
+ # never to a best-effort local except — the same re-raise its two
5098
+ # siblings at the end of this walk already make. Swallowing it here made
5099
+ # a corrupt cache present as an ordinary decline, on the one path a
5100
+ # hook-only install takes every tick.
5101
+ if _cctally_db_sib._is_sqlite_corruption_error(exc):
5102
+ raise
5103
+
5104
+
5105
+ def _defer_codex_replay_drain() -> str:
5106
+ """Hand the declined byte-zero replay to a detached worker (public #5).
5107
+
5108
+ Returns ``"spawned"``, ``"throttled"`` or ``"failed"`` — for the lifecycle
5109
+ log and for tests. Same marker-first shape as
5110
+ ``_defer_codex_quota_verification`` and ``update-check.last-fetch``: the
5111
+ mtime is stamped BEFORE the spawn, so a worker that dies cannot make every
5112
+ following tick spawn another, and an unwritable marker means no spawn at
5113
+ all rather than an unbounded spawn rate.
5114
+
5115
+ Deliberately NOT a fallback to running the drain inline. A budgeted tick
5116
+ cannot perform the replay at any speed — it is "clear everything, then
5117
+ re-read everything", and slicing that corrupts account attribution — so
5118
+ there is nothing to fall back to. The record written at the decline site is
5119
+ what makes a drain that never succeeds diagnosable.
5120
+ """
5121
+ marker = _cctally_core.APP_DIR / CODEX_REPLAY_DRAIN_MARKER_NAME
5122
+ try:
5123
+ age = time.time() - marker.stat().st_mtime
5124
+ except OSError:
5125
+ age = None
5126
+ if age is not None and 0 <= age < CODEX_REPLAY_DRAIN_SPAWN_THROTTLE_SECONDS:
5127
+ return "throttled"
5128
+ try:
5129
+ _cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
5130
+ marker.touch()
5131
+ except OSError:
5132
+ return "failed"
5133
+ from _cctally_update import _spawn_detached
5134
+ return "spawned" if _spawn_detached(CODEX_REPLAY_DRAIN_COMMAND) else "failed"
5135
+
5136
+
5137
+ def cmd_codex_replay_drain_internal(args) -> int:
5138
+ """Hidden ``_codex-replay-drain`` handler: the unbudgeted byte-zero replay.
5139
+
5140
+ An ordinary UNBUDGETED ``sync_codex_cache`` — the same call
5141
+ ``cache-sync --source codex`` makes — which is exactly what performs the
5142
+ replay: the pending marker is OR'd into the sync's own ``rebuild``, so the
5143
+ walk captures ``rebuild_known_identities`` before clearing and re-reads
5144
+ every rollout from byte zero.
5145
+
5146
+ Always returns 0: a detached worker's exit code is observed by nobody. A
5147
+ failure is written to ``hook-tick.log`` rather than swallowed, because the
5148
+ condition it repairs (a frozen Codex ingest) is otherwise invisible and
5149
+ both streams are on ``/dev/null``.
5150
+ """
5151
+ from _cctally_record import (
5152
+ _hook_log_error_detail, _hook_log_safe_free_text, _hook_tick_log_line,
5153
+ _hook_tick_log_rotate_if_needed,
5154
+ )
5155
+ started = time.monotonic()
5156
+
5157
+ def _log(outcome: str, detail: str = "", *, error: str = "") -> None:
5158
+ # `detail` is this function's OWN structured `k=v` counters, emitted
5159
+ # verbatim. Free text goes through `error`, which is rendered LAST and
5160
+ # defused HERE — the same shape as `_codex_lifecycle_log_line`, and the
5161
+ # reason the guarantee is a property of the renderer rather than of a
5162
+ # call site remembering to scrub.
5163
+ stamp = dt.datetime.now(dt.timezone.utc).isoformat(
5164
+ timespec="seconds").replace("+00:00", "Z")
5165
+ suffix = ""
5166
+ if error:
5167
+ safe = _hook_log_safe_free_text(error)
5168
+ if safe:
5169
+ suffix = f" error={safe}"
5170
+ _hook_tick_log_line(
5171
+ f"{stamp} provider=codex op=replay-drain result={outcome} "
5172
+ f"dur_ms={max(0, int((time.monotonic() - started) * 1000))}"
5173
+ + (f" {detail}" if detail else "") + suffix)
5174
+ _hook_tick_log_rotate_if_needed()
5175
+
5176
+ try:
5177
+ conn = open_cache_db()
5178
+ try:
5179
+ stats = sync_codex_cache(
5180
+ conn, lock_timeout=CODEX_REPLAY_DRAIN_LOCK_TIMEOUT_SECONDS)
5181
+ finally:
5182
+ conn.close()
5183
+ except Exception as exc:
5184
+ # This walk touches every rollout, so its `OSError`s carry the most
5185
+ # identifying paths of any worker on this path. `_hook_log_error_detail`
5186
+ # narrows the family's embedded `filename` away at the source; `_log`
5187
+ # defuses whatever is left.
5188
+ _log("error", error=_hook_log_error_detail(exc))
5189
+ return 0
5190
+ if getattr(stats, "lock_contended", False):
5191
+ # A contended sync returns immediately with every counter at zero, so
5192
+ # reporting it as `success files=0` claimed a drain that never walked a
5193
+ # byte — indistinguishable in the log from one that found nothing to do.
5194
+ # Harmless for convergence (the lock holder is itself a sync), but the
5195
+ # log is the only window onto a worker whose streams are /dev/null.
5196
+ _log("contended")
5197
+ return 0
5198
+ _log(
5199
+ "success",
5200
+ f"files={int(getattr(stats, 'files_processed', 0) or 0)} "
5201
+ f"torn={int(getattr(stats, 'files_deferred_torn', 0) or 0)} "
5202
+ f"failed={int(getattr(stats, 'files_failed', 0) or 0)}")
5203
+ return 0
5204
+
5205
+
5206
+ def _codex_walk_owes_unread_files(
5207
+ walk: "list[CodexDiscoveredFile]", existing: dict,
5208
+ ) -> bool:
5209
+ """Could this walk owe bytes for a reason a `stat()` sweep must measure?
5210
+
5211
+ Dict lookups only — deliberately, because this gates
5212
+ ``_codex_backlog_after``, which stats every discovered rollout. True when
5213
+ any discovered file is unknown to the cursor snapshot or is recorded as
5214
+ incompletely ingested; those are the two states a backlog can start from.
5215
+
5216
+ A store where every discovered file is known AND complete can still owe
5217
+ bytes — an active rollout that grew since the last tick — but only a couple
5218
+ of files' worth, and the walk itself commits them within the budget. Paying
5219
+ a full-tree stat sweep on every tick to pre-record that is the wrong trade:
5220
+ it is the steady state, so the sweep ran always and wrote nothing.
5221
+ """
5222
+ for item in walk:
5223
+ prev = existing.get(str(item.source_path))
5224
+ if prev is None:
5225
+ return True
5226
+ complete = prev[12]
5227
+ if complete is not None and not int(complete):
5228
+ return True
5229
+ return False
5230
+
5231
+
5232
+ def _codex_backlog_after(
5233
+ remaining: "list[CodexDiscoveredFile]", existing: dict,
5234
+ committed: "dict[str, tuple[int, int, bool]] | None" = None,
5235
+ ) -> "tuple[int, int]":
5236
+ """``(files, bytes)`` still unread across ``remaining``.
5237
+
5238
+ Stat'ed here rather than tracked incrementally: it runs once per budgeted
5239
+ tick over at most the discovered file count, and a running total would
5240
+ drift the moment a file changed size mid-walk.
5241
+
5242
+ ``existing`` is the PRE-walk cursor snapshot, so a file this tick already
5243
+ advanced would be measured from the offset it had before the tick started —
5244
+ over-reporting the partial file the budget stopped inside by everything the
5245
+ tick just committed, and reporting a brand-new file's whole length. Hence
5246
+ ``committed``: ``{path: (size_bytes, last_byte_offset, ingest_complete)}``
5247
+ for every file this walk committed, which takes precedence.
5248
+ """
5249
+ pending_files = 0
5250
+ pending_bytes = 0
5251
+ for item in remaining:
5252
+ path_str = str(item.source_path)
5253
+ record = None if committed is None else committed.get(path_str)
5254
+ if record is None:
5255
+ prev = existing.get(path_str)
5256
+ if prev is not None:
5257
+ complete = prev[12]
5258
+ record = (
5259
+ int(prev[0] or 0), int(prev[2] or 0),
5260
+ True if complete is None else bool(int(complete)),
5261
+ )
5262
+ try:
5263
+ size = item.source_path.stat().st_size
5264
+ except OSError:
5265
+ continue
5266
+ if record is None:
5267
+ offset = 0
5268
+ else:
5269
+ stored_size, offset, complete = record
5270
+ if complete and size == stored_size:
5271
+ continue # complete and unchanged: nothing owed
5272
+ owed = max(0, size - offset)
5273
+ if owed:
5274
+ pending_files += 1
5275
+ pending_bytes += owed
5276
+ return pending_files, pending_bytes
5277
+
5278
+
4868
5279
  def sync_codex_cache(
4869
5280
  conn: sqlite3.Connection,
4870
5281
  *,
@@ -4872,6 +5283,9 @@ def sync_codex_cache(
4872
5283
  rebuild: bool = False,
4873
5284
  only_paths: "set[str] | None" = None,
4874
5285
  lock_timeout: "float | None" = None,
5286
+ budget_seconds: "float | None" = None,
5287
+ active_transcript_path: "str | None" = None,
5288
+ quota_reconcile: str = "auto",
4875
5289
  _on_first_file_rollback: Callable[[], None] | None = None,
4876
5290
  _on_file_committed: Callable[[str], None] | None = None,
4877
5291
  ) -> CodexIngestStats:
@@ -4886,7 +5300,38 @@ def sync_codex_cache(
4886
5300
  so a lost race does not wipe a cache another process is actively
4887
5301
  populating. If the lock is contended on a rebuild, the cache is left
4888
5302
  untouched and the caller sees `lock_contended=True`.
5303
+
5304
+ ``budget_seconds`` (public #5 spec §4) bounds the walk in wall clock. ONLY
5305
+ the hook passes it; an explicit ``cctally cache-sync`` still runs to
5306
+ completion. The deadline is measured from function entry, not from the
5307
+ ingest loop, so lock acquisition and discovery count against it — the tick's
5308
+ whole cost is what has to fit inside Codex's hook timeout. A budgeted walk
5309
+ starts at a PERSISTED resume cursor and wraps, because the actively-appended
5310
+ files at the front of a sorted walk would otherwise consume every budget
5311
+ forever and the tail would never drain.
5312
+
5313
+ ``active_transcript_path`` is the rollout the hook's stdin payload names.
5314
+ It is ingested first so live numbers stay correct while history lags, and
5315
+ its share of the budget is capped so it cannot itself starve the backlog it
5316
+ precedes. A path that does not resolve to a discovered rollout under the
5317
+ configured Codex roots is ignored, never trusted.
5318
+
5319
+ ``quota_reconcile`` (public #5 spec §4) is ``"auto"`` for every caller but
5320
+ the hook. The hook passes ``"defer"`` and performs the single alert-eligible
5321
+ reconcile itself: it always follows this sync with an explicit
5322
+ ``reconcile_codex_quota_projection(alert_eligible_root_keys=…)``, and that
5323
+ call can never take the certificate short-circuit (it is guarded by
5324
+ ``not alert_eligible_roots``), so the sync-internal reconcile was pure
5325
+ duplicated cost — measured at roughly half of every growing turn. Every
5326
+ other caller keeps ``"auto"``, because the sync-internal reconcile is what
5327
+ keeps the projection current for the dashboard and ``cache-sync``.
4889
5328
  """
5329
+ if quota_reconcile not in ("auto", "defer"):
5330
+ raise ValueError(
5331
+ "sync_codex_cache: quota_reconcile must be 'auto' or 'defer'")
5332
+ started_at = _walk_clock()
5333
+ deadline = (
5334
+ None if budget_seconds is None else started_at + float(budget_seconds))
4890
5335
  stats = CodexIngestStats()
4891
5336
  project_after_unlock = False
4892
5337
  # Per-root instant span this sync wrote — accounting-row timestamps AND
@@ -4936,6 +5381,25 @@ def sync_codex_cache(
4936
5381
  # global quota reconcile) — see the guards threaded through below.
4937
5382
  targeted = only_paths is not None
4938
5383
 
5384
+ # The two ARGUMENT-level contracts are checked here, ahead of the replay
5385
+ # probe, and against the CALLER's `rebuild` rather than the marker-OR'd
5386
+ # one below. Stated after the probe they were unreachable whenever a
5387
+ # replay happened to be armed — the decline returns first — so
5388
+ # `sync_codex_cache(rebuild=True, budget_seconds=5)` raised or silently
5389
+ # deferred depending on a cache_meta row the caller cannot see. A
5390
+ # contract that holds only sometimes is not one.
5391
+ if targeted and rebuild:
5392
+ raise ValueError(
5393
+ "sync_codex_cache: only_paths is incompatible with rebuild")
5394
+ if rebuild and deadline is not None:
5395
+ # A rebuild commits its wipe before the walk starts, so a bounded
5396
+ # rebuild leaves the cache wiped and only partly restored. No
5397
+ # production caller combines them — the hook is the only budgeted
5398
+ # caller and it never asks for a rebuild — so this is a contract,
5399
+ # not a fallback.
5400
+ raise ValueError(
5401
+ "sync_codex_cache: budget_seconds is incompatible with rebuild")
5402
+
4939
5403
  # A pending byte-zero replay is consumed HERE, not by the migration that
4940
5404
  # armed it, so the rebuild path below captures `rebuild_known_identities`
4941
5405
  # before clearing. A migration that cleared `codex_session_files`
@@ -4946,17 +5410,41 @@ def sync_codex_cache(
4946
5410
  "SELECT 1 FROM cache_meta WHERE key=?",
4947
5411
  (CODEX_REPLAY_FROM_ZERO_KEY,),
4948
5412
  ).fetchone() is not None
4949
- if replay_pending and targeted:
5413
+ if replay_pending and (targeted or deadline is not None):
4950
5414
  # A live-tail tick must DEFER, never raise through the
4951
5415
  # `targeted and rebuild` guard below.
5416
+ #
5417
+ # A BUDGETED tick defers for a stronger reason. The replay is
5418
+ # "clear everything, then re-read everything", and the clear happens
5419
+ # ONCE at the top of the walk, so slicing it across ticks has no
5420
+ # safe outcome. Consuming the marker after a truncated walk is the
5421
+ # #416 spec D1 violation directly: every un-walked rollout that
5422
+ # predates the durable attribution map has no decision in
5423
+ # `codex_file_accounts` AND no entry in the (now-cleared)
5424
+ # `rebuild_known_identities` snapshot, so the next tick sends it to
5425
+ # the live `auth.json` branch and re-attributes historical spend to
5426
+ # whoever is logged in now. Keeping the marker instead is no better:
5427
+ # the next tick re-enters the rebuild, re-wipes the store, and
5428
+ # captures a snapshot holding only the previous tick's files — the
5429
+ # same violation, plus a walk that never converges because every
5430
+ # tick undoes the last one's progress.
5431
+ #
5432
+ # Deferring is only safe because an UNBUDGETED caller eventually
5433
+ # runs it. `cache-sync --source codex`, the dashboard sync, the TUI
5434
+ # and every Codex read command all do — but a hook-only install has
5435
+ # none of them, and that is the reporter's shape. There, every tick
5436
+ # from migration 035 onwards returned here before walking a byte
5437
+ # and Codex ingest froze permanently. So the budgeted decline
5438
+ # RECORDS itself (doctor reads the record) and hands the unbudgeted
5439
+ # drain to a detached worker.
5440
+ if deadline is not None:
5441
+ _record_codex_replay_deferral(conn)
4952
5442
  stats.deferred_reason = "replay_pending"
4953
5443
  return stats
5444
+ # Both incompatible combinations already returned at the decline above,
5445
+ # so the marker-OR'd rebuild needs no second guard.
4954
5446
  rebuild = rebuild or replay_pending
4955
5447
 
4956
- if targeted and rebuild:
4957
- raise ValueError(
4958
- "sync_codex_cache: only_paths is incompatible with rebuild")
4959
-
4960
5448
  # F4 (#313): the reconcile trigger gate is "did the Codex physical
4961
5449
  # mutation sequence advance during this sync", NOT rows_changed —
4962
5450
  # rows_changed counts only inserted accounting rows and misses
@@ -5180,13 +5668,13 @@ def sync_codex_cache(
5180
5668
  existing = {
5181
5669
  row[0]: (
5182
5670
  row[1], row[2], row[3], row[4], row[5], row[6], row[7],
5183
- row[8], row[9], row[10], row[11], row[12],
5671
+ row[8], row[9], row[10], row[11], row[12], row[13],
5184
5672
  )
5185
5673
  for row in conn.execute(
5186
5674
  "SELECT path, size_bytes, mtime_ns, last_byte_offset, "
5187
5675
  "last_session_id, last_model, last_total_tokens, source_root_key, "
5188
5676
  "last_native_thread_id, last_root_thread_id, last_parent_thread_id, "
5189
- "last_conversation_key, last_turn_id "
5677
+ "last_conversation_key, last_turn_id, ingest_complete "
5190
5678
  "FROM codex_session_files"
5191
5679
  )
5192
5680
  }
@@ -5218,6 +5706,83 @@ def sync_codex_cache(
5218
5706
  stats.deferred_reason = "truncation"
5219
5707
  return stats
5220
5708
 
5709
+ # public #5 spec §4: a budgeted walk resumes where the last one stopped
5710
+ # and puts the hook's active rollout first. Every unbudgeted caller
5711
+ # keeps the discovered order exactly, so `cache-sync`, the dashboard and
5712
+ # the targeted live-tail path are byte-identical to before.
5713
+ walk = files
5714
+ active_physical: "pathlib.Path | None" = None
5715
+ if deadline is not None and not targeted:
5716
+ if active_transcript_path:
5717
+ # Validate against the DISCOVERED set rather than trusting the
5718
+ # hook payload: an arbitrary path from stdin must never be able
5719
+ # to name a file outside the configured Codex roots.
5720
+ try:
5721
+ candidate = _canonical_codex_path(
5722
+ pathlib.Path(str(active_transcript_path)))
5723
+ except (OSError, ValueError, TypeError):
5724
+ candidate = None
5725
+ if candidate is not None and any(
5726
+ item.physical_path == candidate for item in files
5727
+ ):
5728
+ active_physical = candidate
5729
+ walk = _order_codex_walk(
5730
+ files, cursor=_load_codex_resume_cursor(conn),
5731
+ active_path=active_physical)
5732
+ # The end-of-walk record below cannot describe a walk that never
5733
+ # reaches its end. Every LATER tick is safe — `since` carries
5734
+ # forward, and a tick that dies leaves the previous record standing,
5735
+ # stale but conservative — but the FIRST budgeted tick over a fresh
5736
+ # backlog has no previous record, so dying mid-walk leaves the
5737
+ # backlog reading zero while a real one grows and doctor reports OK.
5738
+ # Record what this tick owes BEFORE it starts.
5739
+ #
5740
+ # The end-of-walk write supersedes this one (and clears it when the
5741
+ # walk drains), and reads this `since` back, so the one-hour clock
5742
+ # still starts when the backlog first appeared. On an already-drained
5743
+ # store the pre-walk figure is whatever the active rollout has
5744
+ # appended, so a reader landing inside the walk can briefly see a
5745
+ # one-file backlog — which is true at that instant, and gone by the
5746
+ # time the tick commits.
5747
+ #
5748
+ # Gated on `_codex_walk_owes_unread_files`, which is dict lookups
5749
+ # only. `_codex_backlog_after` is a full-tree `stat()` sweep, and on
5750
+ # a DRAINED store — exactly the steady state acceptance criterion 1
5751
+ # measures — the record is always absent, so the unconditional form
5752
+ # ran that sweep on every single tick and wrote nothing: 1,859 extra
5753
+ # stats per tick on the real store, on top of the walk's own. The
5754
+ # only backlog a drained store can have is bytes appended to files
5755
+ # it already knows, which is what produced the transient one-file
5756
+ # reading; the sweep is now skipped there entirely.
5757
+ if (
5758
+ _load_codex_backlog_record(conn) is None
5759
+ and _codex_walk_owes_unread_files(walk, existing)
5760
+ ):
5761
+ pre_files, pre_bytes = _codex_backlog_after(walk, existing)
5762
+ if pre_files:
5763
+ _write_codex_backlog_record(
5764
+ conn, files=pre_files, owed_bytes=pre_bytes, since=None)
5765
+ conn.commit()
5766
+ # Where the budget stopped, if it did: the files this tick never
5767
+ # reached. Empty means the walk completed a full cycle.
5768
+ unwalked: "list[CodexDiscoveredFile]" = []
5769
+ # A file the budget stopped INSIDE. The cursor points back at it so the
5770
+ # next tick resumes it immediately, rather than making it wait out a
5771
+ # whole cycle behind files that owe nothing. Last one wins, which is the
5772
+ # right cursor: it is where the OVERALL deadline landed.
5773
+ partial_file: "CodexDiscoveredFile | None" = None
5774
+ # Every file the walk stopped inside, for the backlog COUNT. There can
5775
+ # be more than one: the active rollout has its own capped share of the
5776
+ # budget, so it can stop short while the overall deadline still has room
5777
+ # and a later file then stops short too. Counting only `partial_file`
5778
+ # loses the active rollout's remainder — convergence is unaffected
5779
+ # (active-first revisits it) but the reported figure is wrong.
5780
+ partial_files: "list[CodexDiscoveredFile]" = []
5781
+ # What this walk actually committed, per file:
5782
+ # ``(size_bytes, last_byte_offset, ingest_complete)``. The backlog is
5783
+ # measured against this rather than the pre-walk `existing` snapshot,
5784
+ # which cannot see anything this tick just wrote.
5785
+ committed_state: "dict[str, tuple[int, int, bool]]" = {}
5221
5786
  # #341: per-root active-account cache, resolved once per sync (auth.json
5222
5787
  # is per provider root and rarely changes mid-sync). Keyed by
5223
5788
  # source_root_key. A torn read defers every file under that root.
@@ -5233,7 +5798,14 @@ def sync_codex_cache(
5233
5798
  # the loop stays flat, mirroring sync_cache's walk seam.
5234
5799
  _p_walk = _perf.phase("walk")
5235
5800
  _p_walk.__enter__()
5236
- for discovered in files:
5801
+ for _walk_index, discovered in enumerate(walk):
5802
+ # The budget is checked BEFORE a file is opened, so a tick either
5803
+ # commits a file whole (or to a recorded partial offset) or does not
5804
+ # touch it at all.
5805
+ if deadline is not None and _walk_clock() >= deadline:
5806
+ stats.budget_exhausted = True
5807
+ unwalked = list(walk[_walk_index:])
5808
+ break
5237
5809
  jp = discovered.source_path
5238
5810
  path_str = str(jp)
5239
5811
  try:
@@ -5248,6 +5820,12 @@ def sync_codex_cache(
5248
5820
  mtime_ns = st.st_mtime_ns
5249
5821
  prev = existing.get(path_str)
5250
5822
  start_offset = 0
5823
+ # public #5: the byte length this pass COMMITS to scanning, which
5824
+ # `codex_session_files.size_bytes` records and `ingest_complete`
5825
+ # refers to. It equals the observed size everywhere except a resume
5826
+ # of an incomplete target on a file that has since grown, where the
5827
+ # stored target is finished first.
5828
+ scan_target = size
5251
5829
  truncated = False
5252
5830
  initial_session_id: str | None = None
5253
5831
  initial_model: str | None = None
@@ -5271,11 +5849,21 @@ def sync_codex_cache(
5271
5849
  prev_size, _, prev_offset, prev_sid, prev_model, prev_ttot,
5272
5850
  prev_root_key, prev_native_thread_id, prev_root_thread_id,
5273
5851
  prev_parent_thread_id, prev_conversation_key, prev_turn_id,
5852
+ prev_complete,
5274
5853
  ) = prev
5275
5854
  prev_total_tokens = (
5276
5855
  int(prev_ttot) if prev_ttot is not None else None
5277
5856
  )
5278
5857
  requalified = prev_root_key != discovered.source_root_key
5858
+ # public #5 spec §4. `ingest_complete` is 1 for every row a
5859
+ # pre-budget binary wrote and for every file read to its stored
5860
+ # target, so this branch is unreachable until a budgeted stop
5861
+ # writes a 0 — and once one does, the OLD order was wrong: it
5862
+ # compared `size` (the file's full observed length, persisted
5863
+ # whatever offset ingestion reached) against `prev_size` and
5864
+ # skipped on equality, which made the unread suffix permanently
5865
+ # invisible on any rollout that never grows again.
5866
+ incomplete = prev_complete is not None and not int(prev_complete)
5279
5867
  if targeted and (requalified or size < prev_size):
5280
5868
  # §5.1 preflight-snapshot scoped: a shrink or requalification
5281
5869
  # landing AFTER the preflight is declined HERE, per file —
@@ -5287,10 +5875,32 @@ def sync_codex_cache(
5287
5875
  # — that whole-cache-affecting escalation is the full sync's.
5288
5876
  stats.files_failed += 1
5289
5877
  continue
5290
- if not requalified and size == prev_size:
5878
+ if not requalified and incomplete and size >= prev_offset:
5879
+ # Resume the stored scan target. Deliberately NOT a
5880
+ # `delta_append`: that flag is what authorizes consulting
5881
+ # the live `auth.json` and minting a new account range at
5882
+ # the resume offset, so a resumed backlog would otherwise
5883
+ # acquire a later account merely because processing was
5884
+ # sliced across hooks. The same bytes read in one pass
5885
+ # carry the account decided at offset 0, and where a budget
5886
+ # happened to stop must not change that answer.
5887
+ #
5888
+ # A file that GREW while its target was incomplete keeps
5889
+ # the STORED target: the read is capped at `prev_size` and
5890
+ # the row re-commits that same size, so the suffix written
5891
+ # after the stop becomes an ordinary delta append on the
5892
+ # next tick — and may then legitimately mint a new range,
5893
+ # because those bytes are genuinely new rather than merely
5894
+ # deferred.
5895
+ start_offset = prev_offset
5896
+ scan_target = min(size, prev_size)
5897
+ initial_session_id = prev_sid
5898
+ initial_model = prev_model
5899
+ initial_total_tokens = prev_total_tokens or 0
5900
+ elif not requalified and not incomplete and size == prev_size:
5291
5901
  stats.files_skipped_unchanged += 1
5292
5902
  continue
5293
- if not requalified and size > prev_size:
5903
+ elif not requalified and not incomplete and size > prev_size:
5294
5904
  start_offset = prev_offset
5295
5905
  delta_append = True
5296
5906
  initial_session_id = prev_sid
@@ -5352,14 +5962,19 @@ def sync_codex_cache(
5352
5962
  # The guard is the whole safety argument: `delta_append` means
5353
5963
  # `start_offset` is this file's ingest watermark, and the second
5354
5964
  # condition means the new range starts strictly beyond every
5355
- # decided range. Today the term is algebraically redundant:
5356
- # every non-delta branch sets `start_offset = 0`, while every
5357
- # decided range starts at a non-negative offset, so the strict
5358
- # comparison alone implies a delta append. Keep the explicit
5359
- # term as belt-and-suspenders: it pins the semantic permission
5360
- # to consult auth.json if a future branch changes the offsets.
5361
- # Auth can therefore mint a range only for bytes NOBODY has
5362
- # attributed yet; it never re-decides covered bytes.
5965
+ # decided range. Auth can therefore mint a range only for bytes
5966
+ # NOBODY has attributed yet; it never re-decides covered bytes.
5967
+ #
5968
+ # The `delta_append` term is LOAD-BEARING, not belt-and-
5969
+ # suspenders. It used to be algebraically redundant, because
5970
+ # every non-delta branch set `start_offset = 0` and the strict
5971
+ # comparison alone therefore implied a delta append. Public #5's
5972
+ # resumable ingest broke that: the incomplete-resume branch sets
5973
+ # `start_offset = prev_offset` WITHOUT `delta_append`, exactly so
5974
+ # a resumed backlog keeps the account decided at offset zero
5975
+ # rather than acquiring a later one because a budget happened to
5976
+ # slice the work. Drop this term and that resume mints a fresh
5977
+ # range from the live `auth.json` at its stop point.
5363
5978
  if delta_append and start_offset > account_ranges[-1][0]:
5364
5979
  root_account = _live_root_account()
5365
5980
  if root_account.status == "torn":
@@ -5477,6 +6092,36 @@ def sync_codex_cache(
5477
6092
  context_window=None,
5478
6093
  )
5479
6094
  yielded_count = 0
6095
+ # public #5: did the read stop SHORT of its scan target? Only that
6096
+ # makes the row incomplete. Reaching the target, a natural EOF and a
6097
+ # trailing incomplete JSONL line are all COMPLETE for the target
6098
+ # this pass committed to — the iterator seeks back to that line's
6099
+ # start and a later size increase resumes it correctly.
6100
+ stopped_short = {"value": False}
6101
+ # The active rollout gets a CAPPED share of the budget. Without the
6102
+ # cap, a huge live transcript would consume every tick and the
6103
+ # backlog behind it would never drain — priority is meant to keep
6104
+ # live numbers correct, not to reorder starvation.
6105
+ file_deadline = deadline
6106
+ if (
6107
+ deadline is not None and active_physical is not None
6108
+ and discovered.physical_path == active_physical
6109
+ ):
6110
+ file_deadline = min(deadline, started_at + float(
6111
+ budget_seconds) * _CODEX_ACTIVE_FIRST_BUDGET_FRACTION)
6112
+
6113
+ def _stop_before(
6114
+ offset, _limit=scan_target, _short=stopped_short,
6115
+ _deadline=file_deadline,
6116
+ ):
6117
+ # Reaching the target ends the read without making it partial.
6118
+ if offset >= _limit:
6119
+ return True
6120
+ if _deadline is not None and _walk_clock() >= _deadline:
6121
+ _short["value"] = True
6122
+ return True
6123
+ return False
6124
+
5480
6125
  try:
5481
6126
  with open(jp, "rb") as fh:
5482
6127
  fh.seek(start_offset)
@@ -5488,6 +6133,7 @@ def sync_codex_cache(
5488
6133
  initial_total_tokens=initial_total_tokens,
5489
6134
  source_root_key=discovered.source_root_key,
5490
6135
  state=iter_state,
6136
+ stop_before=_stop_before,
5491
6137
  ):
5492
6138
  event = emission.event
5493
6139
  for quota in emission.quotas:
@@ -5665,7 +6311,11 @@ def sync_codex_cache(
5665
6311
  conn,
5666
6312
  discovered=discovered,
5667
6313
  path_str=path_str,
5668
- size=size,
6314
+ # The SCAN TARGET, not the observed size: `size_bytes`
6315
+ # is what `ingest_complete` refers to, and a resume that
6316
+ # finishes an old target on a file that has since grown
6317
+ # must leave the suffix visible as a delta append.
6318
+ size=scan_target,
5669
6319
  mtime_ns=mtime_ns,
5670
6320
  final_offset=final_offset,
5671
6321
  last_session_id=new_last_session_id,
@@ -5691,6 +6341,7 @@ def sync_codex_cache(
5691
6341
  incarnation=incarnation,
5692
6342
  file_account_decision=pending_decision,
5693
6343
  anchor_resolver=anchor_resolver,
6344
+ ingest_complete=not stopped_short["value"],
5694
6345
  )
5695
6346
  except sqlite3.DatabaseError as exc:
5696
6347
  conn.rollback()
@@ -5723,6 +6374,12 @@ def sync_codex_cache(
5723
6374
  anchor_resolver.discard_uncommitted_file()
5724
6375
  continue
5725
6376
  anchor_resolver.mark_file_committed()
6377
+ committed_state[path_str] = (
6378
+ scan_target, final_offset, not stopped_short["value"])
6379
+ if stopped_short["value"]:
6380
+ stats.budget_exhausted = True
6381
+ partial_file = discovered
6382
+ partial_files.append(discovered)
5726
6383
 
5727
6384
  if not rebuild:
5728
6385
  # Accounting timestamps share one producer spelling, so the
@@ -5753,9 +6410,73 @@ def sync_codex_cache(
5753
6410
  if progress is not None:
5754
6411
  progress(stats)
5755
6412
  _p_walk.__exit__(None, None, None)
6413
+ # public #5: did this walk actually reach every discovered file? The
6414
+ # end-of-walk markers below describe the WHOLE TREE, and a budget stop
6415
+ # increments neither `files_failed` nor `files_deferred_torn` — so their
6416
+ # zero-count "everything was fine" branches would be satisfied TRIVIALLY
6417
+ # by a walk that looked at almost nothing. Every such branch is gated on
6418
+ # this instead. Both terms are checked because they are set at different
6419
+ # points: the pre-open deadline check fills `unwalked`, while a stop
6420
+ # INSIDE a file only sets the flag.
6421
+ walk_complete = not stats.budget_exhausted and not unwalked
5756
6422
  _p_walk.set_count(stats.files_processed)
5757
6423
  _p_walk.set_meta(skipped=stats.files_skipped_unchanged,
5758
6424
  rows=stats.rows_changed)
6425
+ # public #5 spec §4/§5: the resume cursor and the backlog record.
6426
+ #
6427
+ # Written once here rather than inside every per-file commit. The
6428
+ # property the spec is protecting is that a crash cannot spuriously
6429
+ # restart the one-hour staleness clock, and that holds: `since` is
6430
+ # carried forward from the existing record and only minted when a
6431
+ # backlog first appears, so a crashed tick leaves the PREVIOUS record
6432
+ # standing (stale but conservative, and recomputed next tick) rather
6433
+ # than erasing the clock.
6434
+ # Runs for every whole-tree sync, budgeted or not: an explicit
6435
+ # `cache-sync` completes the walk and must therefore CLEAR the backlog
6436
+ # it just drained, or doctor and the dashboard would keep reporting it.
6437
+ if not targeted:
6438
+ # Two different questions. The CURSOR asks "where does the next
6439
+ # tick resume", which is where the overall deadline landed:
6440
+ # `partial_file` (the last stop-short) if there is one, else the
6441
+ # first file the walk never opened. The BACKLOG asks "what is still
6442
+ # owed", which is every stop-short plus everything unwalked.
6443
+ cursor_head = (
6444
+ partial_file if partial_file is not None
6445
+ else (unwalked[0] if unwalked else None))
6446
+ backlog_files, backlog_bytes = _codex_backlog_after(
6447
+ partial_files + unwalked, existing, committed_state)
6448
+ stats.backlog_files = backlog_files
6449
+ stats.backlog_bytes = backlog_bytes
6450
+ if cursor_head is not None:
6451
+ head = cursor_head
6452
+ ordinal = 0
6453
+ head_key = _codex_walk_key(head)
6454
+ for index, item in enumerate(files):
6455
+ # Qualified by root, exactly like the cursor it records: two
6456
+ # configured roots can resolve to the same canonical path,
6457
+ # and matching on the path alone would then store the wrong
6458
+ # ordinal for the fallback.
6459
+ if _codex_walk_key(item) == head_key:
6460
+ ordinal = index
6461
+ break
6462
+ _set_cache_meta(conn, _CODEX_RESUME_CURSOR_KEY, json.dumps({
6463
+ "root_key": head.source_root_key,
6464
+ "path": str(head.physical_path),
6465
+ "ordinal": ordinal,
6466
+ }, sort_keys=True))
6467
+ else:
6468
+ # A complete cycle: the next tick starts at the top again.
6469
+ conn.execute("DELETE FROM cache_meta WHERE key = ?",
6470
+ (_CODEX_RESUME_CURSOR_KEY,))
6471
+ if backlog_files:
6472
+ prior = _load_codex_backlog_record(conn)
6473
+ _write_codex_backlog_record(
6474
+ conn, files=backlog_files, owed_bytes=backlog_bytes,
6475
+ since=None if prior is None else prior.get("since"))
6476
+ else:
6477
+ conn.execute("DELETE FROM cache_meta WHERE key = ?",
6478
+ (_CODEX_BACKLOG_KEY,))
6479
+ conn.commit()
5759
6480
  # #279 S2 F1: rolling parse-health record (codex half). Same
5760
6481
  # anomaly-delta gate as the Claude tail; the global writer flock
5761
6482
  # excludes a concurrent Claude sync.
@@ -5782,6 +6503,12 @@ def sync_codex_cache(
5782
6503
  # NOT R8-gated. This is a health signal, not account decoration — it
5783
6504
  # names no account and adds no per-account column, the same carve-out
5784
6505
  # `alerts.log`'s runtime state has (docs/accounts-gotchas.md).
6506
+ #
6507
+ # Clearing it requires a walk that actually reached every file
6508
+ # (`walk_complete`). A budgeted walk that stopped before the torn file
6509
+ # defers nothing and would otherwise clear a marker describing a real,
6510
+ # ongoing condition — `doctor` would stop reporting the frozen login
6511
+ # while every rollout under that root stayed stalled.
5785
6512
  if not targeted:
5786
6513
  if stats.files_deferred_torn:
5787
6514
  _set_cache_meta(conn, "codex_torn_auth_deferred", json.dumps({
@@ -5789,7 +6516,7 @@ def sync_codex_cache(
5789
6516
  "at": dt.datetime.now(dt.timezone.utc).isoformat(
5790
6517
  timespec="seconds").replace("+00:00", "Z"),
5791
6518
  }, sort_keys=True))
5792
- else:
6519
+ elif walk_complete:
5793
6520
  conn.execute("DELETE FROM cache_meta WHERE key = ?",
5794
6521
  ("codex_torn_auth_deferred",))
5795
6522
  # Consume the byte-zero replay marker only after a clean full walk,
@@ -5803,13 +6530,30 @@ def sync_codex_cache(
5803
6530
  # exclusive lock today, so nothing can arm the marker in between —
5804
6531
  # but the conversations side has no such exclusion, and the two
5805
6532
  # clears must keep the same shape.
5806
- if stats.files_failed == 0 and stats.files_deferred_torn == 0:
6533
+ #
6534
+ # `walk_complete` is the third term for the same reason the torn
6535
+ # marker needs it: a budget-truncated walk fails nothing and defers
6536
+ # nothing, so the zero-count condition alone would consume a marker
6537
+ # (and erase a blocked record) on a walk that reached almost no
6538
+ # file. It is defense in depth today — a budgeted tick declines a
6539
+ # pending replay outright above — but the two clears must keep the
6540
+ # same shape.
6541
+ if (
6542
+ walk_complete
6543
+ and stats.files_failed == 0
6544
+ and stats.files_deferred_torn == 0
6545
+ ):
5807
6546
  if replay_pending:
5808
6547
  conn.execute("DELETE FROM cache_meta WHERE key = ?",
5809
6548
  (CODEX_REPLAY_FROM_ZERO_KEY,))
5810
6549
  conn.execute("DELETE FROM cache_meta WHERE key = ?",
5811
6550
  (CODEX_REPLAY_BLOCKED_KEY,))
5812
- elif replay_pending:
6551
+ # The budgeted-decline record is cleared by the same walk, for
6552
+ # the same reason: an unbudgeted walk reaching here is exactly
6553
+ # the caller whose absence the record was reporting.
6554
+ conn.execute("DELETE FROM cache_meta WHERE key = ?",
6555
+ (CODEX_REPLAY_DEFERRED_KEY,))
6556
+ elif replay_pending and walk_complete:
5813
6557
  # A full walk ran and could NOT consume the marker, so the
5814
6558
  # replay — and with it every Codex transcript ingest, which
5815
6559
  # defers behind this marker — is stalled rather than merely
@@ -5822,6 +6566,31 @@ def sync_codex_cache(
5822
6566
  "files_deferred_torn": stats.files_deferred_torn,
5823
6567
  }, sort_keys=True))
5824
6568
  conn.commit()
6569
+ # Public #5: resolve any still-unstamped `quota_window_snapshots.
6570
+ # observed_model` from the accounting corpus, using the exact expression
6571
+ # the read path used before the fallback was removed. Cache migration
6572
+ # 039 is the one-time leg; this is the standing one, because `db skip
6573
+ # 039_…` and a fresh journal-repopulated cache both bypass the migration
6574
+ # and would then classify a Spark window as account weekly quota (#373).
6575
+ # Ordered BEFORE the spend adoption below, which reads the model to
6576
+ # decide `model_scoped` and must not see a stale NULL. A row it changes
6577
+ # is real interpretation drift, so the physical mutation sequence
6578
+ # advances with it — otherwise the projection certificate would still
6579
+ # read as current and the reconcile would short-circuit past the ledger
6580
+ # entries the triggers just wrote. Best-effort, like the adoption below:
6581
+ # the resolution is fully re-derivable on the next sync.
6582
+ try:
6583
+ resolved_models = _cctally_db_sib.backfill_codex_quota_observed_model(
6584
+ conn)
6585
+ if resolved_models:
6586
+ _bump_codex_physical_mutation_seq(conn)
6587
+ conn.commit()
6588
+ except sqlite3.DatabaseError as exc:
6589
+ conn.rollback()
6590
+ if _cctally_db_sib._is_sqlite_corruption_error(exc):
6591
+ raise
6592
+ eprint("[cache-sync] could not resolve Codex quota model "
6593
+ f"attribution: {exc}")
5825
6594
  # Window-scoped spend adoption (spec
5826
6595
  # docs/superpowers/specs/2026-07-30-codex-window-scoped-spend-adoption.md).
5827
6596
  # Runs AFTER the walk committed and while both cache writer flocks are
@@ -5889,7 +6658,12 @@ def sync_codex_cache(
5889
6658
  # project_after_unlock / deferred_cert_* keep
5890
6659
  # their no-op defaults — the post-flock reconcile paths below then all
5891
6660
  # short-circuit for a targeted call.
5892
- if not targeted:
6661
+ #
6662
+ # `quota_reconcile="defer"` skips the whole decision, including the
6663
+ # post-flock stats.db open below: the hook does its own reconcile
6664
+ # immediately after this call, and that reconcile re-reads the same
6665
+ # certificate and signatures itself.
6666
+ if not targeted and quota_reconcile == "auto":
5893
6667
  cur_seq = codex_physical_mutation_seq(conn)
5894
6668
  if cur_seq != seq_before:
5895
6669
  project_after_unlock = True
@@ -5937,7 +6711,7 @@ def sync_codex_cache(
5937
6711
  project_after_unlock = True
5938
6712
  finally:
5939
6713
  stats_conn.close()
5940
- if project_after_unlock:
6714
+ if project_after_unlock and quota_reconcile == "auto":
5941
6715
  from _cctally_quota import reconcile_codex_quota_projection
5942
6716
  reconcile_codex_quota_projection()
5943
6717
  return stats
@@ -7895,13 +8669,21 @@ def _prepare_claude_conversation_maintenance(
7895
8669
  *,
7896
8670
  rebuild: bool,
7897
8671
  targeted: bool,
7898
- ) -> None:
8672
+ ) -> bool:
7899
8673
  """Consume transcript-only upgrade work under the conversation flock.
7900
8674
 
7901
8675
  These consumers historically ran inside ``sync_cache`` because prose and
7902
8676
  accounting shared one database. Keeping them here is the load-bearing
7903
8677
  half of the #320 split: schema upgrades may re-derive transcript state, but
7904
8678
  they never extend the core-cache critical section.
8679
+
8680
+ Returns whether it performed a FROM-ZERO replay. The resumable reingest
8681
+ deletes and reconstructs each source file from offset zero, which restores
8682
+ rows the throttled retention prune already trimmed — and the caller's
8683
+ ``did_from_zero_replay`` was previously set only by
8684
+ ``rebuild or stats.files_reset_truncated > 0``, so a replay consumed here
8685
+ never reached ``_force_retention_prune_after_replay()`` and pruned history
8686
+ silently reappeared until the next ordinary prune.
7905
8687
  """
7906
8688
  if rebuild:
7907
8689
  # The offset-zero walk below re-derives every transcript projection.
@@ -7914,6 +8696,7 @@ def _prepare_claude_conversation_maintenance(
7914
8696
  "'conversation_media_reingest_pending',"
7915
8697
  "'conversation_queued_prompt_reingest_pending',"
7916
8698
  "'conversation_reingest_nested_agent_pending',"
8699
+ "'conversation_background_mcp_reingest_pending',"
7917
8700
  "'conversation_reingest_file_touches_pending',"
7918
8701
  "'conversation_file_touches_cursor',"
7919
8702
  "'conversation_reingest_cursor',"
@@ -7940,10 +8723,12 @@ def _prepare_claude_conversation_maintenance(
7940
8723
  )
7941
8724
  _set_cache_meta(conn, "conversation_sessions_backfill_pending", "1")
7942
8725
  conn.commit()
7943
- return
8726
+ # The caller's own offset-zero walk is the replay; it already sets
8727
+ # did_from_zero_replay for the rebuild case.
8728
+ return False
7944
8729
 
7945
8730
  if targeted:
7946
- return
8731
+ return False
7947
8732
 
7948
8733
  if conn.execute(
7949
8734
  "SELECT 1 FROM cache_meta WHERE key='conversation_backfill_pending'"
@@ -7971,7 +8756,8 @@ def _prepare_claude_conversation_maintenance(
7971
8756
  "'conversation_reingest_enrichment_pending',"
7972
8757
  "'conversation_media_reingest_pending',"
7973
8758
  "'conversation_queued_prompt_reingest_pending',"
7974
- "'conversation_reingest_nested_agent_pending')"
8759
+ "'conversation_reingest_nested_agent_pending',"
8760
+ "'conversation_background_mcp_reingest_pending')"
7975
8761
  ).fetchone() is not None
7976
8762
  if reingest:
7977
8763
  _resumable_reingest_conversation_messages(conn)
@@ -7982,6 +8768,7 @@ def _prepare_claude_conversation_maintenance(
7982
8768
  _consume_promote_command_args(conn)
7983
8769
  _consume_title_fts(conn)
7984
8770
  _consume_file_touches(conn)
8771
+ return reingest
7985
8772
 
7986
8773
 
7987
8774
  def _report_conversation_progress(
@@ -8047,7 +8834,7 @@ def sync_claude_conversations(
8047
8834
  conn.commit()
8048
8835
 
8049
8836
  _report_conversation_progress(progress, "prepare", stats)
8050
- _prepare_claude_conversation_maintenance(
8837
+ maintenance_replayed = _prepare_claude_conversation_maintenance(
8051
8838
  conn, rebuild=rebuild, targeted=targeted
8052
8839
  )
8053
8840
 
@@ -8219,7 +9006,10 @@ def sync_claude_conversations(
8219
9006
  _report_conversation_progress(progress, "checkpoint", stats)
8220
9007
  _harden_conversation_sidecars()
8221
9008
  _maybe_truncate_wal(conn, _cctally_core.CONVERSATIONS_DB_PATH)
8222
- did_from_zero_replay = rebuild or stats.files_reset_truncated > 0
9009
+ # `maintenance_replayed` is the third from-zero source: a resumable
9010
+ # reingest consumed above also rebuilds every source file from offset 0.
9011
+ did_from_zero_replay = (
9012
+ rebuild or stats.files_reset_truncated > 0 or maintenance_replayed)
8223
9013
  finally:
8224
9014
  try:
8225
9015
  fcntl.flock(lock_fh, fcntl.LOCK_UN)