cctally 1.75.1 → 1.76.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.
- package/CHANGELOG.md +11 -0
- package/bin/_cctally_dashboard_sources.py +25 -3
- package/bin/_cctally_doctor.py +14 -0
- package/bin/_cctally_quota.py +55 -3
- package/bin/_cctally_statusline.py +0 -2
- package/bin/_lib_conversation_retention.py +55 -12
- package/bin/_lib_doctor.py +49 -0
- package/bin/_lib_jsonl.py +23 -6
- package/bin/_lib_statusline.py +0 -26
- package/dashboard/static/assets/{index-fZRxsSf5.js → index-CsDAxBT5.js} +1 -1
- package/dashboard/static/dashboard.html +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,17 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.76.0] - 2026-07-20
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `cctally doctor` now reports `db.reclaimable` when at least 25% of `cache.db` pages are free, with a direct `cctally db vacuum --db cache` remediation. (#315)
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- Codex dashboard Weekly cycles and `$ / 1%` accounting now keep the separately-metered GPT-5.3-Codex-Spark quota pool distinct from the standard seven-day Codex pool, preventing brief Spark sessions from creating phantom standard weeks.
|
|
15
|
+
- Dashboard Codex Weekly cost deltas now use the shared fractional-ratio contract instead of rendering 100× too large, and the Codex **$/1% Trend** table once again aligns Used%, $/1%, and vs-prior values under their matching columns.
|
|
16
|
+
- Claude dashboard quota now keeps updating automatically while Claude Code uses a bracketed context variant such as `opus[1m]`; context-window metadata no longer suppresses the valid account-wide 5-hour and 7-day observations supplied to the status line.
|
|
17
|
+
- Conversation-retention pruning now commits each whole Claude session or Codex conversation separately while retaining its maintenance locks, bounding first-prune WAL growth without exposing partially deleted conversations; a 9.37 GB production-shaped benchmark reduced peak WAL from 889.7 MiB to 28.8 MiB with identical deleted-row counts and FTS integrity. (#315)
|
|
18
|
+
|
|
8
19
|
## [1.75.1] - 2026-07-20
|
|
9
20
|
|
|
10
21
|
### Fixed
|
|
@@ -45,7 +45,7 @@ from _lib_quota import (
|
|
|
45
45
|
quota_freshness,
|
|
46
46
|
select_baseline,
|
|
47
47
|
)
|
|
48
|
-
from _lib_jsonl import CodexEntry
|
|
48
|
+
from _lib_jsonl import CodexEntry, codex_model_scoped_quota_pool
|
|
49
49
|
from _lib_fmt import stable_sum
|
|
50
50
|
from _lib_aggregators import _aggregate_codex_buckets
|
|
51
51
|
from _lib_five_hour import _FIVE_HOUR_JITTER_FLOOR_SECONDS
|
|
@@ -103,6 +103,21 @@ class CodexWeeklyPeriod:
|
|
|
103
103
|
used_percent: float | None = None
|
|
104
104
|
|
|
105
105
|
|
|
106
|
+
def _is_model_scoped_codex_quota(logical_limit_key: object) -> bool:
|
|
107
|
+
"""Whether an interpreted native identity belongs outside standard quota."""
|
|
108
|
+
if not isinstance(logical_limit_key, str):
|
|
109
|
+
return False
|
|
110
|
+
try:
|
|
111
|
+
payload = json.loads(logical_limit_key)
|
|
112
|
+
except (json.JSONDecodeError, TypeError):
|
|
113
|
+
return False
|
|
114
|
+
return (
|
|
115
|
+
isinstance(payload, dict)
|
|
116
|
+
and isinstance(payload.get("modelPool"), str)
|
|
117
|
+
and bool(payload["modelPool"].strip())
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
106
121
|
def _resolve_codex_weekly_cycle(
|
|
107
122
|
observations: Iterable[object],
|
|
108
123
|
now_utc: dt.datetime,
|
|
@@ -113,6 +128,8 @@ def _resolve_codex_weekly_cycle(
|
|
|
113
128
|
for history in build_history(tuple(observations)):
|
|
114
129
|
if history.identity.window_minutes != 10_080:
|
|
115
130
|
continue
|
|
131
|
+
if _is_model_scoped_codex_quota(history.identity.logical_limit_key):
|
|
132
|
+
continue
|
|
116
133
|
baseline = select_baseline(history.observations, now_utc)
|
|
117
134
|
if baseline is None or baseline.resets_at <= now_utc:
|
|
118
135
|
continue
|
|
@@ -161,7 +178,8 @@ def _codex_weekly_periods(
|
|
|
161
178
|
placeholders = ",".join("?" for _ in roots)
|
|
162
179
|
try:
|
|
163
180
|
rows = stats_conn.execute(
|
|
164
|
-
"SELECT source_root_key,
|
|
181
|
+
"SELECT source_root_key, logical_limit_key, resets_at_utc, "
|
|
182
|
+
"nominal_start_at_utc, current_percent "
|
|
165
183
|
"FROM quota_window_blocks "
|
|
166
184
|
"WHERE source='codex' AND window_minutes=10080 "
|
|
167
185
|
f"AND source_root_key IN ({placeholders}) AND orphaned_at IS NULL "
|
|
@@ -174,7 +192,9 @@ def _codex_weekly_periods(
|
|
|
174
192
|
|
|
175
193
|
raw_boundaries: list[tuple[dt.datetime, dt.datetime, set[str], list[float]]] = []
|
|
176
194
|
|
|
177
|
-
for root_key, resets_at_raw, start_at_raw, current_percent in rows:
|
|
195
|
+
for root_key, logical_limit_key, resets_at_raw, start_at_raw, current_percent in rows:
|
|
196
|
+
if _is_model_scoped_codex_quota(logical_limit_key):
|
|
197
|
+
continue
|
|
178
198
|
try:
|
|
179
199
|
start_at = dt.datetime.fromisoformat(str(start_at_raw).replace("Z", "+00:00"))
|
|
180
200
|
resets_at = dt.datetime.fromisoformat(str(resets_at_raw).replace("Z", "+00:00"))
|
|
@@ -1833,6 +1853,8 @@ def _build_codex_native_weekly_view(
|
|
|
1833
1853
|
labels: dict[str, str] = {}
|
|
1834
1854
|
periods_by_bucket: dict[str, CodexWeeklyPeriod] = {}
|
|
1835
1855
|
for entry in entries:
|
|
1856
|
+
if codex_model_scoped_quota_pool(getattr(entry, "model", None)) is not None:
|
|
1857
|
+
continue
|
|
1836
1858
|
timestamp = getattr(entry, "timestamp").astimezone(UTC)
|
|
1837
1859
|
root_key = str(getattr(entry, "source_root_key", "") or "")
|
|
1838
1860
|
period = next((
|
package/bin/_cctally_doctor.py
CHANGED
|
@@ -385,10 +385,21 @@ def doctor_gather_state(
|
|
|
385
385
|
|
|
386
386
|
cache_entries_count = None
|
|
387
387
|
cache_last_entry_at = None
|
|
388
|
+
cache_db_page_count = None
|
|
389
|
+
cache_db_freelist_count = None
|
|
388
390
|
try:
|
|
389
391
|
if _cctally_core.CACHE_DB_PATH.exists():
|
|
390
392
|
conn = sqlite3.connect(str(_cctally_core.CACHE_DB_PATH))
|
|
391
393
|
try:
|
|
394
|
+
try:
|
|
395
|
+
row = conn.execute("PRAGMA page_count").fetchone()
|
|
396
|
+
if row and row[0] is not None:
|
|
397
|
+
cache_db_page_count = int(row[0])
|
|
398
|
+
row = conn.execute("PRAGMA freelist_count").fetchone()
|
|
399
|
+
if row and row[0] is not None:
|
|
400
|
+
cache_db_freelist_count = int(row[0])
|
|
401
|
+
except sqlite3.Error:
|
|
402
|
+
pass
|
|
392
403
|
row = conn.execute(
|
|
393
404
|
"SELECT COUNT(*), MAX(timestamp_utc) FROM session_entries"
|
|
394
405
|
).fetchone()
|
|
@@ -863,6 +874,9 @@ def doctor_gather_state(
|
|
|
863
874
|
locks_held=locks_held,
|
|
864
875
|
# #297: cache.db WAL size backstop (gathered outside the deep branch).
|
|
865
876
|
cache_db_wal_bytes=cache_db_wal_bytes,
|
|
877
|
+
# #315: read-only cache free-page evidence for the reclaim hint.
|
|
878
|
+
cache_db_page_count=cache_db_page_count,
|
|
879
|
+
cache_db_freelist_count=cache_db_freelist_count,
|
|
866
880
|
codex_quota_windows=codex_quota_windows,
|
|
867
881
|
codex_hook_roots=codex_hook_roots,
|
|
868
882
|
codex_lifecycle_activity_24h=codex_lifecycle_activity_24h,
|
package/bin/_cctally_quota.py
CHANGED
|
@@ -41,10 +41,12 @@ from _lib_quota import (
|
|
|
41
41
|
source_path_key,
|
|
42
42
|
)
|
|
43
43
|
from _lib_json_envelope import stamp_schema_version
|
|
44
|
+
from _lib_jsonl import _codex_logical_limit_key, codex_model_scoped_quota_pool
|
|
44
45
|
|
|
45
46
|
|
|
46
47
|
UTC = dt.timezone.utc
|
|
47
48
|
_DASHBOARD_PROJECTION_CERTIFICATE_KEY = "codex_quota_projection_certificate"
|
|
49
|
+
_CODEX_QUOTA_INTERPRETATION_VERSION = 2
|
|
48
50
|
|
|
49
51
|
|
|
50
52
|
@dataclass(frozen=True)
|
|
@@ -134,6 +136,11 @@ def load_codex_quota_projection_certificate(
|
|
|
134
136
|
if row is None:
|
|
135
137
|
return None
|
|
136
138
|
payload = json.loads(str(row[0]))
|
|
139
|
+
if (
|
|
140
|
+
int(payload["interpretationVersion"])
|
|
141
|
+
!= _CODEX_QUOTA_INTERPRETATION_VERSION
|
|
142
|
+
):
|
|
143
|
+
return None
|
|
137
144
|
sequence = int(payload["sequence"])
|
|
138
145
|
signatures = {
|
|
139
146
|
str(root_key): str(signature)
|
|
@@ -169,6 +176,7 @@ def _store_codex_quota_projection_certificate(
|
|
|
169
176
|
conn.rollback()
|
|
170
177
|
return
|
|
171
178
|
payload = json.dumps({
|
|
179
|
+
"interpretationVersion": _CODEX_QUOTA_INTERPRETATION_VERSION,
|
|
172
180
|
"sequence": sequence,
|
|
173
181
|
"signatures": dict(sorted(signatures.items())),
|
|
174
182
|
}, sort_keys=True, separators=(",", ":"))
|
|
@@ -262,14 +270,51 @@ def load_codex_quota_observations(
|
|
|
262
270
|
previous_row_factory = conn.row_factory
|
|
263
271
|
try:
|
|
264
272
|
conn.row_factory = sqlite3.Row
|
|
273
|
+
|
|
274
|
+
def has_columns(table: str, required: set[str]) -> bool:
|
|
275
|
+
columns = {
|
|
276
|
+
str(row[1]) for row in conn.execute(
|
|
277
|
+
f"PRAGMA table_info({table})"
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
return required <= columns
|
|
281
|
+
|
|
282
|
+
has_conversation_events = has_columns(
|
|
283
|
+
"codex_conversation_events",
|
|
284
|
+
{"source_path", "line_offset", "record_type", "payload_json"},
|
|
285
|
+
)
|
|
286
|
+
has_session_entries = has_columns(
|
|
287
|
+
"codex_session_entries", {"source_path", "line_offset", "model"},
|
|
288
|
+
)
|
|
289
|
+
if has_conversation_events:
|
|
290
|
+
model_expr = """
|
|
291
|
+
(SELECT json_extract(events.payload_json, '$.payload.model')
|
|
292
|
+
FROM codex_conversation_events AS events
|
|
293
|
+
WHERE events.source_path=quota_window_snapshots.source_path
|
|
294
|
+
AND events.line_offset<=quota_window_snapshots.line_offset
|
|
295
|
+
AND events.record_type IN ('turn_context','session_meta')
|
|
296
|
+
AND json_type(events.payload_json, '$.payload.model')='text'
|
|
297
|
+
ORDER BY events.line_offset DESC LIMIT 1) AS observed_model
|
|
298
|
+
"""
|
|
299
|
+
elif has_session_entries:
|
|
300
|
+
model_expr = """
|
|
301
|
+
(SELECT entries.model
|
|
302
|
+
FROM codex_session_entries AS entries
|
|
303
|
+
WHERE entries.source_path=quota_window_snapshots.source_path
|
|
304
|
+
AND entries.line_offset<=quota_window_snapshots.line_offset
|
|
305
|
+
ORDER BY entries.line_offset DESC LIMIT 1) AS observed_model
|
|
306
|
+
"""
|
|
307
|
+
else:
|
|
308
|
+
model_expr = "NULL AS observed_model"
|
|
265
309
|
sql = """
|
|
266
310
|
SELECT source, source_root_key, source_path, line_offset,
|
|
267
311
|
captured_at_utc, observed_slot, logical_limit_key, limit_id,
|
|
268
312
|
limit_name, window_minutes, used_percent, resets_at_utc,
|
|
269
|
-
plan_type, individual_limit_json, reached_type
|
|
313
|
+
plan_type, individual_limit_json, reached_type,
|
|
314
|
+
{model_expr}
|
|
270
315
|
FROM quota_window_snapshots
|
|
271
316
|
WHERE source='codex' AND source_root_key IS NOT NULL
|
|
272
|
-
"""
|
|
317
|
+
""".format(model_expr=model_expr)
|
|
273
318
|
params: list[object] = []
|
|
274
319
|
if requested is not None:
|
|
275
320
|
if not requested:
|
|
@@ -317,10 +362,17 @@ def load_codex_quota_observations(
|
|
|
317
362
|
if any(row[name] is None or not str(row[name]).strip() for name in required_text):
|
|
318
363
|
continue
|
|
319
364
|
try:
|
|
365
|
+
logical_limit_key = str(row["logical_limit_key"])
|
|
366
|
+
if codex_model_scoped_quota_pool(row["observed_model"]) is not None:
|
|
367
|
+
logical_limit_key = _codex_logical_limit_key(
|
|
368
|
+
str(row["source_root_key"]), row["limit_id"],
|
|
369
|
+
str(row["observed_slot"]), int(row["window_minutes"]),
|
|
370
|
+
str(row["observed_model"]),
|
|
371
|
+
)
|
|
320
372
|
identity = QuotaWindowIdentity(
|
|
321
373
|
source=str(row["source"]),
|
|
322
374
|
source_root_key=str(row["source_root_key"]),
|
|
323
|
-
logical_limit_key=
|
|
375
|
+
logical_limit_key=logical_limit_key,
|
|
324
376
|
observed_slot=str(row["observed_slot"]),
|
|
325
377
|
window_minutes=int(row["window_minutes"]),
|
|
326
378
|
limit_id=row["limit_id"],
|
|
@@ -1205,8 +1205,6 @@ def _fork_persist(parent_lock_fd: int) -> None:
|
|
|
1205
1205
|
|
|
1206
1206
|
def _statusline_persist(parsed, *, sync_for_test: bool = False) -> None:
|
|
1207
1207
|
"""Spool an eligible session candidate then reduce it opportunistically."""
|
|
1208
|
-
if _lib_statusline.is_alternate_pool_model_id(parsed.model_id):
|
|
1209
|
-
return
|
|
1210
1208
|
candidate = _candidate_from_input(parsed, received_at=int(time.time()))
|
|
1211
1209
|
if candidate is None:
|
|
1212
1210
|
return
|
|
@@ -25,14 +25,16 @@ The FTS5 indexes over ``conversation_messages`` (``conversation_fts``) and
|
|
|
25
25
|
``conversation_ai_titles`` (``conversation_title_fts``) are external-content and
|
|
26
26
|
maintained by AFTER-DELETE triggers, which are logically correct on subset
|
|
27
27
|
deletes. Whole groups are deleted so those triggers keep the index consistent.
|
|
28
|
-
The kernel runs inside the caller's open transaction
|
|
29
|
-
|
|
28
|
+
The kernel normally runs inside the caller's open transaction. The orchestrator
|
|
29
|
+
supplies a post-group boundary that commits each whole conversation separately
|
|
30
|
+
while retaining every flock for the full pass (#315), bounding WAL growth.
|
|
30
31
|
"""
|
|
31
32
|
from __future__ import annotations
|
|
32
33
|
|
|
33
34
|
import datetime as dt
|
|
34
35
|
import fcntl
|
|
35
36
|
import sqlite3
|
|
37
|
+
from collections.abc import Callable
|
|
36
38
|
from dataclasses import dataclass
|
|
37
39
|
|
|
38
40
|
import _cctally_core
|
|
@@ -71,12 +73,25 @@ def _cutoff_iso(cutoff_utc: dt.datetime) -> str:
|
|
|
71
73
|
|
|
72
74
|
|
|
73
75
|
def prune_conversation_transcripts(
|
|
74
|
-
conn: sqlite3.Connection,
|
|
76
|
+
conn: sqlite3.Connection,
|
|
77
|
+
*,
|
|
78
|
+
cutoff_utc: dt.datetime,
|
|
79
|
+
after_group: "Callable[[], None] | None" = None,
|
|
75
80
|
) -> PruneStats:
|
|
76
|
-
"""Prune every transcript group whose latest activity is before ``cutoff_utc``.
|
|
81
|
+
"""Prune every transcript group whose latest activity is before ``cutoff_utc``.
|
|
82
|
+
|
|
83
|
+
``after_group`` runs only after every table and FTS posting owned by one
|
|
84
|
+
session/conversation has been deleted. Direct kernel callers leave it unset
|
|
85
|
+
and retain their caller-managed transaction; the orchestrator uses it for
|
|
86
|
+
#315's whole-conversation intermediate commits.
|
|
87
|
+
"""
|
|
77
88
|
cutoff = _cutoff_iso(cutoff_utc)
|
|
78
|
-
claude_sessions, claude_messages = _prune_claude(
|
|
79
|
-
|
|
89
|
+
claude_sessions, claude_messages = _prune_claude(
|
|
90
|
+
conn, cutoff, after_group=after_group
|
|
91
|
+
)
|
|
92
|
+
codex_conversations, codex_events = _prune_codex(
|
|
93
|
+
conn, cutoff, after_group=after_group
|
|
94
|
+
)
|
|
80
95
|
return PruneStats(
|
|
81
96
|
claude_sessions=claude_sessions,
|
|
82
97
|
claude_messages=claude_messages,
|
|
@@ -116,7 +131,12 @@ def _prunable_null_identity_paths(
|
|
|
116
131
|
return [row[0] for row in conn.execute(sql, (cutoff,))]
|
|
117
132
|
|
|
118
133
|
|
|
119
|
-
def _prune_claude(
|
|
134
|
+
def _prune_claude(
|
|
135
|
+
conn: sqlite3.Connection,
|
|
136
|
+
cutoff: str,
|
|
137
|
+
*,
|
|
138
|
+
after_group: "Callable[[], None] | None" = None,
|
|
139
|
+
) -> tuple[int, int]:
|
|
120
140
|
sessions = 0
|
|
121
141
|
messages = 0
|
|
122
142
|
for session_id in _prunable_groups(
|
|
@@ -140,6 +160,8 @@ def _prune_claude(conn: sqlite3.Connection, cutoff: str) -> tuple[int, int]:
|
|
|
140
160
|
(session_id,),
|
|
141
161
|
)
|
|
142
162
|
sessions += 1
|
|
163
|
+
if after_group is not None:
|
|
164
|
+
after_group()
|
|
143
165
|
for source_path in _prunable_null_identity_paths(
|
|
144
166
|
conn, "conversation_messages", "session_id", cutoff
|
|
145
167
|
):
|
|
@@ -166,6 +188,8 @@ def _prune_claude(conn: sqlite3.Connection, cutoff: str) -> tuple[int, int]:
|
|
|
166
188
|
)
|
|
167
189
|
messages += cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0
|
|
168
190
|
sessions += 1
|
|
191
|
+
if after_group is not None:
|
|
192
|
+
after_group()
|
|
169
193
|
return sessions, messages
|
|
170
194
|
|
|
171
195
|
|
|
@@ -235,9 +259,11 @@ def _maybe_prune_conversation_retention(
|
|
|
235
259
|
two provider flocks are taken in a FIXED order (Claude then Codex),
|
|
236
260
|
non-blocking, so a rebuild/reingest mid-flight makes the prune skip this
|
|
237
261
|
cycle rather than race between candidate selection and deletion. The prune of
|
|
238
|
-
|
|
239
|
-
transaction,
|
|
240
|
-
|
|
262
|
+
each whole session/conversation runs in its own ``BEGIN IMMEDIATE``
|
|
263
|
+
transaction (#315), while every flock remains held for the full pass. The
|
|
264
|
+
throttle stamp is committed only after both provider phases succeed. A
|
|
265
|
+
failure rolls back the active group but preserves completed groups, writes no
|
|
266
|
+
stamp, and therefore retries the remainder next cycle.
|
|
241
267
|
|
|
242
268
|
``conn`` must hold no provider flock and no open transaction (the caller
|
|
243
269
|
guarantees this — the dashboard opens a dedicated cache connection; the
|
|
@@ -274,7 +300,15 @@ def _maybe_prune_conversation_retention(
|
|
|
274
300
|
cutoff = now_utc - dt.timedelta(days=int(retention_days))
|
|
275
301
|
conn.execute("BEGIN IMMEDIATE")
|
|
276
302
|
try:
|
|
277
|
-
|
|
303
|
+
def commit_group() -> None:
|
|
304
|
+
conn.commit()
|
|
305
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
306
|
+
|
|
307
|
+
stats = prune_conversation_transcripts(
|
|
308
|
+
conn,
|
|
309
|
+
cutoff_utc=cutoff,
|
|
310
|
+
after_group=commit_group,
|
|
311
|
+
)
|
|
278
312
|
_stamp_retention_prune(conn, now_utc)
|
|
279
313
|
conn.commit()
|
|
280
314
|
except Exception:
|
|
@@ -354,7 +388,12 @@ def _delete_codex_conversation_derived(
|
|
|
354
388
|
)
|
|
355
389
|
|
|
356
390
|
|
|
357
|
-
def _prune_codex(
|
|
391
|
+
def _prune_codex(
|
|
392
|
+
conn: sqlite3.Connection,
|
|
393
|
+
cutoff: str,
|
|
394
|
+
*,
|
|
395
|
+
after_group: "Callable[[], None] | None" = None,
|
|
396
|
+
) -> tuple[int, int]:
|
|
358
397
|
conversations = 0
|
|
359
398
|
events = 0
|
|
360
399
|
for conversation_key in _prunable_groups(
|
|
@@ -369,6 +408,8 @@ def _prune_codex(conn: sqlite3.Connection, cutoff: str) -> tuple[int, int]:
|
|
|
369
408
|
)
|
|
370
409
|
events += cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0
|
|
371
410
|
conversations += 1
|
|
411
|
+
if after_group is not None:
|
|
412
|
+
after_group()
|
|
372
413
|
for source_path in _prunable_null_identity_paths(
|
|
373
414
|
conn, "codex_conversation_events", "conversation_key", cutoff
|
|
374
415
|
):
|
|
@@ -381,4 +422,6 @@ def _prune_codex(conn: sqlite3.Connection, cutoff: str) -> tuple[int, int]:
|
|
|
381
422
|
)
|
|
382
423
|
events += cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0
|
|
383
424
|
conversations += 1
|
|
425
|
+
if after_group is not None:
|
|
426
|
+
after_group()
|
|
384
427
|
return conversations, events
|
package/bin/_lib_doctor.py
CHANGED
|
@@ -200,6 +200,12 @@ class DoctorState:
|
|
|
200
200
|
# DOCTOR_WAL_WARN_BYTES (2x the WAL cap) — only when the journal_size_limit
|
|
201
201
|
# + forced-checkpoint machinery has genuinely failed to contain the WAL.
|
|
202
202
|
cache_db_wal_bytes: Optional[int] = None
|
|
203
|
+
# #315: read-only PRAGMA page_count/freelist_count evidence. The pure
|
|
204
|
+
# db.reclaimable check warns when free pages reach 25% of cache.db and
|
|
205
|
+
# points at the already-guarded explicit vacuum command. None means the
|
|
206
|
+
# cache was absent or unreadable; the check degrades to OK.
|
|
207
|
+
cache_db_page_count: Optional[int] = None
|
|
208
|
+
cache_db_freelist_count: Optional[int] = None
|
|
203
209
|
# #294 S2: root-qualified physical Codex quota freshness, per-root native
|
|
204
210
|
# hook state, and lifecycle activity are gathered by _cctally_doctor.
|
|
205
211
|
codex_quota_windows: Optional[list[dict]] = None
|
|
@@ -1725,6 +1731,48 @@ def _check_db_wal_size(s: DoctorState) -> CheckResult:
|
|
|
1725
1731
|
)
|
|
1726
1732
|
|
|
1727
1733
|
|
|
1734
|
+
# #315: conservative advisory threshold. A quarter of cache.db being free is
|
|
1735
|
+
# large enough to make an explicit, guarded VACUUM useful without nagging for
|
|
1736
|
+
# ordinary page churn. This is a ratio, so it remains page-size independent.
|
|
1737
|
+
DOCTOR_RECLAIMABLE_WARN_RATIO = 0.25
|
|
1738
|
+
|
|
1739
|
+
|
|
1740
|
+
def _check_db_reclaimable(s: DoctorState) -> CheckResult:
|
|
1741
|
+
"""Surface cache free pages without mutating or auto-vacuuming the DB."""
|
|
1742
|
+
page_count = s.cache_db_page_count
|
|
1743
|
+
freelist_count = s.cache_db_freelist_count
|
|
1744
|
+
ratio = None
|
|
1745
|
+
if (
|
|
1746
|
+
isinstance(page_count, int)
|
|
1747
|
+
and not isinstance(page_count, bool)
|
|
1748
|
+
and page_count > 0
|
|
1749
|
+
and isinstance(freelist_count, int)
|
|
1750
|
+
and not isinstance(freelist_count, bool)
|
|
1751
|
+
and 0 <= freelist_count <= page_count
|
|
1752
|
+
):
|
|
1753
|
+
ratio = freelist_count / page_count
|
|
1754
|
+
details = {
|
|
1755
|
+
"cache_db_page_count": page_count,
|
|
1756
|
+
"cache_db_freelist_count": freelist_count,
|
|
1757
|
+
"cache_db_free_ratio": ratio,
|
|
1758
|
+
"warn_ratio": DOCTOR_RECLAIMABLE_WARN_RATIO,
|
|
1759
|
+
}
|
|
1760
|
+
if ratio is not None and ratio >= DOCTOR_RECLAIMABLE_WARN_RATIO:
|
|
1761
|
+
return CheckResult(
|
|
1762
|
+
id="db.reclaimable", title="Reclaimable cache space",
|
|
1763
|
+
severity="warn",
|
|
1764
|
+
summary=f"high — {ratio * 100:.1f}% of cache.db pages are free",
|
|
1765
|
+
remediation=(
|
|
1766
|
+
"Run `cctally db vacuum --db cache` to reclaim disk space."
|
|
1767
|
+
),
|
|
1768
|
+
details=details,
|
|
1769
|
+
)
|
|
1770
|
+
return CheckResult(
|
|
1771
|
+
id="db.reclaimable", title="Reclaimable cache space", severity="ok",
|
|
1772
|
+
summary="below threshold", remediation=None, details=details,
|
|
1773
|
+
)
|
|
1774
|
+
|
|
1775
|
+
|
|
1728
1776
|
# Each entry is (category_id, category_title, ((check_id, evaluator_fn_name), ...)).
|
|
1729
1777
|
# The dotted check_id is the stable JSON-contract ID (spec §5.2) AND the
|
|
1730
1778
|
# fingerprint identity-slice key (spec §5.5). When an evaluator raises,
|
|
@@ -1759,6 +1807,7 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
|
|
|
1759
1807
|
("db.migrations.pending", "_check_db_migrations_pending"),
|
|
1760
1808
|
("db.lock_state", "_check_db_lock_state"),
|
|
1761
1809
|
("db.wal_size", "_check_db_wal_size"),
|
|
1810
|
+
("db.reclaimable", "_check_db_reclaimable"),
|
|
1762
1811
|
)),
|
|
1763
1812
|
("data", "Data", (
|
|
1764
1813
|
("data.latest_snapshot_age", "_check_data_latest_snapshot_age"),
|
package/bin/_lib_jsonl.py
CHANGED
|
@@ -584,20 +584,37 @@ def _first_valid_reset_at(
|
|
|
584
584
|
|
|
585
585
|
def _codex_logical_limit_key(
|
|
586
586
|
source_root_key: str | None, limit_id: str | None, observed_slot: str,
|
|
587
|
-
window_minutes: int,
|
|
587
|
+
window_minutes: int, model: str | None = None,
|
|
588
588
|
) -> str:
|
|
589
|
-
|
|
589
|
+
payload = {
|
|
590
590
|
"limitId": limit_id,
|
|
591
591
|
"observedSlot": observed_slot,
|
|
592
592
|
"source": "codex",
|
|
593
593
|
"sourceRootKey": source_root_key,
|
|
594
594
|
"windowMinutes": window_minutes,
|
|
595
|
-
}
|
|
595
|
+
}
|
|
596
|
+
if (model_pool := codex_model_scoped_quota_pool(model)) is not None:
|
|
597
|
+
payload["modelPool"] = model_pool
|
|
598
|
+
return _codex_canonical_json(payload)
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def codex_model_scoped_quota_pool(model: object) -> str | None:
|
|
602
|
+
"""Return the native model pool when Codex documents it as separate.
|
|
603
|
+
|
|
604
|
+
GPT Codex Spark runs against its own allowance and does not consume the
|
|
605
|
+
standard Codex quota. Native payloads currently reuse ``limit_id=codex``
|
|
606
|
+
and the same slot/duration as the standard pool, so the sticky rollout
|
|
607
|
+
model is the only retained discriminator.
|
|
608
|
+
"""
|
|
609
|
+
if not isinstance(model, str):
|
|
610
|
+
return None
|
|
611
|
+
normalized = model.strip().lower()
|
|
612
|
+
return normalized if "-codex-spark" in normalized else None
|
|
596
613
|
|
|
597
614
|
|
|
598
615
|
def _codex_quota_observations(
|
|
599
616
|
obj: dict[str, Any], payload: dict[str, Any], path_str: str, line_offset: int,
|
|
600
|
-
source_root_key: str | None,
|
|
617
|
+
source_root_key: str | None, model: str | None,
|
|
601
618
|
) -> tuple[CodexQuotaObservation, ...]:
|
|
602
619
|
captured_at = _parse_codex_timestamp(obj.get("timestamp"))
|
|
603
620
|
if captured_at is None:
|
|
@@ -638,7 +655,7 @@ def _codex_quota_observations(
|
|
|
638
655
|
captured_at_utc=_format_codex_timestamp(captured_at),
|
|
639
656
|
observed_slot=slot,
|
|
640
657
|
logical_limit_key=_codex_logical_limit_key(
|
|
641
|
-
source_root_key, limit_id, slot, window_minutes
|
|
658
|
+
source_root_key, limit_id, slot, window_minutes, model
|
|
642
659
|
),
|
|
643
660
|
limit_id=limit_id,
|
|
644
661
|
limit_name=_first_valid_string(
|
|
@@ -867,7 +884,7 @@ def _iter_codex_fused_records_with_offsets(
|
|
|
867
884
|
filename_session_id_warned,
|
|
868
885
|
)
|
|
869
886
|
quotas = _codex_quota_observations(
|
|
870
|
-
obj, payload, path_str, line_offset, source_root_key
|
|
887
|
+
obj, payload, path_str, line_offset, source_root_key, state.model
|
|
871
888
|
)
|
|
872
889
|
yield CodexFusedEmission(
|
|
873
890
|
line_offset=line_offset,
|
package/bin/_lib_statusline.py
CHANGED
|
@@ -191,32 +191,6 @@ def parse_statusline_stdin(raw: "bytes | str") -> "StatuslineInput | ParseError"
|
|
|
191
191
|
)
|
|
192
192
|
|
|
193
193
|
|
|
194
|
-
# ---- Pool-identity guard (persist-only; spec 2026-07-17 #311 D1) ----------
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
_ALTERNATE_POOL_MODEL_ID_RE = re.compile(r"\[[^\]]+\]$")
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
def is_alternate_pool_model_id(model_id) -> bool:
|
|
201
|
-
"""True iff ``model_id`` is a bracketed variant id (e.g.
|
|
202
|
-
``claude-opus-4-8[1m]``) that reports a SEPARATE rate-limit pool.
|
|
203
|
-
|
|
204
|
-
Such a session's stdin ``rate_limits`` describes a DIFFERENT usage pool
|
|
205
|
-
on the same account; persisting it poisons the default-pool DB (the 7d
|
|
206
|
-
HWM clamp latches the foreign high value and dedup then freezes tracking
|
|
207
|
-
— see the #311 spec). The persist feeder skips it.
|
|
208
|
-
|
|
209
|
-
Matches ANY trailing bracket suffix, not just the literal ``[1m]``: a
|
|
210
|
-
future variant that turns out to share the default pool would merely lose
|
|
211
|
-
one redundant writer (fail-safe toward data purity), whereas matching
|
|
212
|
-
only ``[1m]`` would let the next variant poison the DB again. Missing /
|
|
213
|
-
``None`` / non-string / no-suffix ids return ``False`` (persist proceeds —
|
|
214
|
-
only a positive variant match skips). Never raises."""
|
|
215
|
-
if not isinstance(model_id, str) or not model_id:
|
|
216
|
-
return False
|
|
217
|
-
return _ALTERNATE_POOL_MODEL_ID_RE.search(model_id) is not None
|
|
218
|
-
|
|
219
|
-
|
|
220
194
|
# ---- Segment 1: model -----------------------------------------------------
|
|
221
195
|
|
|
222
196
|
|