fischer-agentkit/src/agentkit/calendar/scheduler.py

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