175 lines
6.3 KiB
Python
175 lines
6.3 KiB
Python
"""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
|