Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions nerve/channels/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -1515,18 +1515,53 @@ async def _handle_callback_query(self, update: Update, context: Any) -> None:
)

if success:
await query.answer(f"Answered: {answer}")
status_line = f"\u2705 Answered: {answer}"
toast = f"Answered: {answer}"
# A snooze keeps the row pending with redeliver_at stamped \u2014
# confirm on the card that it will come back, instead of the
# generic answered state (which read as "handled, gone").
snoozed_until = await self._get_snoozed_until(notification_id)
if snoozed_until:
status_line = (
f"\U0001F4A4 Snoozed until {snoozed_until} \u2014 will resurface"
)
toast = f"Snoozed until {snoozed_until}"
await query.answer(toast)
try:
original = query.message.text or ""
await query.edit_message_text(
text=f"{original}\n\n\u2705 Answered: {answer}",
text=f"{original}\n\n{status_line}",
reply_markup=None,
)
except Exception:
pass
else:
await query.answer("Already answered or expired", show_alert=True)

async def _get_snoozed_until(self, notification_id: str) -> str | None:
"""Return a human-readable re-delivery time if the row was snoozed.

After ``handle_answer`` succeeds, a snoozed approval is the only
outcome that leaves the row ``pending`` with ``redeliver_at``
set. Rendered in the host's local timezone. None when the answer
was a final decision (or anything fails \u2014 this is cosmetic).
"""
try:
notif = await self._notification_service.db.get_notification(
notification_id,
)
if (
not notif
or notif.get("status") != "pending"
or not notif.get("redeliver_at")
):
return None
from datetime import datetime
dt = datetime.fromisoformat(notif["redeliver_at"])
return dt.astimezone().strftime("%Y-%m-%d %H:%M %Z")
except Exception:
return None

async def _handle_reply(self, update: Update, context: Any) -> None:
"""Handle /reply <text> — answer the most recent pending question."""
self._touch()
Expand Down
2 changes: 2 additions & 0 deletions nerve/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,7 @@ class NotificationsConfig:
channels: list[str] = field(default_factory=lambda: ["web", "telegram"])
telegram_chat_id: int | None = None # Target chat; falls back to first allowed_user
default_expiry_hours: int = 48 # Auto-expire unanswered questions
max_redeliveries: int = 3 # Per-row cap on snooze/re-delivery cycles
priority_prefixes: dict[str, str] = field(default_factory=lambda: {
"high": "⚠️ ",
"urgent": "🚨 ",
Expand All @@ -677,6 +678,7 @@ def from_dict(cls, d: dict) -> NotificationsConfig:
channels=d.get("channels", ["web", "telegram"]),
telegram_chat_id=d.get("telegram_chat_id"),
default_expiry_hours=d.get("default_expiry_hours", 48),
max_redeliveries=d.get("max_redeliveries", 3),
priority_prefixes=d.get("priority_prefixes", {
"high": "⚠️ ",
"urgent": "🚨 ",
Expand Down
46 changes: 46 additions & 0 deletions nerve/db/migrations/v037_notification_redelivery.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""V37: Add re-delivery columns to notifications.

Completes the snooze lifecycle for ``question``/``approval`` rows. Until
now, snoozing only advanced ``expires_at`` — the row sat pending and
invisible until the expiry sweep silently killed it ("delayed silent
decline"). The two new columns let the periodic maintenance tick
re-surface snoozed rows with a fresh fanout:

- ``redeliver_at`` TEXT NULL: when set on a ``pending`` row, the
maintenance tick re-fans-out the notification at/after this time
(fresh Telegram card + web broadcast). NULL = no re-delivery queued.
- ``redelivery_count`` INTEGER NOT NULL DEFAULT 0: how many times the
row has been re-delivered. Capped by
``config.notifications.max_redeliveries`` so a snooze loop can't nag
forever — at the cap the row expires (with reporting) instead.

Existing rows are untouched (redeliver_at = NULL, count = 0), so
nothing is re-delivered retroactively.
"""

from __future__ import annotations

import logging

import aiosqlite

logger = logging.getLogger(__name__)


async def up(db: aiosqlite.Connection) -> None:
await db.execute(
"ALTER TABLE notifications ADD COLUMN redeliver_at TEXT"
)
await db.execute(
"ALTER TABLE notifications ADD COLUMN redelivery_count "
"INTEGER NOT NULL DEFAULT 0"
)
# The maintenance tick polls "pending rows whose redeliver_at is due"
# every 15 minutes; keep that scan off the table.
await db.execute(
"CREATE INDEX IF NOT EXISTS idx_notifications_redeliver "
"ON notifications(status, redeliver_at)"
)
logger.info(
"v037: added redeliver_at/redelivery_count to notifications + index"
)
132 changes: 115 additions & 17 deletions nerve/db/notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,26 +137,71 @@ async def dismiss_all_notifications(self) -> int:
await self.db.commit()
return cursor.rowcount

async def expire_notifications(self) -> int:
async def expire_due_notifications(self) -> list[dict]:
"""Flip pending rows past their expiry to ``expired``.

Returns the affected rows (as they were *before* the flip, with
``status`` already rewritten to ``'expired'`` in the returned
dicts) so the service layer can report each expiry — inject a
note into the asking session, audit-log approvals, gray the web
card, edit the Telegram message. Select-then-update runs inside
one transaction so a concurrent answer can't slip between the
two statements.
"""
now = datetime.now(timezone.utc).isoformat()
cursor = await self.db.execute(
"""UPDATE notifications SET status = 'expired'
WHERE status = 'pending' AND expires_at IS NOT NULL AND expires_at < ?""",
(now,),
)
await self.db.commit()
return cursor.rowcount
async with self._atomic():
async with self.db.execute(
"""SELECT * FROM notifications
WHERE status = 'pending'
AND expires_at IS NOT NULL AND expires_at < ?""",
(now,),
) as cursor:
rows = [dict(row) async for row in cursor]
if rows:
placeholders = ",".join("?" for _ in rows)
await self.db.execute(
f"""UPDATE notifications SET status = 'expired'
WHERE id IN ({placeholders})""",
tuple(r["id"] for r in rows),
)
for r in rows:
r["status"] = "expired"
return rows

async def expire_notification(self, notification_id: str) -> bool:
"""Flip a single pending row to ``expired``.

Used by the re-delivery tick when a row hits the
``max_redeliveries`` cap: instead of another fanout, it expires
(with reporting) even though ``expires_at`` may still be in the
future. Returns False if the row is not pending.
"""
async with self._atomic():
async with self.db.execute(
"SELECT id FROM notifications WHERE id = ? AND status = 'pending'",
(notification_id,),
) as cursor:
if not await cursor.fetchone():
return False
await self.db.execute(
"UPDATE notifications SET status = 'expired' WHERE id = ?",
(notification_id,),
)
return True

async def snooze_notification(
self, notification_id: str, new_expires_at: str,
self, notification_id: str, redeliver_at: str, new_expires_at: str,
) -> bool:
"""Push a pending notification's expiry forward.
"""Queue a pending notification for re-delivery.

Used by the ``approval`` dispatcher when the user picks
``snooze_24h``: the row stays at status=pending so a later
re-delivery tick (wired in PR 2) can surface it again, but the
expiry advances so it does not get caught by ``expire_stale``
in the meantime.
Used when the user picks ``snooze_24h`` on an approval: the row
stays at status=pending, ``redeliver_at`` marks when the
periodic maintenance tick should fan it out again (fresh
Telegram card + web broadcast), and ``expires_at`` advances past
the re-delivery time so the row cannot expire before it
resurfaces. Re-snoozing after a re-delivery simply sets
``redeliver_at`` again — each snooze buys another cycle, up to
``config.notifications.max_redeliveries``.

Returns True on success, False if the row is not pending.
"""
Expand All @@ -168,11 +213,64 @@ async def snooze_notification(
if not await cursor.fetchone():
return False
await self.db.execute(
"UPDATE notifications SET expires_at = ? WHERE id = ?",
(new_expires_at, notification_id),
"""UPDATE notifications SET redeliver_at = ?, expires_at = ?
WHERE id = ?""",
(redeliver_at, new_expires_at, notification_id),
)
return True

async def get_due_redeliveries(self) -> list[dict]:
"""Return pending rows whose ``redeliver_at`` has passed.

Oldest-first so long-waiting rows resurface before fresher ones
when several come due in the same sweep.
"""
now = datetime.now(timezone.utc).isoformat()
async with self.db.execute(
"""SELECT * FROM notifications
WHERE status = 'pending'
AND redeliver_at IS NOT NULL AND redeliver_at <= ?
ORDER BY created_at ASC""",
(now,),
) as cursor:
return [dict(row) async for row in cursor]

async def mark_notification_redelivered(
self, notification_id: str, new_expires_at: str | None = None,
) -> bool:
"""Record one re-delivery: bump the count, clear ``redeliver_at``.

``new_expires_at`` (when given) restarts the expiry window so
the fresh card gets a full answering window — without it, a row
whose original expiry already passed would be expired by the
very next pass of the same sweep that just re-delivered it.
Returns False if the row is not pending.
"""
async with self._atomic():
async with self.db.execute(
"SELECT id FROM notifications WHERE id = ? AND status = 'pending'",
(notification_id,),
) as cursor:
if not await cursor.fetchone():
return False
if new_expires_at is not None:
await self.db.execute(
"""UPDATE notifications
SET redelivery_count = redelivery_count + 1,
redeliver_at = NULL, expires_at = ?
WHERE id = ?""",
(new_expires_at, notification_id),
)
else:
await self.db.execute(
"""UPDATE notifications
SET redelivery_count = redelivery_count + 1,
redeliver_at = NULL
WHERE id = ?""",
(notification_id,),
)
return True

async def count_pending_notifications(self, channel: str | None = None) -> int:
sql = "SELECT COUNT(*) FROM notifications WHERE status = 'pending'"
params: tuple = ()
Expand Down
19 changes: 15 additions & 4 deletions nerve/gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,18 +256,29 @@ async def _periodic_idle_sweep():

idle_sweep_task = asyncio.create_task(_periodic_idle_sweep())

# Periodic notification expiry (every 15 minutes)
async def _periodic_notify_expiry():
# Periodic notification maintenance (every 15 minutes): re-deliver
# snoozed rows, then expire stale ones. Ordered so a row whose
# redeliver_at AND expires_at both passed gets its last chance
# (re-delivery restarts the expiry window) instead of dying.
async def _periodic_notify_maintenance():
while True:
await asyncio.sleep(15 * 60)
try:
redelivered = await notification_service.redeliver_due()
if redelivered:
logger.info(
"Re-delivered %d snoozed notifications", redelivered,
)
except Exception as e:
logger.error("Notification re-delivery failed: %s", e)
try:
expired = await notification_service.expire_stale()
if expired:
logger.info("Expired %d stale notifications", expired)
except Exception as e:
logger.error("Notification expiry failed: %s", e)

notify_expiry_task = asyncio.create_task(_periodic_notify_expiry())
notify_maintenance_task = asyncio.create_task(_periodic_notify_maintenance())

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

db_retention_task.cancel()
notify_expiry_task.cancel()
notify_maintenance_task.cancel()
backup_task.cancel()
idle_sweep_task.cancel()
memorize_task.cancel()
Expand Down
19 changes: 12 additions & 7 deletions nerve/notifications/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
return a structured ``DispatchResult`` so the service can audit-log the
outcome uniformly.

PR 1 ships one dispatcher: ``mechanical-action``. PR 2 will add the
``plan`` dispatcher and the per-target re-delivery wiring.
One dispatcher ships today: ``mechanical-action``. Snoozed approvals
are re-surfaced by the notification service's periodic maintenance
tick (``NotificationService.redeliver_due``); a future ``plan``
dispatcher can register here without service changes.

Design notes:

Expand Down Expand Up @@ -62,8 +64,11 @@ class DispatchResult:
mechanical-actions audit log. Already includes the dispatcher's
own event name (``approval-acted``); the service adds the
notification id and timestamp.
- ``snooze_until``: ISO-8601 UTC timestamp for the new expiry when
the decision is a snooze. None for approve / decline.
- ``snooze_until``: ISO-8601 UTC timestamp for when a snoozed
notification should be re-delivered. The service stamps it into
the row's ``redeliver_at`` (and pushes ``expires_at`` past it) so
the periodic maintenance tick re-surfaces the card. None for
approve / decline.
"""

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