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.
@@ -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.
@@ -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, CONFIG_LOCK_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
- while not self._stop.is_set():
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
- for _ in range(int(max(1, self._interval * 10))):
6638
- if self._stop.is_set():
6639
- return
6640
- if self._ref.take_sync_request():
6641
- break
6642
- _time.sleep(0.1)
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