Files
Maven/internal/delivery/dispatcher.go
T
kami 29f23e3715 Add durable delivery outbox: Begin-before-send, Complete-after, unknown on crash
Closes the audit finding: a crash between 'sink accepted it' and 'we
recorded that' caused duplicate sends on the next tick with no trace.
BeginDeliveryAttempt now runs before Send, CompleteDeliveryAttempt
after — a stale 'pending' row found at startup reconciles to 'unknown'
(never silently resent, never silently dropped, same rule as the Hexis
execution engine's timeout handling). Wired into DispatchNudge,
DispatchReminder, and RepeatUnacked; ReconcileStaleDeliveryAttempts
runs once at mavend startup before the tick loop resumes.

9 new dispatcher tests cover begin-before-send ordering, failed-send
completion, the reminder path, and begin-failure not blocking send.

Vikunja #270.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ghELqYhZNLub2TXGMazqA
2026-07-20 03:05:41 +04:00

362 lines
14 KiB
Go

package delivery
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"log"
"time"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/store"
)
// NudgeRecorder — the seam the store implements. the dispatcher records one
// nudge row per channel-sent (the nudges table IS the restraint memory + the
// feedback loop's only input). recording happens AFTER a successful send, so
// a failed send doesn't pollute the feedback signal with a phantom nudge —
// ignored_rate would drift on a row that never reached anyone.
type NudgeRecorder interface {
RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error)
}
// ReminderCompleter — the seam the store implements. For one-shot reminders:
// pending → fired after successful delivery. For recurring reminders (with
// cron): reschedule after successful delivery. A failed send does NOT mark or
// reschedule it (it stays pending; the next tick re-delivers).
type ReminderCompleter interface {
MarkReminder(ctx context.Context, id int64, status string) error
RescheduleReminder(ctx context.Context, id int64, now time.Time) error
}
// Outbox — the durable delivery ledger. Begin is recorded BEFORE the external
// send, so a crash between "the sink accepted it" and "we recorded that" (the
// window that causes duplicate sends on the next tick — the audit finding
// this closes) leaves a durable "pending" row instead of silence. Complete
// records the sink's actual outcome once Send returns. nil Outbox = tracking
// disabled (existing send/record behavior, unchanged — test scenarios that
// don't care about crash recovery).
type Outbox interface {
BeginDeliveryAttempt(ctx context.Context, kind, rule string, reminderID int64, channel, bodyHash string, now time.Time) (int64, error)
CompleteDeliveryAttempt(ctx context.Context, id int64, status string, now time.Time) error
}
// bodyHash is an opaque dedup/triage key for a delivery attempt row — not a
// uniqueness constraint (the same rule/reminder legitimately re-sends across
// ticks), just something an operator can compare across attempts after a
// crash to tell "same message resent" from "different message".
func bodyHash(channel Channel, body string) string {
sum := sha256.Sum256([]byte(string(channel) + "\x00" + body))
return hex.EncodeToString(sum[:8])
}
// beginOutbox records intent to send, if an Outbox is wired. A failure to
// record intent is not fatal to the send itself — losing outbox visibility
// on one attempt shouldn't block a nudge/reminder actually reaching the user
// — but it does mean this attempt can't be reconciled after a crash, so it's
// logged. Returns 0 (no-op id) when unrecorded.
func (d *Dispatcher) beginOutbox(ctx context.Context, kind, rule string, reminderID int64, channel Channel, body string, now time.Time) int64 {
if d.cfg.Outbox == nil {
return 0
}
id, err := d.cfg.Outbox.BeginDeliveryAttempt(ctx, kind, rule, reminderID, string(channel), bodyHash(channel, body), now)
if err != nil {
log.Printf("dispatcher: outbox begin failed (send proceeds untracked): %v", err)
return 0
}
return id
}
// completeOutbox records the sink's outcome for a prior beginOutbox call.
// id==0 means either tracking is disabled or the begin failed — nothing to
// complete either way.
func (d *Dispatcher) completeOutbox(ctx context.Context, id int64, status string, now time.Time) {
if id == 0 || d.cfg.Outbox == nil {
return
}
if err := d.cfg.Outbox.CompleteDeliveryAttempt(ctx, id, status, now); err != nil {
log.Printf("dispatcher: outbox complete failed: %v", err)
}
}
// PhrasedNudge — the phraser module's output for a nudge. the phraser (LFM
// sub-1b, prompted not trained) takes (rule, severity, context) and produces
// Body (full message for voice) + Summary (minimal body for away channels).
// the phraser is a separate module; the dispatcher only consumes its output.
type PhrasedNudge struct {
Candidate loop.Candidate
Body string
Summary string
Mood string
}
// PhrasedReminder — the phraser's output for a reminder.
type PhrasedReminder struct {
Decision loop.ReminderDecision
Body string
Summary string
Mood string
}
// Dispatch — record of one successful send. returned to the daemon for
// logging, ack wiring, and nudge-id tracking. NudgeID is set for nudges
// (the feedback loop's key); 0 for reminders.
type Dispatch struct {
Sendable Sendable
NudgeID int64
}
// Config — wires the dispatcher. nil sinks = that channel not wired. nil
// AckTracker = repeat-til-ack disabled (the daemon doesn't wire it until the
// telegram module lands). nil Nudges/Reminders = recording disabled (test
// scenarios that only exercise routing).
type Config struct {
Voice Sink
Ntfy Sink
Telegram Sink
Ack AckTracker
Nudges NudgeRecorder
Reminders ReminderCompleter
Outbox Outbox
}
// Dispatcher — holds one sink per channel + the recorder seams. the daemon
// wires it once; per-tick it calls DispatchNudge / DispatchReminder. the
// routing table (channel.go) is pure; this struct is the impure orchestration.
type Dispatcher struct {
cfg Config
}
func NewDispatcher(cfg Config) *Dispatcher {
return &Dispatcher{cfg: cfg}
}
// DispatchNudge — routes a phrased nudge to the channels the routing table
// picks for (severity, presence), sends via the matching sink, and records
// one nudge row per successful send. returns the dispatches (one per channel).
//
// a Drop channel = no send, no record (the nudge was suppressed by routing,
// not by a failure — "a missed water nudge is noise"). a nil sink = channel
// not wired, skip silently. a send error stops the dispatch and returns what
// got through — the daemon decides whether to retry.
func (d *Dispatcher) DispatchNudge(ctx context.Context, pn PhrasedNudge, now time.Time) ([]Dispatch, error) {
c := pn.Candidate
channels := ChannelsFor(c.Severity, c.State.Presence)
var out []Dispatch
for i := 0; i < len(channels); i++ {
ch := channels[i]
if ch == ChannelDrop {
continue
}
s := Sendable{
Channel: ch,
Kind: KindNudge,
Severity: c.Severity,
RuleName: c.Rule.Name,
Body: pn.Body,
Summary: pn.Summary,
RepeatUntilAck: ch == ChannelTelegram && c.Severity >= loop.Sev4,
Ts: now,
}
sink := d.sinkFor(ch)
if sink == nil {
continue
}
attemptID := d.beginOutbox(ctx, "nudge", c.Rule.Name, 0, ch, messageForChannel(s), now)
if err := sink.Send(ctx, s); err != nil {
if errors.Is(err, ErrVoiceNoSession) {
// voice was assumed reachable (presence=present) but no live
// session exists — the presence guess was wrong. reroute through
// the AWAY table per § away-channel fallthrough: sev3→ntfy,
// sev4→telegram-repeat-til-ack, sev≤2→drop. voice is always the
// first present channel, so nothing has been sent yet; replace
// the remaining list wholesale. away channels never include
// voice, so this can't re-trigger.
d.completeOutbox(ctx, attemptID, store.DeliveryFailed, now)
log.Printf("dispatcher: no live voice session for %s, rerouting to away channels", c.Rule.Name)
channels = ChannelsFor(c.Severity, store.Away)
i = -1
continue
}
d.completeOutbox(ctx, attemptID, store.DeliveryFailed, now)
return out, fmt.Errorf("send %s: %w", ch, err)
}
d.completeOutbox(ctx, attemptID, store.DeliverySent, now)
// record AFTER successful send — a failed send must not pollute the
// feedback loop with a phantom nudge (ignored_rate would drift).
var nudgeID int64
if d.cfg.Nudges != nil {
id, err := d.cfg.Nudges.RecordNudge(ctx, c.Rule.Name, string(ch), messageForChannel(s), now)
if err != nil {
return out, fmt.Errorf("record nudge %s: %w", ch, err)
}
nudgeID = id
}
// for repeat-til-ack telegram sends, mark the initial send in the ack
// tracker so ShouldRepeat's clock starts now.
if s.RepeatUntilAck && d.cfg.Ack != nil {
if err := d.cfg.Ack.MarkSent(ctx, c.Rule.Name, now); err != nil {
return out, fmt.Errorf("ack mark-sent: %w", err)
}
}
out = append(out, Dispatch{Sendable: s, NudgeID: nudgeID})
}
return out, nil
}
// DispatchReminder — routes a phrased reminder. reminders bypass the gate and
// fire once (pending → fired after successful delivery). voice when present,
// ntfy when away. no repeat (reminders fire once). marks the reminder fired
// only if at least one channel succeeded — a failed send leaves it pending
// for the next tick to re-deliver.
func (d *Dispatcher) DispatchReminder(ctx context.Context, pr PhrasedReminder, now time.Time) ([]Dispatch, error) {
rd := pr.Decision
channels := ChannelsForReminder(rd.State.Presence)
var out []Dispatch
for i := 0; i < len(channels); i++ {
ch := channels[i]
s := Sendable{
Channel: ch,
Kind: KindReminder,
ReminderID: rd.Reminder.ID,
Body: pr.Body,
Summary: pr.Summary,
Ts: now,
}
sink := d.sinkFor(ch)
if sink == nil {
continue
}
attemptID := d.beginOutbox(ctx, "reminder", "", rd.Reminder.ID, ch, messageForChannel(s), now)
if err := sink.Send(ctx, s); err != nil {
if errors.Is(err, ErrVoiceNoSession) {
// presence guess was wrong — reroute reminder to the away
// channel (ntfy). voice is the only present channel, so nothing
// has been sent yet.
d.completeOutbox(ctx, attemptID, store.DeliveryFailed, now)
log.Printf("dispatcher: no live voice session for reminder %d, rerouting to away channels", rd.Reminder.ID)
channels = ChannelsForReminder(store.Away)
i = -1
continue
}
d.completeOutbox(ctx, attemptID, store.DeliveryFailed, now)
return out, fmt.Errorf("send %s: %w", ch, err)
}
d.completeOutbox(ctx, attemptID, store.DeliverySent, now)
out = append(out, Dispatch{Sendable: s})
}
if d.cfg.Reminders != nil && len(out) > 0 {
// ID=0 is a synthetic digest reminder; it's not in the DB. Complete
// the collapsed originals it stands in for instead — only now, after
// a successful send, so a failed digest leaves them all pending.
if rd.Reminder.ID == 0 {
for _, orig := range rd.Reminder.Collapsed {
if err := d.completeReminder(ctx, orig, now); err != nil {
return out, err
}
}
} else if err := d.completeReminder(ctx, rd.Reminder, now); err != nil {
return out, err
}
}
return out, nil
}
// completeReminder — post-delivery bookkeeping for one reminder: recurring
// (cron set) reschedules, one-shot marks fired.
func (d *Dispatcher) completeReminder(ctx context.Context, r store.Reminder, now time.Time) error {
if r.Cron != "" {
if err := d.cfg.Reminders.RescheduleReminder(ctx, r.ID, now); err != nil {
return fmt.Errorf("reschedule reminder %d: %w", r.ID, err)
}
return nil
}
if err := d.cfg.Reminders.MarkReminder(ctx, r.ID, "fired"); err != nil {
return fmt.Errorf("mark reminder %d fired: %w", r.ID, err)
}
return nil
}
// RepeatUnacked — the daemon calls this each tick to re-send un-acked sev4
// telegram nudges. `keys` = rule names with un-acked telegram sends (the
// daemon queries the nudges table for pending-outcome sev4+telegram rows —
// a store helper that's deferred). for each key: if not acked and the repeat
// interval has elapsed since the last send, re-send + update last-sent.
//
// the body/summary are passed in because the phraser output from the original
// dispatch isn't retained — the daemon re-phrases (or reuses a cached phrase).
// a sev4 alarm repeating with the same terse body is correct; it's an alarm.
func (d *Dispatcher) RepeatUnacked(ctx context.Context, keys []string, now time.Time, interval time.Duration, body, summary string) ([]Dispatch, error) {
if d.cfg.Telegram == nil || d.cfg.Ack == nil {
return nil, nil
}
var out []Dispatch
for _, key := range keys {
acked, err := d.cfg.Ack.WasAcked(ctx, key)
if err != nil {
return out, fmt.Errorf("ack was-acked %s: %w", key, err)
}
last, err := d.cfg.Ack.LastSent(ctx, key)
if err != nil {
return out, fmt.Errorf("ack last-sent %s: %w", key, err)
}
if !ShouldRepeat(last, acked, now, interval) {
continue
}
s := Sendable{
Channel: ChannelTelegram,
Kind: KindNudge,
Severity: loop.Sev4,
RuleName: key,
Body: body,
Summary: summary,
RepeatUntilAck: true,
Ts: now,
}
attemptID := d.beginOutbox(ctx, "nudge", key, 0, ChannelTelegram, messageForChannel(s), now)
if err := d.cfg.Telegram.Send(ctx, s); err != nil {
d.completeOutbox(ctx, attemptID, store.DeliveryFailed, now)
return out, fmt.Errorf("repeat send telegram %s: %w", key, err)
}
d.completeOutbox(ctx, attemptID, store.DeliverySent, now)
if err := d.cfg.Ack.MarkSent(ctx, key, now); err != nil {
return out, fmt.Errorf("ack mark-sent %s: %w", key, err)
}
out = append(out, Dispatch{Sendable: s})
}
return out, nil
}
func (d *Dispatcher) sinkFor(ch Channel) Sink {
switch ch {
case ChannelVoice:
return d.cfg.Voice
case ChannelNtfy:
return d.cfg.Ntfy
case ChannelTelegram:
return d.cfg.Telegram
default:
return nil
}
}
// messageForChannel — away channels get the minimal summary (no shoulder-surf
// exfil — "disk low on homesrv," not detail); voice gets the full body (local).
// a missing summary falls back to body — a terse full message is better than
// no message, and the phraser should have produced a summary for away-bound
// severities. this is the "minimal body" rule from the spec, enforced at the
// last mile so a phraser bug can't accidentally exfil via the relay.
func messageForChannel(s Sendable) string {
switch s.Channel {
case ChannelNtfy, ChannelTelegram:
if s.Summary != "" {
return s.Summary
}
return s.Body
default:
return s.Body
}
}