From 8b49ea896a21b65f89f8955126815097ce7a76c5 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 2 Jun 2026 18:47:54 -0400 Subject: [PATCH] fix(schedulers): wire recurring-task spawn + deliver event reminders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drift-audit Group 2 (Phase-8 amputation — live wiring, no consumer): - Recurring tasks never recurred: spawn_recurring_tasks() had no caller. Register it as a 15-min interval job in the event scheduler (which app.py already starts/stops). Also add a deleted_at IS NULL guard to the spawn query in the same change, so a trashed recurring parent can never resurrect children once the sweep is live. - Event reminders were stamped reminder_sent_at but never delivered. _fire_reminders now creates an 'event_reminder' in-app notification before stamping, so a delivery failure stays retryable. Frontend NotificationsPanel renders the new type (⏰ + message); message logic pulled into a notifMessage() helper. - Remove the dead _fire_push_notif no-op stub (push left in Phase 8) and its three create_task call sites — no more throwaway tasks per share. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/NotificationsPanel.vue | 23 +++++-- .../services/event_scheduler.py | 61 +++++++++++++++---- src/fabledassistant/services/notifications.py | 9 --- src/fabledassistant/services/recurrence.py | 3 + 4 files changed, 71 insertions(+), 25 deletions(-) diff --git a/frontend/src/components/NotificationsPanel.vue b/frontend/src/components/NotificationsPanel.vue index 90fb9b5..ea5275b 100644 --- a/frontend/src/components/NotificationsPanel.vue +++ b/frontend/src/components/NotificationsPanel.vue @@ -13,6 +13,23 @@ const typeIcon: Record = { project_shared: '📁', note_shared: '📝', group_added: '👥', + event_reminder: '⏰', +} + +function notifMessage(n: { type: string; payload: Record }): string { + const p = n.payload + switch (n.type) { + case 'project_shared': + return ` shared "${p.project_title}" with you as ${p.permission}` + case 'note_shared': + return ` shared "${p.note_title}" with you as ${p.permission}` + case 'group_added': + return ` added you to "${p.group_name}" as ${p.role}` + case 'event_reminder': + return `Reminder: "${p.title}" is coming up` + default: + return '' + } } async function handleClick(notif: { id: number; payload: Record }) { @@ -49,11 +66,7 @@ onMounted(() => store.fetchAll())

{{ n.payload.invited_by }} - {{ n.type === 'project_shared' - ? ` shared "${n.payload.project_title}" with you as ${n.payload.permission}` - : n.type === 'note_shared' - ? ` shared "${n.payload.note_title}" with you as ${n.payload.permission}` - : ` added you to "${n.payload.group_name}" as ${n.payload.role}` }} + {{ notifMessage(n) }}

{{ relativeTime(n.created_at) }}
diff --git a/src/fabledassistant/services/event_scheduler.py b/src/fabledassistant/services/event_scheduler.py index b64bea3..25ef092 100644 --- a/src/fabledassistant/services/event_scheduler.py +++ b/src/fabledassistant/services/event_scheduler.py @@ -1,10 +1,12 @@ """Scheduler jobs for background maintenance tasks. -- Reminder notifications: checks every 5 minutes for due event reminders. +- Reminder notifications: checks every 5 minutes for due event reminders and + delivers them to the in-app notification feed. - CalDAV pull sync: runs every hour for all users with CalDAV configured. -- Chat retention cleanup: runs daily, deleting old conversations per user setting. +- Recurring-task spawn: every 15 minutes, creates the next occurrence of any + recurring task whose spawn time has arrived. -Uses the same BackgroundScheduler pattern as briefing_scheduler.py. +Uses the BackgroundScheduler pattern shared with the other *_scheduler modules. """ from __future__ import annotations @@ -55,19 +57,26 @@ async def _fire_reminders() -> None: if not to_notify: return + # Deliver via the in-app notification feed (push was removed in Phase 8). + from fabledassistant.services.notifications import create_in_app_notification + async with async_session() as session: for event in to_notify: - # Push delivery removed alongside the chat subsystem in Phase 8. - # Event reminders are still flagged via in-app notifications - # (see services/notifications.py). - - # Mark as sent regardless of push success to avoid re-firing result = await session.execute( select(Event).where(Event.id == event.id) ) ev = result.scalar_one_or_none() - if ev: - ev.reminder_sent_at = datetime.now(timezone.utc) + if ev is None or ev.reminder_sent_at is not None: + continue + await create_in_app_notification(ev.user_id, "event_reminder", { + "event_id": ev.id, + "title": ev.title, + "start_dt": ev.start_dt.isoformat() if ev.start_dt else None, + "url": "/calendar", + }) + # Stamp only after the notification is created, so a delivery + # failure leaves the reminder eligible to retry next sweep. + ev.reminder_sent_at = datetime.now(timezone.utc) await session.commit() @@ -91,6 +100,22 @@ def _run_caldav_sync_threadsafe(loop: asyncio.AbstractEventLoop) -> None: asyncio.run_coroutine_threadsafe(_run_caldav_sync(), loop) +# --------------------------------------------------------------------------- +# Recurring-task spawn job +# --------------------------------------------------------------------------- + +async def _run_recurrence_spawn() -> None: + from fabledassistant.services.recurrence import spawn_recurring_tasks # noqa: PLC0415 + try: + await spawn_recurring_tasks() + except Exception: + logger.warning("Recurring-task spawn job failed", exc_info=True) + + +def _run_recurrence_spawn_threadsafe(loop: asyncio.AbstractEventLoop) -> None: + asyncio.run_coroutine_threadsafe(_run_recurrence_spawn(), loop) + + # --------------------------------------------------------------------------- # Lifecycle # --------------------------------------------------------------------------- @@ -120,8 +145,22 @@ def start_event_scheduler(loop: asyncio.AbstractEventLoop) -> None: replace_existing=True, ) + # Spawn the next occurrence of due recurring tasks every 15 minutes. + # Without this job, recurrence_next_spawn_at is armed on completion but + # never drained, so recurring tasks never recur. + _scheduler.add_job( + _run_recurrence_spawn_threadsafe, + trigger=IntervalTrigger(minutes=15), + args=[loop], + id="recurrence_spawn", + replace_existing=True, + ) + _scheduler.start() - logger.info("Event scheduler started (reminders every 5m, CalDAV sync every 1h)") + logger.info( + "Event scheduler started (reminders every 5m, CalDAV sync every 1h, " + "recurring-task spawn every 15m)" + ) def stop_event_scheduler() -> None: diff --git a/src/fabledassistant/services/notifications.py b/src/fabledassistant/services/notifications.py index c8dec98..ac178ef 100644 --- a/src/fabledassistant/services/notifications.py +++ b/src/fabledassistant/services/notifications.py @@ -266,12 +266,6 @@ async def create_in_app_notification(user_id: int, notif_type: str, payload: dic return n -async def _fire_push_notif(user_id: int, title: str, body: str, url: str) -> None: - # Push delivery was removed alongside the chat subsystem (Phase 8). - # In-app notifications still flow through the bell-icon feed. - return None - - async def _fire_share_email(user_id: int, subject: str, body_text: str) -> None: try: if not await is_smtp_configured(): @@ -325,7 +319,6 @@ async def notify_project_shared( "invited_by": inviter.username, "url": url, }) - asyncio.create_task(_fire_push_notif(uid, "Project shared with you", msg, url)) asyncio.create_task(_fire_share_email(uid, f"[Fabled] {inviter.username} shared a project with you", msg)) @@ -361,7 +354,6 @@ async def notify_note_shared( "invited_by": inviter.username, "url": url, }) - asyncio.create_task(_fire_push_notif(uid, "Note shared with you", msg, url)) asyncio.create_task(_fire_share_email(uid, f"[Fabled] {inviter.username} shared a note with you", msg)) @@ -385,7 +377,6 @@ async def notify_group_added( "invited_by": inviter.username, "url": url, }) - asyncio.create_task(_fire_push_notif(target_user_id, "Added to a group", msg, url)) asyncio.create_task(_fire_share_email(target_user_id, "[Fabled] You've been added to a group", msg)) diff --git a/src/fabledassistant/services/recurrence.py b/src/fabledassistant/services/recurrence.py index b196df2..77f57b4 100644 --- a/src/fabledassistant/services/recurrence.py +++ b/src/fabledassistant/services/recurrence.py @@ -115,6 +115,9 @@ async def spawn_recurring_tasks() -> int: and_( Note.recurrence_rule.isnot(None), Note.recurrence_next_spawn_at <= now, + # Never spawn children off a trashed parent — that would + # resurrect work the user explicitly deleted. + Note.deleted_at.is_(None), ) ) )