Skip to content

Complete the snooze/expiry notification lifecycle (re-delivery tick + expiry reporting)#165

Merged
pufit merged 1 commit into
mainfrom
pufit/notification-snooze-redelivery
Jul 5, 2026
Merged

Complete the snooze/expiry notification lifecycle (re-delivery tick + expiry reporting)#165
pufit merged 1 commit into
mainfrom
pufit/notification-snooze-redelivery

Conversation

@pufit

@pufit pufit commented Jul 5, 2026

Copy link
Copy Markdown
Member

Problem

The propose_action approval flow shipped with its return leg missing, and plain-question expiry shares the hole:

  1. Snooze 24h was a delayed silent decline. snooze_notification only advanced expires_at, keeping the row pending "so a later re-delivery tick (wired in PR 2) can surface it again" — that tick was never built. Actual lifecycle: snooze → Telegram buttons already consumed → row sits pending and invisible → expiry sweep silently kills it 24h later.
  2. Expiry reported to no one. expire_notifications flipped pending → expired in one blind UPDATE — no web broadcast, no Telegram edit, and no word to the asking session. A cron that ask_user'd something never learns the question died; from the user's side the card just vanished.

Changes

Schema (v037)redeliver_at + redelivery_count on notifications. Existing rows untouched; verified against a snapshot of a live DB (852 rows, defaults correct, nothing re-delivered retroactively).

Snooze — stamps redeliver_at = snooze_until and pushes expires_at past it, so the row can't expire before it resurfaces. Re-snooze after re-delivery works — each click buys another cycle.

Maintenance tick_periodic_notify_expiry_periodic_notify_maintenance, same 15-min cadence, two ordered passes:

  • Redeliver: re-runs _fanout for due rows — fresh Telegram card with a fresh keyboard (new message id stored), web broadcast flagged redelivered: true. Capped by notifications.max_redeliveries (default 3); at the cap the row expires with reporting instead of re-sending, so an auto-snoozing loop can't nag forever.
  • Expire with reporting: expired questions inject [Question expired unanswered: <title>] into the origin session (engine.run(..., source="notification:expiry", internal=True), dispatched unconditionally — the per-session lock serializes; external/archived/missing sessions get broadcast only; multiple questions per session batch into one injection). Expired approvals append an approval-expired event to the mechanical-actions audit log. Both broadcast notification_expired (web grays the card live) and best-effort edit the Telegram message to "⏰ Expired unanswered" (dead keyboard dropped, failures swallowed). notify-kind expiry stays silent as before.

Redeliver runs before expire, and re-delivery restarts the expiry window — a row whose redeliver_at and expires_at both passed gets its last chance instead of dying in the same sweep.

UX — Telegram snooze ack now edits the card to "💤 Snoozed until — will resurface" instead of the generic answered state. Web: snoozed cards stay actionable with a "Snoozed — returns " line (mirrors the server: the row is still pending), re-delivered cards get a badge and move to the top without double-counting the pending badge.

Confignotifications.max_redeliveries: 3 (only new knob; reuses default_expiry_hours for the post-redelivery window).

Tests

16 new tests in tests/test_notification_lifecycle.py covering snooze semantics, the re-delivery cycle (incl. re-snooze and cap-hit), expiry reporting per kind (question injection incl. mid-turn sessions, approval audit, notify silence, external/archived guards, batching), Telegram edit + swallowed failures, migration defaults, and the both-due ordering guarantee. Full suite: 1365 passed. End-to-end simulated: propose → snooze → backdate → tick → fresh card → decide on the re-delivered card.

Sibling issue (answers arriving mid-turn get stored-not-injected) is deliberately out of scope — the expiry injection here uses the dispatch-unconditionally pattern that fix needs, factored as _inject_expiry_note for it to adopt.

Generated by Nerve

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
@pufit pufit merged commit 89e5749 into main Jul 5, 2026
2 checks passed
@pufit pufit deleted the pufit/notification-snooze-redelivery branch July 5, 2026 18:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant