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, reply_handler: ReplyHandler | None = None, calendar_client: Any | None = None, tz: ZoneInfo | 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,
    reply_handler: ReplyHandler | None = None,
    calendar_client: Any | None = None,
    tz: zoneinfo.ZoneInfo | 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
    self._reply_handler = reply_handler
    self._calendar_client = calendar_client
    self._tz = tz
    # 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 as exc:
            logger.exception("overdue_check_failed")
            await self._service.dispatch_fallback_alert(
                component="overdue_detector",
                error=f"Overdue check failed: {type(exc).__name__}: {exc}",
                fallback="skipped this check cycle",
            )

        await asyncio.sleep(CHECK_INTERVAL_SECONDS)

handle_reply async

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

Handle user reply in an overdue thread.

Delegates to ReplyHandler if wired, falls back to legacy keywords.

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

    Delegates to ReplyHandler if wired, falls back to legacy keywords.
    """
    task = await self._db.get_task(task_id)
    if task is None:
        logger.warning("overdue_reply_task_not_found", task_id=task_id)
        return None

    if self._reply_handler is not None:
        thread_id = f"overdue-{task_id}"
        try:
            result = await self._reply_handler.handle(
                thread_id, reply, task, "overdue",
            )
        except Exception as exc:
            logger.exception("reply_handler_failed", task_id=task_id)
            await self._service.dispatch_fallback_alert(
                component="overdue_reply",
                error=(
                    f"Reply handler crashed for task '{task.title}': "
                    f"{type(exc).__name__}: {exc}"
                ),
                fallback="reply ignored",
                context={"task_id": task_id},
            )
            return None
        logger.info(
            "overdue_reply_handled",
            task_id=task_id,
            path=result.path,
            action=result.action,
        )
        if result.path in ("fast", "plan_confirmed") and self._escalation_manager is not None:
            if result.action in ("mark_done", "reschedule"):
                await self._escalation_manager.acknowledge(task_id)
            elif result.action == "snooze":
                await self._escalation_manager.backoff(task_id)
        return result

    # Legacy fallback (kept until ReplyHandler is fully wired)
    done_kw = {"done", "finished", "complete", "completed", "did it", "yes"}
    reschedule_kw = {"reschedule", "tomorrow", "later", "push", "move"}
    busy_kw = {"busy", "not now", "snooze"}

    words = reply.lower()
    if any(kw in words for kw in done_kw):
        if self._escalation_manager is not None:
            await self._escalation_manager.acknowledge(task_id)
        await self._mark_done(task_id, task)
    elif any(kw in words for kw in reschedule_kw):
        if self._escalation_manager is not None:
            await self._escalation_manager.acknowledge(task_id)
        await self._reschedule(task_id, task)
    elif any(kw in words for kw in busy_kw):
        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])
    return None