diff --git a/src/fabledassistant/app.py b/src/fabledassistant/app.py index fce073b..761a4c4 100644 --- a/src/fabledassistant/app.py +++ b/src/fabledassistant/app.py @@ -331,6 +331,12 @@ def create_app() -> Quart: from fabledassistant.services.event_scheduler import start_event_scheduler start_event_scheduler(asyncio.get_running_loop()) + # Start version-pinning scheduler (daily auto-pin scan at 03:00 UTC) + from fabledassistant.services.version_pinning_scheduler import ( + start_version_pinning_scheduler, + ) + start_version_pinning_scheduler(asyncio.get_running_loop()) + # Voice model loading (enabled via Admin → Config in the UI, or VOICE_ENABLED env var) from fabledassistant.services.stt import load_stt_model from fabledassistant.services.tts import load_tts_model @@ -343,6 +349,10 @@ def create_app() -> Quart: stop_journal_scheduler() from fabledassistant.services.event_scheduler import stop_event_scheduler stop_event_scheduler() + from fabledassistant.services.version_pinning_scheduler import ( + stop_version_pinning_scheduler, + ) + stop_version_pinning_scheduler() @app.route("/") async def serve_index(): diff --git a/src/fabledassistant/services/version_pinning_scheduler.py b/src/fabledassistant/services/version_pinning_scheduler.py new file mode 100644 index 0000000..1b04fe1 --- /dev/null +++ b/src/fabledassistant/services/version_pinning_scheduler.py @@ -0,0 +1,72 @@ +"""Daily APScheduler cron for the auto-pin scan. + +Single global job at 03:00 UTC. Runs scan_all_users_for_auto_pins so the +system promotes stable note versions before they get aged out of the +rolling cap. Off-hours by design — the scan is cheap but not time- +critical and doesn't need to interrupt regular activity. + +Mirrors the BackgroundScheduler + threadsafe-async-call pattern used by +journal_scheduler.py. +""" +from __future__ import annotations + +import asyncio +import logging + +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger + +from fabledassistant.services.version_pinning import scan_all_users_for_auto_pins + +logger = logging.getLogger(__name__) + +_scheduler: BackgroundScheduler | None = None +_loop: asyncio.AbstractEventLoop | None = None + + +def _run_scan_threadsafe() -> None: + """APScheduler invokes this from a worker thread; bridge into the + asyncio loop so the scan can await its DB operations.""" + if _loop is None: + logger.warning("version_pinning scheduler: no loop registered") + return + + async def _runner(): + try: + results = await scan_all_users_for_auto_pins() + total = sum(results.values()) + if total > 0: + logger.info( + "auto-pin scan: pinned %d version(s) across %d user(s)", + total, len(results), + ) + else: + logger.debug("auto-pin scan: no new pins") + except Exception: + logger.exception("auto-pin scan run failed") + + asyncio.run_coroutine_threadsafe(_runner(), _loop) + + +def start_version_pinning_scheduler(loop: asyncio.AbstractEventLoop) -> None: + global _scheduler, _loop + if _scheduler is not None: + return + _loop = loop + _scheduler = BackgroundScheduler() + _scheduler.add_job( + _run_scan_threadsafe, + trigger=CronTrigger(hour=3, minute=0, timezone="UTC"), + id="version_pinning_auto_scan", + replace_existing=True, + ) + _scheduler.start() + logger.info("Version pinning scheduler started (daily 03:00 UTC)") + + +def stop_version_pinning_scheduler() -> None: + global _scheduler + if _scheduler is not None: + _scheduler.shutdown(wait=False) + _scheduler = None + logger.info("Version pinning scheduler stopped")