"""Reminder scheduler — background loop that scans upcoming events and dispatches reminders via :class:`ReminderDispatcher`. Follows the ``start()``/``stop()`` + ``asyncio.create_task`` loop pattern from ``server/task_store.py`` (KTD-2 — conscious deviation from APScheduler). ponytail: app.py lifespan wiring is deferred — the orchestrator will call ``start()``/``stop()`` when integrating into the application lifecycle. ponytail: ``asyncio.sleep`` polling has second-level precision. If sub-second scheduling is needed, upgrade to APScheduler. """ from __future__ import annotations import asyncio import logging import uuid from datetime import datetime, timedelta, timezone from pathlib import Path from agentkit.calendar.db import ( DEFAULT_CALENDAR_DB_PATH, get_pending_deliveries, insert_reminder_delivery, list_all_events_in_time_range, list_reminder_rules_for_event, update_delivery_status, ) from agentkit.calendar.models import CalendarEvent, ReminderDelivery, ReminderRule from agentkit.calendar.reminders import ReminderDispatcher logger = logging.getLogger(__name__) def _parse_dt(dt_str: str) -> datetime: """Parse ISO 8601 string to timezone-aware datetime (UTC).""" dt = datetime.fromisoformat(dt_str) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt class ReminderScheduler: """Background scheduler that scans for events entering the reminder window and dispatches via the configured channels. """ def __init__( self, db_path: str | Path | None = None, dispatcher: ReminderDispatcher | None = None, interval_seconds: int = 60, lookback_seconds: int = 3600, max_retries: int = 3, retry_base_delay: float = 1.0, ) -> None: self._db_path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH self._dispatcher = dispatcher or ReminderDispatcher() self._interval = interval_seconds self._lookback = lookback_seconds self._max_retries = max_retries self._retry_base_delay = retry_base_delay self._task: asyncio.Task[None] | None = None async def start(self) -> None: """Start the background scan loop.""" if self._task is None: self._task = asyncio.create_task(self._loop()) async def stop(self) -> None: """Cancel the background scan loop.""" if self._task is not None: self._task.cancel() try: await self._task except asyncio.CancelledError: pass self._task = None async def _loop(self) -> None: """Main scan loop — runs until cancelled.""" while True: try: await self.scan_once() except asyncio.CancelledError: break except Exception: logger.exception("Reminder scheduler scan error") await asyncio.sleep(self._interval) async def scan_once(self) -> int: """Run a single scan cycle. Returns the number of deliveries created. Public method so tests can invoke a single scan without waiting for the loop interval. """ now = datetime.now(timezone.utc) window_start = now - timedelta(seconds=self._lookback) window_end = now + timedelta(seconds=self._interval) # Query a broad range of events — the reminder_time filter happens below. # ponytail: recurring event reminder expansion is not handled here; only # the event's stored start_time is used. Upgrade: expand RRULE occurrences # and check each occurrence's reminder time. query_start = (now - timedelta(hours=2)).isoformat() query_end = (now + timedelta(hours=48)).isoformat() events = await list_all_events_in_time_range(query_start, query_end, self._db_path) dispatched = 0 for event in events: rules = await list_reminder_rules_for_event(event.id, self._db_path) for rule in rules: reminder_time = _parse_dt(event.start_time) + timedelta(minutes=rule.offset_minutes) if window_start <= reminder_time <= window_end: dispatched += await self._process_reminder(event, rule, reminder_time) return dispatched async def _process_reminder( self, event: CalendarEvent, rule: ReminderRule, reminder_time: datetime, ) -> int: """Create delivery records and dispatch. Returns count of deliveries created. Idempotent: if any delivery already exists for this event+rule, skip. """ existing = await get_pending_deliveries(event.id, rule.id, self._db_path, status="sent") if existing: return 0 created = 0 for channel in rule.channels: delivery = ReminderDelivery( id=uuid.uuid4().hex, reminder_rule_id=rule.id, event_id=event.id, scheduled_time=reminder_time.isoformat(), status="pending", channel=channel, attempts=0, last_error=None, ) await insert_reminder_delivery(delivery, self._db_path) created += 1 await self._dispatch_with_retry(event, delivery) return created async def _dispatch_with_retry( self, event: CalendarEvent, delivery: ReminderDelivery, ) -> bool: """Attempt dispatch up to ``max_retries`` times with exponential backoff. Updates the delivery record's ``attempts`` counter after each try. Returns ``True`` on success, ``False`` if all retries exhausted. """ for attempt in range(self._max_retries): error: str | None = None try: success = await self._dispatcher.dispatch(delivery.channel, event, event.user_id) if success: await update_delivery_status(delivery.id, "sent", None, self._db_path) return True except Exception as exc: error = str(exc) await update_delivery_status(delivery.id, "failed", error, self._db_path) if attempt < self._max_retries - 1: await asyncio.sleep(self._retry_base_delay * (2**attempt)) return False