cctally 1.67.1 → 1.68.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 +12 -0
- package/bin/_cctally_cache.py +110 -2
- package/bin/_cctally_core.py +19 -10
- package/bin/_cctally_db.py +63 -0
- package/bin/_cctally_doctor.py +13 -0
- package/bin/_cctally_parser.py +23 -0
- package/bin/_lib_doctor.py +36 -0
- package/bin/cctally +1 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,18 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.68.0] - 2026-07-13
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `cctally db checkpoint [--db {cache,stats}] [--json]` — fast, non-destructive WAL drain (TRUNCATE checkpoint) for cache.db/stats.db; the manual escape hatch and the `doctor` remediation for a bloated write-ahead log. (#297)
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- `cctally-test-all`: faster full-suite runs — `reconcile` now runs inside the parallel pool instead of as a serial barrier, and a three-role worker model (`CCTALLY_TEST_JOBS` outer budget + derived/overridable `CCTALLY_INNER_JOBS` / `CCTALLY_PYTEST_JOBS`) caps the nested per-harness fan-out that made individual harnesses 2–8× slower under load. Adds a slowest-harness timing report. (#292)
|
|
15
|
+
- `doctor` gains a read-only `cache.db WAL size` check that warns when the WAL exceeds 256 MB and points to `cctally db checkpoint`. (#297)
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- Prevent the cache.db write-ahead log from bloating to multi-GB under heavy concurrent multi-agent syncs, which surfaced as `Error: database is locked` on every `cctally` command — cap the WAL file (`PRAGMA journal_size_limit`), force a best-effort TRUNCATE checkpoint at end-of-sync, and raise `busy_timeout` from 5s to 15s. Existing bloated caches self-heal on the next sync (or run `cctally db checkpoint`). (#297)
|
|
19
|
+
|
|
8
20
|
## [1.67.1] - 2026-07-12
|
|
9
21
|
|
|
10
22
|
### Fixed
|
package/bin/_cctally_cache.py
CHANGED
|
@@ -107,7 +107,7 @@ import sqlite3
|
|
|
107
107
|
import sys
|
|
108
108
|
import time
|
|
109
109
|
from dataclasses import dataclass, field
|
|
110
|
-
from typing import Any, Callable, Iterator
|
|
110
|
+
from typing import Any, Callable, Iterator, NamedTuple
|
|
111
111
|
|
|
112
112
|
|
|
113
113
|
def _cctally():
|
|
@@ -316,6 +316,96 @@ clear_conversation_messages = _cctally_db_sib.clear_conversation_messages
|
|
|
316
316
|
_set_cache_meta = _cctally_db_sib._set_cache_meta
|
|
317
317
|
|
|
318
318
|
|
|
319
|
+
# cache.db WAL hardening (#297). See
|
|
320
|
+
# docs/superpowers/specs/2026-07-13-cache-db-wal-hardening-design.md.
|
|
321
|
+
# `journal_size_limit` bounds the *persistent* WAL file so a checkpoint that
|
|
322
|
+
# actually resets the WAL truncates the file back down to this cap — it is
|
|
323
|
+
# containment/recovery under transient reader contention, NOT a reader-proof
|
|
324
|
+
# hard cap (a pinned long-lived reader still defeats it until it releases).
|
|
325
|
+
CACHE_WAL_SIZE_LIMIT_BYTES = 128 * 1024 * 1024 # 134217728
|
|
326
|
+
# End-of-sync physical-size shrink trigger: only force a TRUNCATE checkpoint
|
|
327
|
+
# once the -wal file has grown past this, so normal small syncs stay cheap.
|
|
328
|
+
CACHE_WAL_CHECKPOINT_TRIGGER_BYTES = 64 * 1024 * 1024 # 67108864
|
|
329
|
+
# Near-nonblocking busy_timeout for the auto end-of-sync checkpoint. It runs
|
|
330
|
+
# BEFORE the ingest flock is released, so a long wait here would stall every
|
|
331
|
+
# above-threshold sync's flock under the heavy-reader contention that motivates
|
|
332
|
+
# the fix — fail fast and rely on journal_size_limit + the next checkpoint.
|
|
333
|
+
CHECKPOINT_AUTO_BUSY_TIMEOUT_MS = 100
|
|
334
|
+
# The explicit `cctally db checkpoint` command may wait this long.
|
|
335
|
+
CHECKPOINT_CMD_BUSY_TIMEOUT_MS = 15000
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
class CheckpointResult(NamedTuple):
|
|
339
|
+
"""Outcome of a single ``PRAGMA wal_checkpoint(TRUNCATE)`` (#297).
|
|
340
|
+
|
|
341
|
+
``busy`` is the checkpoint's own busy flag (a reader/writer held it off);
|
|
342
|
+
``truncated`` means the WAL was actually reset AND the -wal file is now
|
|
343
|
+
zero-length/absent — a checkpoint can copy some frames yet still report
|
|
344
|
+
``busy`` (partial), which is NOT ``truncated``.
|
|
345
|
+
"""
|
|
346
|
+
|
|
347
|
+
db: str
|
|
348
|
+
wal_bytes_before: int
|
|
349
|
+
wal_bytes_after: int
|
|
350
|
+
frames_checkpointed: int
|
|
351
|
+
busy: bool
|
|
352
|
+
truncated: bool
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _wal_file_size(db_path) -> int:
|
|
356
|
+
"""Best-effort size of the -wal sidecar in bytes; 0 if absent/unreadable."""
|
|
357
|
+
try:
|
|
358
|
+
return os.path.getsize(f"{db_path}-wal")
|
|
359
|
+
except OSError:
|
|
360
|
+
return 0
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _run_wal_truncate(conn, db_path, *, db_label: str) -> "CheckpointResult":
|
|
364
|
+
"""Run a best-effort TRUNCATE checkpoint on an already-open connection.
|
|
365
|
+
|
|
366
|
+
PRECONDITION: ``conn`` has NO active transaction (autocommit). The
|
|
367
|
+
``db checkpoint`` command passes a fresh raw connection; the end-of-sync
|
|
368
|
+
caller has committed all ingest work first. ``PRAGMA
|
|
369
|
+
wal_checkpoint(TRUNCATE)`` returns ``(busy, log, checkpointed)``: ``busy=0``
|
|
370
|
+
means the WAL was reset. Measures the -wal size before and after so callers
|
|
371
|
+
can report the shrink without re-deriving it.
|
|
372
|
+
"""
|
|
373
|
+
before = _wal_file_size(db_path)
|
|
374
|
+
row = conn.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone()
|
|
375
|
+
busy = bool(row[0]) if row else True
|
|
376
|
+
frames = int(row[2]) if (row and row[2] is not None) else 0
|
|
377
|
+
after = _wal_file_size(db_path)
|
|
378
|
+
truncated = (not busy) and after == 0
|
|
379
|
+
return CheckpointResult(db_label, before, after, frames, busy, truncated)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _maybe_truncate_wal(conn, db_path) -> None:
|
|
383
|
+
"""End-of-sync best-effort WAL drain (#297).
|
|
384
|
+
|
|
385
|
+
Threshold-gated (only once the -wal file has grown past
|
|
386
|
+
``CACHE_WAL_CHECKPOINT_TRIGGER_BYTES``) and run under a near-nonblocking
|
|
387
|
+
short busy_timeout so it NEVER stalls the held sync flock — the checkpoint
|
|
388
|
+
runs BEFORE the flock is released, so a long busy wait here would stall
|
|
389
|
+
every above-threshold sync under exactly the heavy-reader contention that
|
|
390
|
+
motivates the fix. Restores the prior busy_timeout. Fail-soft: a checkpoint
|
|
391
|
+
error must never fail the sync (observability/hygiene, not correctness).
|
|
392
|
+
|
|
393
|
+
PRECONDITION: ``conn`` has no active transaction — the caller has committed
|
|
394
|
+
all ingest work by this point.
|
|
395
|
+
"""
|
|
396
|
+
try:
|
|
397
|
+
if _wal_file_size(db_path) <= CACHE_WAL_CHECKPOINT_TRIGGER_BYTES:
|
|
398
|
+
return
|
|
399
|
+
prior = conn.execute("PRAGMA busy_timeout").fetchone()[0]
|
|
400
|
+
try:
|
|
401
|
+
conn.execute(f"PRAGMA busy_timeout={CHECKPOINT_AUTO_BUSY_TIMEOUT_MS}")
|
|
402
|
+
_run_wal_truncate(conn, db_path, db_label="cache.db")
|
|
403
|
+
finally:
|
|
404
|
+
conn.execute(f"PRAGMA busy_timeout={prior}")
|
|
405
|
+
except sqlite3.DatabaseError:
|
|
406
|
+
pass # best-effort; observability/hygiene must never fail a sync
|
|
407
|
+
|
|
408
|
+
|
|
319
409
|
_PARSE_HEALTH_SCHEMA = 1
|
|
320
410
|
|
|
321
411
|
|
|
@@ -1875,6 +1965,11 @@ def sync_cache(
|
|
|
1875
1965
|
# write has materialized the -wal/-shm sidecars. open_cache_db hardens
|
|
1876
1966
|
# cache.db + the data dir; this finishes the job for the sidecars.
|
|
1877
1967
|
_harden_cache_sidecars()
|
|
1968
|
+
# #297: forced end-of-sync WAL drain. Threshold-gated + short-timeout +
|
|
1969
|
+
# best-effort. Runs here — all ingest work is committed (no active txn)
|
|
1970
|
+
# and the flock is still held, so the short busy_timeout keeps it from
|
|
1971
|
+
# stalling the lock under heavy-reader contention.
|
|
1972
|
+
_maybe_truncate_wal(conn, _cctally_core.CACHE_DB_PATH)
|
|
1878
1973
|
return stats
|
|
1879
1974
|
finally:
|
|
1880
1975
|
try:
|
|
@@ -3262,6 +3357,12 @@ def sync_codex_cache(
|
|
|
3262
3357
|
skip_reasons=stats.skip_reasons,
|
|
3263
3358
|
rebuild=rebuild,
|
|
3264
3359
|
)
|
|
3360
|
+
# #297: forced end-of-sync WAL drain (Codex half). Claude and Codex
|
|
3361
|
+
# ingests use SEPARATE flocks but commit into the SAME cache.db WAL, so
|
|
3362
|
+
# the fail-fast short timeout naturally dedupes concurrent attempts —
|
|
3363
|
+
# whoever wins truncates; the other gets `busy` immediately and moves
|
|
3364
|
+
# on. All Codex ingest work is committed here (no active txn).
|
|
3365
|
+
_maybe_truncate_wal(conn, _cctally_core.CACHE_DB_PATH)
|
|
3265
3366
|
return stats
|
|
3266
3367
|
finally:
|
|
3267
3368
|
try:
|
|
@@ -3513,7 +3614,14 @@ def open_cache_db() -> sqlite3.Connection:
|
|
|
3513
3614
|
eprint(f"[cache] could not chmod cache.db 0600 ({exc}); continuing")
|
|
3514
3615
|
|
|
3515
3616
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
3516
|
-
|
|
3617
|
+
# #297: 15 s (was 5 s) lets a writer wait out a slow-but-normal sync (>5 s)
|
|
3618
|
+
# instead of instantly erroring with "database is locked". Covers plain
|
|
3619
|
+
# writer-vs-writer SQLITE_BUSY, not SQLITE_BUSY_SNAPSHOT (defended by
|
|
3620
|
+
# write-lock-up-front at the write sites, #87).
|
|
3621
|
+
conn.execute("PRAGMA busy_timeout=15000")
|
|
3622
|
+
# #297: bound the persistent WAL file so a resetting checkpoint truncates it
|
|
3623
|
+
# back to this cap instead of leaving it at its multi-GB high-water size.
|
|
3624
|
+
conn.execute(f"PRAGMA journal_size_limit={CACHE_WAL_SIZE_LIMIT_BYTES}")
|
|
3517
3625
|
# Re-derivable DB under WAL: NORMAL risks at most the tail transaction on
|
|
3518
3626
|
# power loss, and cache.db can always be rebuilt (cache-sync --rebuild).
|
|
3519
3627
|
# Fewer ingest fsyncs than the default FULL. Matches stats.db
|
package/bin/_cctally_core.py
CHANGED
|
@@ -213,6 +213,14 @@ def _real_prod_data_dir() -> pathlib.Path:
|
|
|
213
213
|
_init_paths_from_env()
|
|
214
214
|
|
|
215
215
|
|
|
216
|
+
# stats.db WAL cap (#297). Bounds the persistent WAL file so a resetting
|
|
217
|
+
# checkpoint truncates it back down instead of leaving it at its high-water
|
|
218
|
+
# size. stats.db writes are small (its -wal was observed at 0 bytes even under
|
|
219
|
+
# the contention that bloated cache.db to multi-GB), so a tighter 16 MB cap is
|
|
220
|
+
# ample. See docs/superpowers/specs/2026-07-13-cache-db-wal-hardening-design.md.
|
|
221
|
+
STATS_WAL_SIZE_LIMIT_BYTES = 16 * 1024 * 1024 # 16777216
|
|
222
|
+
|
|
223
|
+
|
|
216
224
|
# === Telemetry constants (non-path; see spec 2026-07-07) =============
|
|
217
225
|
#
|
|
218
226
|
# These are static (not APP_DIR-derived) so they live outside
|
|
@@ -939,16 +947,17 @@ def open_db() -> sqlite3.Connection:
|
|
|
939
947
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
940
948
|
conn.execute("PRAGMA synchronous=NORMAL")
|
|
941
949
|
# Explicit for intent + symmetry with open_cache_db (bin/_cctally_cache.py).
|
|
942
|
-
#
|
|
943
|
-
#
|
|
944
|
-
#
|
|
945
|
-
#
|
|
946
|
-
#
|
|
947
|
-
#
|
|
948
|
-
#
|
|
949
|
-
#
|
|
950
|
-
|
|
951
|
-
|
|
950
|
+
# #297: raised 5000 -> 15000 so a writer waits out a slow-but-normal
|
|
951
|
+
# sync (>5 s) instead of instantly erroring with "database is locked".
|
|
952
|
+
# NOTE: busy_timeout does NOT absorb SQLITE_BUSY_SNAPSHOT (a WAL
|
|
953
|
+
# read-then-write transaction whose snapshot is invalidated by a
|
|
954
|
+
# competing commit raises "database is locked" instantly, bypassing the
|
|
955
|
+
# busy handler). The write paths defend against that by taking the write
|
|
956
|
+
# lock up front — BEGIN IMMEDIATE, or a write as the transaction's first
|
|
957
|
+
# DML. See cctally-dev#87.
|
|
958
|
+
conn.execute("PRAGMA busy_timeout=15000")
|
|
959
|
+
# #297: bound the persistent WAL file (symmetry with open_cache_db).
|
|
960
|
+
conn.execute(f"PRAGMA journal_size_limit={STATS_WAL_SIZE_LIMIT_BYTES}")
|
|
952
961
|
conn.execute("SELECT 1").fetchone()
|
|
953
962
|
except sqlite3.DatabaseError as exc:
|
|
954
963
|
try:
|
package/bin/_cctally_db.py
CHANGED
|
@@ -5453,3 +5453,66 @@ def cmd_db_recover(args: argparse.Namespace) -> int:
|
|
|
5453
5453
|
return 0
|
|
5454
5454
|
finally:
|
|
5455
5455
|
conn.close()
|
|
5456
|
+
|
|
5457
|
+
|
|
5458
|
+
def cmd_db_checkpoint(args: argparse.Namespace) -> int:
|
|
5459
|
+
"""Fast, non-destructive WAL drain (TRUNCATE checkpoint) for cache.db /
|
|
5460
|
+
stats.db (#297).
|
|
5461
|
+
|
|
5462
|
+
Opens a RAW existing-file-only connection — NOT open_cache_db()/open_db(),
|
|
5463
|
+
which apply schema, run the migration dispatcher, can DELETE Codex rows on a
|
|
5464
|
+
column-add, and CREATE a missing DB; a checkpoint may do none of those.
|
|
5465
|
+
Precedent: cmd_db_recover's raw connect above. It flushes WAL frames into
|
|
5466
|
+
the main DB and truncates the -wal file — it changes no data, no schema, no
|
|
5467
|
+
user_version — so there is no prod guard and no --yes.
|
|
5468
|
+
|
|
5469
|
+
Exit 0 when drained / already-small / the DB is absent; 3 (staged) if the
|
|
5470
|
+
target stayed busy or was not fully truncated through the timeout — an
|
|
5471
|
+
actionable "something is still holding it" signal.
|
|
5472
|
+
"""
|
|
5473
|
+
import _cctally_cache
|
|
5474
|
+
from _lib_json_envelope import stamp_schema_version
|
|
5475
|
+
|
|
5476
|
+
which = args.db # "cache" | "stats"
|
|
5477
|
+
if which == "cache":
|
|
5478
|
+
path, label = _cctally_core.CACHE_DB_PATH, "cache.db"
|
|
5479
|
+
else:
|
|
5480
|
+
path, label = _cctally_core.DB_PATH, "stats.db"
|
|
5481
|
+
timeout = int(getattr(args, "busy_timeout_ms", None)
|
|
5482
|
+
or _cctally_cache.CHECKPOINT_CMD_BUSY_TIMEOUT_MS)
|
|
5483
|
+
as_json = bool(getattr(args, "json", False))
|
|
5484
|
+
|
|
5485
|
+
# Absent file → nothing to drain; a missing re-derivable cache is not an
|
|
5486
|
+
# error (mirrors cmd_db_recover / cmd_db_unskip). Do NOT connect — mode=rw
|
|
5487
|
+
# would refuse to create it, but skip it entirely for a clean message.
|
|
5488
|
+
if not path.exists():
|
|
5489
|
+
if as_json:
|
|
5490
|
+
payload = {"db": label, "walBytesBefore": 0, "walBytesAfter": 0,
|
|
5491
|
+
"framesCheckpointed": 0, "busy": False, "truncated": True,
|
|
5492
|
+
"present": False}
|
|
5493
|
+
print(json.dumps(stamp_schema_version(payload, version=1)))
|
|
5494
|
+
else:
|
|
5495
|
+
print(f"cctally: no {label} database file present; nothing to drain.")
|
|
5496
|
+
return 0
|
|
5497
|
+
|
|
5498
|
+
conn = sqlite3.connect(f"file:{path}?mode=rw", uri=True)
|
|
5499
|
+
try:
|
|
5500
|
+
conn.execute(f"PRAGMA busy_timeout={timeout}")
|
|
5501
|
+
result = _cctally_cache._run_wal_truncate(conn, path, db_label=label)
|
|
5502
|
+
finally:
|
|
5503
|
+
conn.close()
|
|
5504
|
+
|
|
5505
|
+
if as_json:
|
|
5506
|
+
payload = {"db": result.db, "walBytesBefore": result.wal_bytes_before,
|
|
5507
|
+
"walBytesAfter": result.wal_bytes_after,
|
|
5508
|
+
"framesCheckpointed": result.frames_checkpointed,
|
|
5509
|
+
"busy": result.busy, "truncated": result.truncated,
|
|
5510
|
+
"present": True}
|
|
5511
|
+
print(json.dumps(stamp_schema_version(payload, version=1)))
|
|
5512
|
+
else:
|
|
5513
|
+
mb_b = result.wal_bytes_before / (1024 * 1024)
|
|
5514
|
+
mb_a = result.wal_bytes_after / (1024 * 1024)
|
|
5515
|
+
state = "drained" if result.truncated else ("still busy" if result.busy else "partial")
|
|
5516
|
+
print(f"cctally: {result.db} WAL {mb_b:.1f} MB -> {mb_a:.1f} MB "
|
|
5517
|
+
f"({result.frames_checkpointed} frames; {state}).")
|
|
5518
|
+
return 0 if result.truncated else 3
|
package/bin/_cctally_doctor.py
CHANGED
|
@@ -473,6 +473,17 @@ def doctor_gather_state(
|
|
|
473
473
|
except Exception:
|
|
474
474
|
locks_held = None
|
|
475
475
|
|
|
476
|
+
# ── cache.db WAL size (#297) — read-only backstop ────────────────
|
|
477
|
+
# Gathered OUTSIDE the deep/quick_check branch (above) so the WAL-size
|
|
478
|
+
# check runs in both shallow and deep gather modes. Best-effort getsize;
|
|
479
|
+
# None on OSError/race (doctor never blocks or raises), 0 when absent.
|
|
480
|
+
cache_db_wal_bytes: "int | None"
|
|
481
|
+
try:
|
|
482
|
+
_wal = pathlib.Path(f"{_cctally_core.CACHE_DB_PATH}-wal")
|
|
483
|
+
cache_db_wal_bytes = _wal.stat().st_size if _wal.exists() else 0
|
|
484
|
+
except OSError:
|
|
485
|
+
cache_db_wal_bytes = None
|
|
486
|
+
|
|
476
487
|
# ── Safety ───────────────────────────────────────────────────────
|
|
477
488
|
# `dashboard.bind` is read via the same chokepoint that powers
|
|
478
489
|
# `cctally config get dashboard.bind` — `_config_known_value`
|
|
@@ -646,6 +657,8 @@ def doctor_gather_state(
|
|
|
646
657
|
stats_db_quick_check=stats_db_quick_check,
|
|
647
658
|
cache_db_quick_check=cache_db_quick_check,
|
|
648
659
|
locks_held=locks_held,
|
|
660
|
+
# #297: cache.db WAL size backstop (gathered outside the deep branch).
|
|
661
|
+
cache_db_wal_bytes=cache_db_wal_bytes,
|
|
649
662
|
)
|
|
650
663
|
|
|
651
664
|
|
package/bin/_cctally_parser.py
CHANGED
|
@@ -2576,6 +2576,29 @@ def _build_db_parser(subparsers, name, *, help_text, xref=None):
|
|
|
2576
2576
|
help="Required for --db stats (non-re-derivable; may need a re-record)",
|
|
2577
2577
|
)
|
|
2578
2578
|
db_recover.set_defaults(func=c.cmd_db_recover)
|
|
2579
|
+
db_checkpoint = db_sub.add_parser(
|
|
2580
|
+
"checkpoint",
|
|
2581
|
+
help="Drain the WAL (TRUNCATE checkpoint) — fast, non-destructive",
|
|
2582
|
+
)
|
|
2583
|
+
db_checkpoint.add_argument(
|
|
2584
|
+
"--db",
|
|
2585
|
+
choices=("cache", "stats"),
|
|
2586
|
+
default="cache",
|
|
2587
|
+
help="Which DB to checkpoint (default: cache)",
|
|
2588
|
+
)
|
|
2589
|
+
db_checkpoint.add_argument(
|
|
2590
|
+
"--json",
|
|
2591
|
+
action="store_true",
|
|
2592
|
+
help="Emit JSON to stdout",
|
|
2593
|
+
)
|
|
2594
|
+
db_checkpoint.add_argument(
|
|
2595
|
+
"--busy-timeout-ms",
|
|
2596
|
+
dest="busy_timeout_ms",
|
|
2597
|
+
type=int,
|
|
2598
|
+
default=15000,
|
|
2599
|
+
help=argparse.SUPPRESS,
|
|
2600
|
+
)
|
|
2601
|
+
db_checkpoint.set_defaults(func=c.cmd_db_checkpoint)
|
|
2579
2602
|
|
|
2580
2603
|
def _build_doctor_parser(subparsers, name, *, help_text, xref=None):
|
|
2581
2604
|
"""Build the `doctor` parser (registered via _REGISTRATION; #279 S6 W3).
|
package/bin/_lib_doctor.py
CHANGED
|
@@ -194,6 +194,11 @@ class DoctorState:
|
|
|
194
194
|
# (name -> True held / False free / None unreadable). Probe never
|
|
195
195
|
# creates files (doctor read-only contract). None = probe errored.
|
|
196
196
|
locks_held: Optional[dict] = None
|
|
197
|
+
# #297: size in bytes of cache.db-wal, gathered read-only in
|
|
198
|
+
# doctor_gather_state (getsize, OSError/absent -> None). Warns above
|
|
199
|
+
# DOCTOR_WAL_WARN_BYTES (2x the WAL cap) — only when the journal_size_limit
|
|
200
|
+
# + forced-checkpoint machinery has genuinely failed to contain the WAL.
|
|
201
|
+
cache_db_wal_bytes: Optional[int] = None
|
|
197
202
|
|
|
198
203
|
|
|
199
204
|
@dataclasses.dataclass(frozen=True)
|
|
@@ -1340,6 +1345,36 @@ def _check_db_lock_state(s: DoctorState) -> CheckResult:
|
|
|
1340
1345
|
)
|
|
1341
1346
|
|
|
1342
1347
|
|
|
1348
|
+
# #297: WARN when cache.db-wal exceeds 2x the Section 1 cap (128 MiB), so this
|
|
1349
|
+
# only fires when the journal_size_limit + forced-checkpoint machinery has
|
|
1350
|
+
# genuinely failed to contain the WAL — never in normal operation.
|
|
1351
|
+
DOCTOR_WAL_WARN_BYTES = 256 * 1024 * 1024 # 268435456
|
|
1352
|
+
|
|
1353
|
+
|
|
1354
|
+
def _check_db_wal_size(s: DoctorState) -> CheckResult:
|
|
1355
|
+
"""Read-only backstop that makes a genuine cache.db WAL wedge visible and
|
|
1356
|
+
points at `cctally db checkpoint` (#297).
|
|
1357
|
+
|
|
1358
|
+
The exact byte count lives ONLY in the (fingerprint-excluded) details block;
|
|
1359
|
+
the summary stays a stable string so a below-threshold byte count that
|
|
1360
|
+
drifts tick-to-tick does not flip the doctor fingerprint — only an OK<->WARN
|
|
1361
|
+
crossing does.
|
|
1362
|
+
"""
|
|
1363
|
+
wal = s.cache_db_wal_bytes
|
|
1364
|
+
details = {"cache_db_wal_bytes": wal}
|
|
1365
|
+
if isinstance(wal, int) and wal > DOCTOR_WAL_WARN_BYTES:
|
|
1366
|
+
return CheckResult(
|
|
1367
|
+
id="db.wal_size", title="cache.db WAL size", severity="warn",
|
|
1368
|
+
summary="oversized — cache.db WAL far above its cap",
|
|
1369
|
+
remediation="Run `cctally db checkpoint` to drain the WAL.",
|
|
1370
|
+
details=details,
|
|
1371
|
+
)
|
|
1372
|
+
return CheckResult(
|
|
1373
|
+
id="db.wal_size", title="cache.db WAL size", severity="ok",
|
|
1374
|
+
summary="within limit", remediation=None, details=details,
|
|
1375
|
+
)
|
|
1376
|
+
|
|
1377
|
+
|
|
1343
1378
|
# Each entry is (category_id, category_title, ((check_id, evaluator_fn_name), ...)).
|
|
1344
1379
|
# The dotted check_id is the stable JSON-contract ID (spec §5.2) AND the
|
|
1345
1380
|
# fingerprint identity-slice key (spec §5.5). When an evaluator raises,
|
|
@@ -1370,6 +1405,7 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
|
|
|
1370
1405
|
("db.migrations.applied", "_check_db_migrations_applied"),
|
|
1371
1406
|
("db.migrations.pending", "_check_db_migrations_pending"),
|
|
1372
1407
|
("db.lock_state", "_check_db_lock_state"),
|
|
1408
|
+
("db.wal_size", "_check_db_wal_size"),
|
|
1373
1409
|
)),
|
|
1374
1410
|
("data", "Data", (
|
|
1375
1411
|
("data.latest_snapshot_age", "_check_data_latest_snapshot_age"),
|
package/bin/cctally
CHANGED
|
@@ -831,6 +831,7 @@ _db_path_for_label = _cctally_db._db_path_for_label
|
|
|
831
831
|
cmd_db_skip = _cctally_db.cmd_db_skip
|
|
832
832
|
cmd_db_unskip = _cctally_db.cmd_db_unskip
|
|
833
833
|
cmd_db_recover = _cctally_db.cmd_db_recover
|
|
834
|
+
cmd_db_checkpoint = _cctally_db.cmd_db_checkpoint
|
|
834
835
|
|
|
835
836
|
|
|
836
837
|
# Session-entry cache subsystem (Claude + Codex) — read-through delta
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cctally",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.68.0",
|
|
4
4
|
"description": "Claude Code usage tracker and local dashboard for Pro/Max subscription limits - weekly cost-per-percent trend, quota forecasts, threshold alerts. ccusage-compatible.",
|
|
5
5
|
"homepage": "https://github.com/omrikais/cctally",
|
|
6
6
|
"repository": {
|