Skip to content

donna.notifications.overdue

donna.notifications.overdue

Overdue task detection — nudge the user when tasks run past their end time.

Background async task that runs every 15 minutes. For every scheduled or in-progress task whose estimated end time + 30-minute buffer has passed, it creates a Discord thread in #donna-tasks with an overdue nudge.

User replies in the thread

"done" → transitions task to in_progress → done (sets completed_at) "reschedule" → transitions task to in_progress → scheduled, finds next slot

Respects blackout/quiet hours via NotificationService — nudges are queued during blackout (12 AM–6 AM) and replayed at 6 AM.

See slices/slice_05_reminders_digest.md and docs/notifications.md.

logger module-attribute

logger = get_logger()

OVERDUE_BUFFER_MINUTES module-attribute

OVERDUE_BUFFER_MINUTES = 30

CHECK_INTERVAL_SECONDS module-attribute

CHECK_INTERVAL_SECONDS = 900

OverdueDetector

OverdueDetector(db: Database, service: NotificationService, bot: DonnaBot, scheduler: Scheduler, calendar_id: str, user_id: str, escalation_manager: EscalationManager | None = None, router: ModelRouter | None = None)

Detects overdue tasks and sends nudges via Discord threads.

Usage

detector = OverdueDetector(db, service, bot, scheduler, client, calendar_id, user_id)

Register the reply handler before starting the bot loop:

bot = DonnaBot(..., overdue_reply_handler=detector.handle_reply) asyncio.create_task(detector.run())

Source code in src/donna/notifications/overdue.py
def __init__(
    self,
    db: Database,
    service: NotificationService,
    bot: DonnaBot,
    scheduler: Scheduler,
    calendar_id: str,
    user_id: str,
    escalation_manager: EscalationManager | None = None,
    router: ModelRouter | None = None,
) -> None:
    self._db = db
    self._service = service
    self._bot = bot
    self._scheduler = scheduler
    self._calendar_id = calendar_id
    self._user_id = user_id
    self._escalation_manager = escalation_manager
    self._router = router
    # task_id set: only nudge once per day (reset at midnight).
    self._nudged: set[str] = set()
    self._nudged_date: str = ""

run async

run() -> None

Loop forever, checking for overdue tasks every 15 minutes.

Source code in src/donna/notifications/overdue.py
async def run(self) -> None:
    """Loop forever, checking for overdue tasks every 15 minutes."""
    logger.info(
        "overdue_detector_started",
        buffer_minutes=OVERDUE_BUFFER_MINUTES,
        interval_seconds=CHECK_INTERVAL_SECONDS,
    )

    while True:
        now = datetime.now(tz=UTC)
        today_str = now.date().isoformat()

        # Reset nudge set daily.
        if self._nudged_date != today_str:
            self._nudged.clear()
            self._nudged_date = today_str

        try:
            await self._check_and_nudge(now)
        except Exception:
            logger.exception("overdue_check_failed")

        await asyncio.sleep(CHECK_INTERVAL_SECONDS)

handle_reply async

handle_reply(task_id: str, reply: str) -> None

Handle user reply in an overdue thread.

Parameters:

Name Type Description Default
task_id str

The task that was nudged.

required
reply str

Normalised (lower-case stripped) user reply text.

required
Source code in src/donna/notifications/overdue.py
async def handle_reply(self, task_id: str, reply: str) -> None:
    """Handle user reply in an overdue thread.

    Args:
        task_id: The task that was nudged.
        reply: Normalised (lower-case stripped) user reply text.
    """
    task = await self._db.get_task(task_id)
    if task is None:
        logger.warning("overdue_reply_task_not_found", task_id=task_id)
        return

    if reply.startswith("done"):
        if self._escalation_manager is not None:
            await self._escalation_manager.acknowledge(task_id)
        await self._mark_done(task_id, task)
    elif reply.startswith("reschedule"):
        if self._escalation_manager is not None:
            await self._escalation_manager.acknowledge(task_id)
        await self._reschedule(task_id, task)
    elif reply.startswith("busy"):
        if self._escalation_manager is not None:
            await self._escalation_manager.backoff(task_id)
        logger.info("overdue_reply_busy", task_id=task_id)
    else:
        logger.info("overdue_reply_unrecognised", task_id=task_id, reply=reply[:50])