cctally 1.70.0 → 1.72.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 +21 -0
- package/bin/_cctally_cache.py +430 -8
- package/bin/_cctally_config.py +108 -0
- package/bin/_cctally_core.py +6 -1
- package/bin/_cctally_dashboard.py +106 -11
- package/bin/_cctally_db.py +445 -1
- package/bin/_cctally_parser.py +20 -0
- package/bin/_cctally_quota.py +58 -6
- package/bin/_lib_codex_conversation.py +509 -0
- package/bin/_lib_codex_conversation_query.py +950 -0
- package/bin/_lib_conversation_dispatch.py +547 -0
- package/bin/_lib_conversation_retention.py +344 -0
- package/bin/cctally +1 -0
- package/dashboard/static/assets/index-BWadAHxC.js +80 -0
- package/dashboard/static/assets/index-D2nwo_ln.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +5 -1
- package/dashboard/static/assets/index-CBbErI-P.js +0 -80
- package/dashboard/static/assets/index-kDDVOLa_.css +0 -1
package/bin/_cctally_config.py
CHANGED
|
@@ -327,11 +327,76 @@ ALLOWED_CONFIG_KEYS = (
|
|
|
327
327
|
"budget.codex.alert_thresholds",
|
|
328
328
|
"budget.codex.projected_enabled",
|
|
329
329
|
"telemetry.enabled",
|
|
330
|
+
"conversation.retention_days",
|
|
330
331
|
)
|
|
331
332
|
|
|
332
333
|
|
|
333
334
|
_CODEX_BUDGET_LEAF_PREFIX = "budget.codex."
|
|
334
335
|
|
|
336
|
+
_DEFAULT_CONVERSATION_RETENTION_DAYS = 180
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _validate_retention_days_value(raw: object) -> int:
|
|
340
|
+
"""Validate a ``config set`` value for ``conversation.retention_days`` (F8).
|
|
341
|
+
|
|
342
|
+
Accept a non-negative integer; ``off`` / ``0`` canonicalize to integer 0
|
|
343
|
+
(disabled — keep transcripts forever). Reject booleans, negatives, and
|
|
344
|
+
non-numeric input by raising ``ValueError`` (the caller maps that to exit 2).
|
|
345
|
+
"""
|
|
346
|
+
if isinstance(raw, bool):
|
|
347
|
+
raise ValueError(
|
|
348
|
+
"conversation.retention_days must be a non-negative integer or 'off'"
|
|
349
|
+
)
|
|
350
|
+
if isinstance(raw, str):
|
|
351
|
+
text = raw.strip().lower()
|
|
352
|
+
if text == "off":
|
|
353
|
+
return 0
|
|
354
|
+
try:
|
|
355
|
+
value = int(text, 10)
|
|
356
|
+
except (TypeError, ValueError):
|
|
357
|
+
raise ValueError(
|
|
358
|
+
"conversation.retention_days must be a non-negative integer "
|
|
359
|
+
f"or 'off', got {raw!r}"
|
|
360
|
+
)
|
|
361
|
+
elif isinstance(raw, int):
|
|
362
|
+
value = raw
|
|
363
|
+
else:
|
|
364
|
+
raise ValueError(
|
|
365
|
+
"conversation.retention_days must be a non-negative integer or 'off'"
|
|
366
|
+
)
|
|
367
|
+
if value < 0:
|
|
368
|
+
raise ValueError(
|
|
369
|
+
"conversation.retention_days must be >= 0 (use 'off' or 0 to disable)"
|
|
370
|
+
)
|
|
371
|
+
return value
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def resolve_retention_days(config: dict) -> int:
|
|
375
|
+
"""Effective conversation transcript retention in days (F8).
|
|
376
|
+
|
|
377
|
+
Default is ``180``; ``0`` disables retention (keep forever). Any malformed
|
|
378
|
+
persisted value (non-int, boolean, negative, un-parseable string) degrades
|
|
379
|
+
to the safe 180 default rather than raising.
|
|
380
|
+
"""
|
|
381
|
+
default = _DEFAULT_CONVERSATION_RETENTION_DAYS
|
|
382
|
+
block = config.get("conversation") if isinstance(config, dict) else None
|
|
383
|
+
if not isinstance(block, dict):
|
|
384
|
+
return default
|
|
385
|
+
stored = block.get("retention_days")
|
|
386
|
+
if stored is None:
|
|
387
|
+
return default
|
|
388
|
+
if isinstance(stored, bool):
|
|
389
|
+
return default
|
|
390
|
+
if isinstance(stored, int):
|
|
391
|
+
return stored if stored >= 0 else default
|
|
392
|
+
if isinstance(stored, str):
|
|
393
|
+
try:
|
|
394
|
+
value = int(stored.strip(), 10)
|
|
395
|
+
except (TypeError, ValueError):
|
|
396
|
+
return default
|
|
397
|
+
return value if value >= 0 else default
|
|
398
|
+
return default
|
|
399
|
+
|
|
335
400
|
|
|
336
401
|
def _config_codex_leaf_value(config: dict, key: str) -> object:
|
|
337
402
|
"""Read one nested Codex budget leaf with its public default.
|
|
@@ -794,6 +859,10 @@ def _config_known_value(config: dict, key: str) -> "object":
|
|
|
794
859
|
except ValueError:
|
|
795
860
|
return True
|
|
796
861
|
return True
|
|
862
|
+
if key == "conversation.retention_days":
|
|
863
|
+
# Effective transcript-retention window in days (default 180; 0 = keep
|
|
864
|
+
# forever). Malformed persisted data surfaces the safe default (F8).
|
|
865
|
+
return resolve_retention_days(config)
|
|
797
866
|
if key in ("update.check.enabled", "update.check.ttl_hours"):
|
|
798
867
|
# Defaults mirror `_is_update_check_due` (True / 24 hours).
|
|
799
868
|
# Hand-edited junk surfaces as the default — matches dashboard.bind.
|
|
@@ -1422,6 +1491,32 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
1422
1491
|
rendered = str(normalized)
|
|
1423
1492
|
print(f"{key}={rendered}")
|
|
1424
1493
|
return 0
|
|
1494
|
+
if key == "conversation.retention_days":
|
|
1495
|
+
# Validate first; rejection short-circuits before lock acquisition (F8).
|
|
1496
|
+
try:
|
|
1497
|
+
normalized = _validate_retention_days_value(raw)
|
|
1498
|
+
except ValueError as exc:
|
|
1499
|
+
print(f"cctally: {exc}", file=sys.stderr)
|
|
1500
|
+
return 2
|
|
1501
|
+
with config_writer_lock():
|
|
1502
|
+
config = _load_config_unlocked()
|
|
1503
|
+
existing = config.get("conversation")
|
|
1504
|
+
if existing is not None and not isinstance(existing, dict):
|
|
1505
|
+
print(
|
|
1506
|
+
"cctally: conversation config error: conversation must be "
|
|
1507
|
+
"an object",
|
|
1508
|
+
file=sys.stderr,
|
|
1509
|
+
)
|
|
1510
|
+
return 2
|
|
1511
|
+
block = dict(existing or {})
|
|
1512
|
+
block["retention_days"] = normalized
|
|
1513
|
+
config["conversation"] = block
|
|
1514
|
+
save_config(config)
|
|
1515
|
+
if getattr(args, "emit_json", False):
|
|
1516
|
+
print(json.dumps({"conversation": {"retention_days": normalized}}, indent=2))
|
|
1517
|
+
else:
|
|
1518
|
+
print(f"{key}={normalized}")
|
|
1519
|
+
return 0
|
|
1425
1520
|
if key in ("update.check.enabled", "update.check.ttl_hours"):
|
|
1426
1521
|
# Validate first; rejection short-circuits before lock acquisition.
|
|
1427
1522
|
if key == "update.check.enabled":
|
|
@@ -1862,6 +1957,19 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
|
|
|
1862
1957
|
save_config(config)
|
|
1863
1958
|
# idempotent: silent on missing key
|
|
1864
1959
|
return 0
|
|
1960
|
+
if key == "conversation.retention_days":
|
|
1961
|
+
# Mirror the display.tz branch: drop the leaf; if the `conversation`
|
|
1962
|
+
# block ends up empty, drop it too. Next get resolves the 180 default.
|
|
1963
|
+
with config_writer_lock():
|
|
1964
|
+
config = _load_config_unlocked()
|
|
1965
|
+
block = config.get("conversation")
|
|
1966
|
+
if isinstance(block, dict) and "retention_days" in block:
|
|
1967
|
+
del block["retention_days"]
|
|
1968
|
+
if not block:
|
|
1969
|
+
config.pop("conversation", None)
|
|
1970
|
+
save_config(config)
|
|
1971
|
+
# idempotent: silent on missing key
|
|
1972
|
+
return 0
|
|
1865
1973
|
if key in ("update.check.enabled", "update.check.ttl_hours"):
|
|
1866
1974
|
# Mirror the dashboard.bind branch: drop the leaf, then prune
|
|
1867
1975
|
# empty `check` and empty `update` so config.json stays tidy.
|
package/bin/_cctally_core.py
CHANGED
|
@@ -61,7 +61,8 @@ def _init_paths_from_env() -> None:
|
|
|
61
61
|
"""
|
|
62
62
|
global APP_DIR, LEGACY_APP_DIR, LOG_DIR, DEV_MODE
|
|
63
63
|
global DB_PATH, CACHE_DB_PATH
|
|
64
|
-
global CACHE_LOCK_PATH, CACHE_LOCK_CODEX_PATH,
|
|
64
|
+
global CACHE_LOCK_PATH, CACHE_LOCK_CODEX_PATH, CACHE_LOCK_MAINTENANCE_PATH
|
|
65
|
+
global CONFIG_LOCK_PATH
|
|
65
66
|
global CONFIG_PATH, MIGRATION_ERROR_LOG_PATH, CHANGELOG_PATH
|
|
66
67
|
global HOOK_TICK_LOG_DIR, HOOK_TICK_LOG_PATH, HOOK_TICK_LOG_ROTATED_PATH
|
|
67
68
|
global HOOK_TICK_THROTTLE_PATH, HOOK_TICK_THROTTLE_LOCK_PATH
|
|
@@ -100,6 +101,10 @@ def _init_paths_from_env() -> None:
|
|
|
100
101
|
|
|
101
102
|
CACHE_LOCK_PATH = APP_DIR / "cache.db.lock"
|
|
102
103
|
CACHE_LOCK_CODEX_PATH = APP_DIR / "cache.db.codex.lock"
|
|
104
|
+
# #313 P3 (F7): dedicated maintenance flock serializing the transcript
|
|
105
|
+
# retention prune across processes, held ABOVE the two provider flocks so a
|
|
106
|
+
# rebuild/reingest cannot land between candidate selection and deletion.
|
|
107
|
+
CACHE_LOCK_MAINTENANCE_PATH = APP_DIR / "cache.db.maintenance.lock"
|
|
103
108
|
CONFIG_LOCK_PATH = APP_DIR / "config.json.lock"
|
|
104
109
|
|
|
105
110
|
CONFIG_PATH = APP_DIR / "config.json"
|
|
@@ -1068,6 +1068,86 @@ def _load_recorded_five_hour_windows(*args, **kwargs):
|
|
|
1068
1068
|
return sys.modules["cctally"]._load_recorded_five_hour_windows(*args, **kwargs)
|
|
1069
1069
|
|
|
1070
1070
|
|
|
1071
|
+
def _next_deadline(t0: float, interval: float, work: float) -> float:
|
|
1072
|
+
"""Monotonic deadline for the next sync iteration (#313 P2 / F10).
|
|
1073
|
+
|
|
1074
|
+
The iteration's work ended at ``t0 + work``; cool down from there for
|
|
1075
|
+
``max(interval, work)``. So the period is ``work + max(interval, work)``:
|
|
1076
|
+
|
|
1077
|
+
- work >= interval → period = ``2 * work`` → CPU duty capped at 50% of one
|
|
1078
|
+
core, scale-independent (a slow rebuild on a huge cache can never peg a
|
|
1079
|
+
full core — the exact #313 symptom).
|
|
1080
|
+
- work < interval → period = ``work + interval`` — byte-identical to the
|
|
1081
|
+
prior loop (rebuild then a fixed ``interval`` sleep), so normal-cadence
|
|
1082
|
+
behavior and its goldens are unchanged.
|
|
1083
|
+
|
|
1084
|
+
NOTE (spec deviation): the spec §3 pseudocode's ``deadline = t0 + max(
|
|
1085
|
+
interval, work)`` cools down from ``t0`` (before the work), which for
|
|
1086
|
+
work >= interval yields a ZERO cooldown (period = work, 100% duty) — it does
|
|
1087
|
+
not meet the spec's own stated "period = 2*work / duty <= 50%" property or
|
|
1088
|
+
§1's "<= 50% of one core" success criterion, and would leave the peg unfixed
|
|
1089
|
+
(in fact worse than the old work+interval cadence). This formula cools down
|
|
1090
|
+
from the work's END, which is what those properties require.
|
|
1091
|
+
"""
|
|
1092
|
+
return (t0 + work) + max(interval, work)
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
def _dashboard_sync_loop(
|
|
1096
|
+
*,
|
|
1097
|
+
stop,
|
|
1098
|
+
interval: float,
|
|
1099
|
+
run_iteration,
|
|
1100
|
+
take_sync_request,
|
|
1101
|
+
monotonic=time.monotonic,
|
|
1102
|
+
sleep=time.sleep,
|
|
1103
|
+
) -> None:
|
|
1104
|
+
"""Run the dashboard's automatic sync loop with a work-proportional cooldown.
|
|
1105
|
+
|
|
1106
|
+
``run_iteration`` performs one whole automatic iteration (rebuild plus any
|
|
1107
|
+
orphan self-heal / retention maintenance the thread does), so its measured
|
|
1108
|
+
duration — not just the rebuild — drives the deadline (F10). The manual
|
|
1109
|
+
``POST /api/sync`` refresh is a separate synchronous path under ``sync_lock``
|
|
1110
|
+
and is unaffected; ``take_sync_request`` is the TUI force-refresh flag that
|
|
1111
|
+
breaks the cooldown early.
|
|
1112
|
+
"""
|
|
1113
|
+
while not stop.is_set():
|
|
1114
|
+
t0 = monotonic()
|
|
1115
|
+
run_iteration()
|
|
1116
|
+
work = monotonic() - t0
|
|
1117
|
+
deadline = _next_deadline(t0, interval, work)
|
|
1118
|
+
while not stop.is_set() and monotonic() < deadline:
|
|
1119
|
+
if take_sync_request():
|
|
1120
|
+
break
|
|
1121
|
+
sleep(min(0.1, max(0.0, deadline - monotonic())))
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
def _dashboard_maybe_prune_retention() -> None:
|
|
1125
|
+
"""#313 P3 (F7): throttled transcript retention prune driven from the
|
|
1126
|
+
dashboard sync thread. Runs once per iteration INSIDE the measured cooldown
|
|
1127
|
+
work (Task 6), so its cost paces the deadline. Opens a dedicated cache
|
|
1128
|
+
connection (no provider flock, no open transaction) for the orchestrator.
|
|
1129
|
+
No-op when retention is disabled; never raises (a prune failure must not
|
|
1130
|
+
crash the sync thread)."""
|
|
1131
|
+
try:
|
|
1132
|
+
c = sys.modules["cctally"]
|
|
1133
|
+
from _cctally_config import resolve_retention_days
|
|
1134
|
+
import _lib_conversation_retention as retention
|
|
1135
|
+
retention_days = resolve_retention_days(c.load_config())
|
|
1136
|
+
if retention_days <= 0:
|
|
1137
|
+
return
|
|
1138
|
+
conn = c.open_cache_db()
|
|
1139
|
+
try:
|
|
1140
|
+
retention._maybe_prune_conversation_retention(
|
|
1141
|
+
conn,
|
|
1142
|
+
now_utc=dt.datetime.now(dt.timezone.utc),
|
|
1143
|
+
retention_days=retention_days,
|
|
1144
|
+
)
|
|
1145
|
+
finally:
|
|
1146
|
+
conn.close()
|
|
1147
|
+
except Exception:
|
|
1148
|
+
pass
|
|
1149
|
+
|
|
1150
|
+
|
|
1071
1151
|
def _make_run_sync_now(*args, **kwargs):
|
|
1072
1152
|
return sys.modules["cctally"]._make_run_sync_now(*args, **kwargs)
|
|
1073
1153
|
|
|
@@ -6623,23 +6703,38 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
|
6623
6703
|
|
|
6624
6704
|
class _DashboardSyncThread(_c_for_subclass._TuiSyncThread):
|
|
6625
6705
|
def _run(self) -> None:
|
|
6626
|
-
last_heal = _time.monotonic()
|
|
6627
|
-
|
|
6706
|
+
last_heal = [_time.monotonic()]
|
|
6707
|
+
|
|
6708
|
+
def run_iteration() -> None:
|
|
6628
6709
|
_run_sync_now(skip_sync=self._skip_sync)
|
|
6629
6710
|
# Self-heal removed-worktree orphans on a ~60s cadence (far
|
|
6630
6711
|
# rarer than the sync tick — a deleted worktree is not urgent).
|
|
6631
6712
|
# Non-blocking on the flock, so a contended tick just retries
|
|
6632
|
-
# next cadence; gated off under --no-sync.
|
|
6713
|
+
# next cadence; gated off under --no-sync. Runs INSIDE the
|
|
6714
|
+
# measured iteration so its cost counts toward the cooldown
|
|
6715
|
+
# deadline (#313 P2 / F10).
|
|
6633
6716
|
if (not self._skip_sync
|
|
6634
|
-
and _time.monotonic() - last_heal >= 60.0):
|
|
6635
|
-
last_heal = _time.monotonic()
|
|
6717
|
+
and _time.monotonic() - last_heal[0] >= 60.0):
|
|
6718
|
+
last_heal[0] = _time.monotonic()
|
|
6636
6719
|
_dashboard_self_heal_orphans(skip_sync=self._skip_sync)
|
|
6637
|
-
|
|
6638
|
-
|
|
6639
|
-
|
|
6640
|
-
|
|
6641
|
-
|
|
6642
|
-
|
|
6720
|
+
# #313 P3 (F7): throttled transcript retention prune, inside the
|
|
6721
|
+
# measured iteration so its cost is in the cooldown work. Gated
|
|
6722
|
+
# off under --no-sync (frozen mode makes no cache writes).
|
|
6723
|
+
if not self._skip_sync:
|
|
6724
|
+
_dashboard_maybe_prune_retention()
|
|
6725
|
+
|
|
6726
|
+
# Work-proportional cooldown (F10): sleep to t0 + max(interval, work)
|
|
6727
|
+
# so a slow rebuild cannot peg a full core. The manual POST /api/sync
|
|
6728
|
+
# refresh runs synchronously under sync_lock, independent of this
|
|
6729
|
+
# cooldown, so a user force-refresh is always immediate.
|
|
6730
|
+
_dashboard_sync_loop(
|
|
6731
|
+
stop=self._stop,
|
|
6732
|
+
interval=self._interval,
|
|
6733
|
+
run_iteration=run_iteration,
|
|
6734
|
+
take_sync_request=self._ref.take_sync_request,
|
|
6735
|
+
monotonic=_time.monotonic,
|
|
6736
|
+
sleep=_time.sleep,
|
|
6737
|
+
)
|
|
6643
6738
|
|
|
6644
6739
|
sync_thread = (
|
|
6645
6740
|
None if args.no_sync
|