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.
- package/CHANGELOG.md +32 -0
- package/bin/_cctally_cache.py +827 -37
- package/bin/_cctally_config.py +125 -0
- package/bin/_cctally_core.py +86 -2
- package/bin/_cctally_dashboard_cache_report.py +31 -0
- package/bin/_cctally_dashboard_conversation.py +26 -7
- package/bin/_cctally_dashboard_sources.py +51 -0
- package/bin/_cctally_db.py +626 -0
- package/bin/_cctally_doctor.py +84 -1
- package/bin/_cctally_journal.py +2 -1
- package/bin/_cctally_parser.py +42 -0
- package/bin/_cctally_quota.py +1358 -112
- package/bin/_cctally_record.py +249 -9
- package/bin/_cctally_setup.py +14 -5
- package/bin/_cctally_store.py +16 -1
- package/bin/_cctally_tui.py +16 -2
- package/bin/_cctally_update.py +9 -2
- package/bin/_lib_background_mcp.py +168 -0
- package/bin/_lib_cache_report.py +19 -2
- package/bin/_lib_codex_conversation.py +8 -0
- package/bin/_lib_codex_conversation_query.py +8 -7
- package/bin/_lib_conversation.py +105 -5
- package/bin/_lib_conversation_dispatch.py +15 -4
- package/bin/_lib_conversation_query.py +294 -2
- package/bin/_lib_dashboard_sources.py +5 -1
- package/bin/_lib_doctor.py +202 -1
- package/bin/_lib_jsonl.py +12 -0
- package/bin/_lib_quota_alert_axes.py +188 -0
- package/bin/_lib_quota_ledger.py +274 -0
- package/bin/_lib_snapshot_cache.py +36 -0
- package/bin/cctally +6 -3
- package/dashboard/static/assets/index-BgoYXdus.js +92 -0
- package/dashboard/static/assets/index-Ub8vwz1M.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +4 -1
- package/dashboard/static/assets/index-B0ZCsoxI.css +0 -1
- package/dashboard/static/assets/index-Bvp8mxtz.js +0 -92
package/bin/_cctally_cache.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
5356
|
-
#
|
|
5357
|
-
#
|
|
5358
|
-
#
|
|
5359
|
-
#
|
|
5360
|
-
#
|
|
5361
|
-
#
|
|
5362
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
) ->
|
|
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
|
-
|
|
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
|
-
|
|
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)
|