Skip to content

Commit 89e5749

Browse files
authored
Complete the snooze/expiry notification lifecycle (#165)
Snooze 24h was a delayed silent decline: the re-delivery tick the code comments promised ('wired in PR 2') never existed, and expiry was a blind status flip that reported to no one. - v037: redeliver_at + redelivery_count columns on notifications - Snooze stamps redeliver_at and pushes expires_at past it; the 15-min maintenance loop (renamed from expiry-only) re-fans-out due rows with a fresh card, capped by notifications.max_redeliveries (default 3) - Expired questions inject an internal note into the asking session (dispatched unconditionally; the per-session lock serializes), expired approvals write an approval-expired audit event; both broadcast notification_expired and edit the Telegram card best-effort - Telegram snooze ack shows 'Snoozed until <time>' instead of the generic answered state; web card shows snoozed/re-delivered state
1 parent ff03d4c commit 89e5749

14 files changed

Lines changed: 1255 additions & 78 deletions

nerve/channels/telegram.py

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1515,18 +1515,53 @@ async def _handle_callback_query(self, update: Update, context: Any) -> None:
15151515
)
15161516

15171517
if success:
1518-
await query.answer(f"Answered: {answer}")
1518+
status_line = f"\u2705 Answered: {answer}"
1519+
toast = f"Answered: {answer}"
1520+
# A snooze keeps the row pending with redeliver_at stamped \u2014
1521+
# confirm on the card that it will come back, instead of the
1522+
# generic answered state (which read as "handled, gone").
1523+
snoozed_until = await self._get_snoozed_until(notification_id)
1524+
if snoozed_until:
1525+
status_line = (
1526+
f"\U0001F4A4 Snoozed until {snoozed_until} \u2014 will resurface"
1527+
)
1528+
toast = f"Snoozed until {snoozed_until}"
1529+
await query.answer(toast)
15191530
try:
15201531
original = query.message.text or ""
15211532
await query.edit_message_text(
1522-
text=f"{original}\n\n\u2705 Answered: {answer}",
1533+
text=f"{original}\n\n{status_line}",
15231534
reply_markup=None,
15241535
)
15251536
except Exception:
15261537
pass
15271538
else:
15281539
await query.answer("Already answered or expired", show_alert=True)
15291540

1541+
async def _get_snoozed_until(self, notification_id: str) -> str | None:
1542+
"""Return a human-readable re-delivery time if the row was snoozed.
1543+
1544+
After ``handle_answer`` succeeds, a snoozed approval is the only
1545+
outcome that leaves the row ``pending`` with ``redeliver_at``
1546+
set. Rendered in the host's local timezone. None when the answer
1547+
was a final decision (or anything fails \u2014 this is cosmetic).
1548+
"""
1549+
try:
1550+
notif = await self._notification_service.db.get_notification(
1551+
notification_id,
1552+
)
1553+
if (
1554+
not notif
1555+
or notif.get("status") != "pending"
1556+
or not notif.get("redeliver_at")
1557+
):
1558+
return None
1559+
from datetime import datetime
1560+
dt = datetime.fromisoformat(notif["redeliver_at"])
1561+
return dt.astimezone().strftime("%Y-%m-%d %H:%M %Z")
1562+
except Exception:
1563+
return None
1564+
15301565
async def _handle_reply(self, update: Update, context: Any) -> None:
15311566
"""Handle /reply <text> — answer the most recent pending question."""
15321567
self._touch()

nerve/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,7 @@ class NotificationsConfig:
666666
channels: list[str] = field(default_factory=lambda: ["web", "telegram"])
667667
telegram_chat_id: int | None = None # Target chat; falls back to first allowed_user
668668
default_expiry_hours: int = 48 # Auto-expire unanswered questions
669+
max_redeliveries: int = 3 # Per-row cap on snooze/re-delivery cycles
669670
priority_prefixes: dict[str, str] = field(default_factory=lambda: {
670671
"high": "⚠️ ",
671672
"urgent": "🚨 ",
@@ -677,6 +678,7 @@ def from_dict(cls, d: dict) -> NotificationsConfig:
677678
channels=d.get("channels", ["web", "telegram"]),
678679
telegram_chat_id=d.get("telegram_chat_id"),
679680
default_expiry_hours=d.get("default_expiry_hours", 48),
681+
max_redeliveries=d.get("max_redeliveries", 3),
680682
priority_prefixes=d.get("priority_prefixes", {
681683
"high": "⚠️ ",
682684
"urgent": "🚨 ",
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""V37: Add re-delivery columns to notifications.
2+
3+
Completes the snooze lifecycle for ``question``/``approval`` rows. Until
4+
now, snoozing only advanced ``expires_at`` — the row sat pending and
5+
invisible until the expiry sweep silently killed it ("delayed silent
6+
decline"). The two new columns let the periodic maintenance tick
7+
re-surface snoozed rows with a fresh fanout:
8+
9+
- ``redeliver_at`` TEXT NULL: when set on a ``pending`` row, the
10+
maintenance tick re-fans-out the notification at/after this time
11+
(fresh Telegram card + web broadcast). NULL = no re-delivery queued.
12+
- ``redelivery_count`` INTEGER NOT NULL DEFAULT 0: how many times the
13+
row has been re-delivered. Capped by
14+
``config.notifications.max_redeliveries`` so a snooze loop can't nag
15+
forever — at the cap the row expires (with reporting) instead.
16+
17+
Existing rows are untouched (redeliver_at = NULL, count = 0), so
18+
nothing is re-delivered retroactively.
19+
"""
20+
21+
from __future__ import annotations
22+
23+
import logging
24+
25+
import aiosqlite
26+
27+
logger = logging.getLogger(__name__)
28+
29+
30+
async def up(db: aiosqlite.Connection) -> None:
31+
await db.execute(
32+
"ALTER TABLE notifications ADD COLUMN redeliver_at TEXT"
33+
)
34+
await db.execute(
35+
"ALTER TABLE notifications ADD COLUMN redelivery_count "
36+
"INTEGER NOT NULL DEFAULT 0"
37+
)
38+
# The maintenance tick polls "pending rows whose redeliver_at is due"
39+
# every 15 minutes; keep that scan off the table.
40+
await db.execute(
41+
"CREATE INDEX IF NOT EXISTS idx_notifications_redeliver "
42+
"ON notifications(status, redeliver_at)"
43+
)
44+
logger.info(
45+
"v037: added redeliver_at/redelivery_count to notifications + index"
46+
)

nerve/db/notifications.py

Lines changed: 115 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -137,26 +137,71 @@ async def dismiss_all_notifications(self) -> int:
137137
await self.db.commit()
138138
return cursor.rowcount
139139

140-
async def expire_notifications(self) -> int:
140+
async def expire_due_notifications(self) -> list[dict]:
141+
"""Flip pending rows past their expiry to ``expired``.
142+
143+
Returns the affected rows (as they were *before* the flip, with
144+
``status`` already rewritten to ``'expired'`` in the returned
145+
dicts) so the service layer can report each expiry — inject a
146+
note into the asking session, audit-log approvals, gray the web
147+
card, edit the Telegram message. Select-then-update runs inside
148+
one transaction so a concurrent answer can't slip between the
149+
two statements.
150+
"""
141151
now = datetime.now(timezone.utc).isoformat()
142-
cursor = await self.db.execute(
143-
"""UPDATE notifications SET status = 'expired'
144-
WHERE status = 'pending' AND expires_at IS NOT NULL AND expires_at < ?""",
145-
(now,),
146-
)
147-
await self.db.commit()
148-
return cursor.rowcount
152+
async with self._atomic():
153+
async with self.db.execute(
154+
"""SELECT * FROM notifications
155+
WHERE status = 'pending'
156+
AND expires_at IS NOT NULL AND expires_at < ?""",
157+
(now,),
158+
) as cursor:
159+
rows = [dict(row) async for row in cursor]
160+
if rows:
161+
placeholders = ",".join("?" for _ in rows)
162+
await self.db.execute(
163+
f"""UPDATE notifications SET status = 'expired'
164+
WHERE id IN ({placeholders})""",
165+
tuple(r["id"] for r in rows),
166+
)
167+
for r in rows:
168+
r["status"] = "expired"
169+
return rows
170+
171+
async def expire_notification(self, notification_id: str) -> bool:
172+
"""Flip a single pending row to ``expired``.
173+
174+
Used by the re-delivery tick when a row hits the
175+
``max_redeliveries`` cap: instead of another fanout, it expires
176+
(with reporting) even though ``expires_at`` may still be in the
177+
future. Returns False if the row is not pending.
178+
"""
179+
async with self._atomic():
180+
async with self.db.execute(
181+
"SELECT id FROM notifications WHERE id = ? AND status = 'pending'",
182+
(notification_id,),
183+
) as cursor:
184+
if not await cursor.fetchone():
185+
return False
186+
await self.db.execute(
187+
"UPDATE notifications SET status = 'expired' WHERE id = ?",
188+
(notification_id,),
189+
)
190+
return True
149191

150192
async def snooze_notification(
151-
self, notification_id: str, new_expires_at: str,
193+
self, notification_id: str, redeliver_at: str, new_expires_at: str,
152194
) -> bool:
153-
"""Push a pending notification's expiry forward.
195+
"""Queue a pending notification for re-delivery.
154196
155-
Used by the ``approval`` dispatcher when the user picks
156-
``snooze_24h``: the row stays at status=pending so a later
157-
re-delivery tick (wired in PR 2) can surface it again, but the
158-
expiry advances so it does not get caught by ``expire_stale``
159-
in the meantime.
197+
Used when the user picks ``snooze_24h`` on an approval: the row
198+
stays at status=pending, ``redeliver_at`` marks when the
199+
periodic maintenance tick should fan it out again (fresh
200+
Telegram card + web broadcast), and ``expires_at`` advances past
201+
the re-delivery time so the row cannot expire before it
202+
resurfaces. Re-snoozing after a re-delivery simply sets
203+
``redeliver_at`` again — each snooze buys another cycle, up to
204+
``config.notifications.max_redeliveries``.
160205
161206
Returns True on success, False if the row is not pending.
162207
"""
@@ -168,11 +213,64 @@ async def snooze_notification(
168213
if not await cursor.fetchone():
169214
return False
170215
await self.db.execute(
171-
"UPDATE notifications SET expires_at = ? WHERE id = ?",
172-
(new_expires_at, notification_id),
216+
"""UPDATE notifications SET redeliver_at = ?, expires_at = ?
217+
WHERE id = ?""",
218+
(redeliver_at, new_expires_at, notification_id),
173219
)
174220
return True
175221

222+
async def get_due_redeliveries(self) -> list[dict]:
223+
"""Return pending rows whose ``redeliver_at`` has passed.
224+
225+
Oldest-first so long-waiting rows resurface before fresher ones
226+
when several come due in the same sweep.
227+
"""
228+
now = datetime.now(timezone.utc).isoformat()
229+
async with self.db.execute(
230+
"""SELECT * FROM notifications
231+
WHERE status = 'pending'
232+
AND redeliver_at IS NOT NULL AND redeliver_at <= ?
233+
ORDER BY created_at ASC""",
234+
(now,),
235+
) as cursor:
236+
return [dict(row) async for row in cursor]
237+
238+
async def mark_notification_redelivered(
239+
self, notification_id: str, new_expires_at: str | None = None,
240+
) -> bool:
241+
"""Record one re-delivery: bump the count, clear ``redeliver_at``.
242+
243+
``new_expires_at`` (when given) restarts the expiry window so
244+
the fresh card gets a full answering window — without it, a row
245+
whose original expiry already passed would be expired by the
246+
very next pass of the same sweep that just re-delivered it.
247+
Returns False if the row is not pending.
248+
"""
249+
async with self._atomic():
250+
async with self.db.execute(
251+
"SELECT id FROM notifications WHERE id = ? AND status = 'pending'",
252+
(notification_id,),
253+
) as cursor:
254+
if not await cursor.fetchone():
255+
return False
256+
if new_expires_at is not None:
257+
await self.db.execute(
258+
"""UPDATE notifications
259+
SET redelivery_count = redelivery_count + 1,
260+
redeliver_at = NULL, expires_at = ?
261+
WHERE id = ?""",
262+
(new_expires_at, notification_id),
263+
)
264+
else:
265+
await self.db.execute(
266+
"""UPDATE notifications
267+
SET redelivery_count = redelivery_count + 1,
268+
redeliver_at = NULL
269+
WHERE id = ?""",
270+
(notification_id,),
271+
)
272+
return True
273+
176274
async def count_pending_notifications(self, channel: str | None = None) -> int:
177275
sql = "SELECT COUNT(*) FROM notifications WHERE status = 'pending'"
178276
params: tuple = ()

nerve/gateway/server.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -256,18 +256,29 @@ async def _periodic_idle_sweep():
256256

257257
idle_sweep_task = asyncio.create_task(_periodic_idle_sweep())
258258

259-
# Periodic notification expiry (every 15 minutes)
260-
async def _periodic_notify_expiry():
259+
# Periodic notification maintenance (every 15 minutes): re-deliver
260+
# snoozed rows, then expire stale ones. Ordered so a row whose
261+
# redeliver_at AND expires_at both passed gets its last chance
262+
# (re-delivery restarts the expiry window) instead of dying.
263+
async def _periodic_notify_maintenance():
261264
while True:
262265
await asyncio.sleep(15 * 60)
266+
try:
267+
redelivered = await notification_service.redeliver_due()
268+
if redelivered:
269+
logger.info(
270+
"Re-delivered %d snoozed notifications", redelivered,
271+
)
272+
except Exception as e:
273+
logger.error("Notification re-delivery failed: %s", e)
263274
try:
264275
expired = await notification_service.expire_stale()
265276
if expired:
266277
logger.info("Expired %d stale notifications", expired)
267278
except Exception as e:
268279
logger.error("Notification expiry failed: %s", e)
269280

270-
notify_expiry_task = asyncio.create_task(_periodic_notify_expiry())
281+
notify_maintenance_task = asyncio.create_task(_periodic_notify_maintenance())
271282

272283
# Periodic DB retention (opt-in). Compacts old memorized messages'
273284
# blocks/thinking JSON and prunes append-only telemetry + file snapshots,
@@ -475,7 +486,7 @@ async def _periodic_backup():
475486
await cron_task.stop()
476487

477488
db_retention_task.cancel()
478-
notify_expiry_task.cancel()
489+
notify_maintenance_task.cancel()
479490
backup_task.cancel()
480491
idle_sweep_task.cancel()
481492
memorize_task.cancel()

nerve/notifications/handlers.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77
return a structured ``DispatchResult`` so the service can audit-log the
88
outcome uniformly.
99
10-
PR 1 ships one dispatcher: ``mechanical-action``. PR 2 will add the
11-
``plan`` dispatcher and the per-target re-delivery wiring.
10+
One dispatcher ships today: ``mechanical-action``. Snoozed approvals
11+
are re-surfaced by the notification service's periodic maintenance
12+
tick (``NotificationService.redeliver_due``); a future ``plan``
13+
dispatcher can register here without service changes.
1214
1315
Design notes:
1416
@@ -62,8 +64,11 @@ class DispatchResult:
6264
mechanical-actions audit log. Already includes the dispatcher's
6365
own event name (``approval-acted``); the service adds the
6466
notification id and timestamp.
65-
- ``snooze_until``: ISO-8601 UTC timestamp for the new expiry when
66-
the decision is a snooze. None for approve / decline.
67+
- ``snooze_until``: ISO-8601 UTC timestamp for when a snoozed
68+
notification should be re-delivered. The service stamps it into
69+
the row's ``redeliver_at`` (and pushes ``expires_at`` past it) so
70+
the periodic maintenance tick re-surfaces the card. None for
71+
approve / decline.
6772
"""
6873

6974
ok: bool
@@ -151,9 +156,9 @@ def _dispatch_mechanical_action(
151156
Snooze: rather than touch the queue file directly, this dispatcher
152157
calls ``mechanical-action.sh snooze <id> --hours 24`` and lets the
153158
decide-side script record the audit event and update the queue
154-
entry's ``not_before`` field. PR 2 wires the re-delivery scheduler;
155-
until then, the snooze just keeps the proposal in queue/ with a
156-
future ``not_before`` timestamp.
159+
entry's ``not_before`` field. The returned ``snooze_until`` makes
160+
the service stamp the notification row's ``redeliver_at``, and the
161+
periodic maintenance tick re-delivers the card at that time.
157162
"""
158163
notif_id = notification.get("id", "")
159164
base_event: dict[str, Any] = {

0 commit comments

Comments
 (0)