PROTOCOL.md + away-channel fallthrough

PROTOCOL.md: generated from internal/voice/wire.go — documents the
voice wire format (TCP, length-prefixed JSON, methods, pushes, errors)
for multi-client implementors. Must stay in sync with wire.go.

dispatcher: when voice sink returns ErrVoiceNoSession, skip voice and
continue to remaining channels instead of aborting. sev4-present
already has ntfy in the routing table (continues naturally). sev1-3
present have only voice — the loop ends with no dispatches, which
matches the spec (care/ops-soft drop on no-voice).

voicesink: maps voice.ErrNoSession to delivery.ErrVoiceNoSession so
the dispatcher can detect it without importing the voice package.
This commit is contained in:
kami
2026-07-03 13:18:38 +02:00
parent 877136aa61
commit bc0e3a5a89
4 changed files with 280 additions and 2 deletions
+10
View File
@@ -27,10 +27,20 @@
package delivery
import (
"errors"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/store"
)
// ErrVoiceNoSession — sentinel returned by the voice sink when no live
// client session exists at push time. The dispatcher treats this as a
// skip-continue: the voice channel is unavailable for this delivery, but
// remaining channels (ntfy, telegram) should still fire. The voicesink
// maps voice.ErrNoSession to this sentinel so the dispatcher doesn't need
// to import the voice package.
var ErrVoiceNoSession = errors.New("delivery: voice: no live session")
// Channel — one delivery transport. Drop is an explicit no-op (the routing
// table chose to suppress, which is a decision, not a failure — "a missed
// water nudge is noise"). a nil Sink for a wired channel is a daemon config
+10
View File
@@ -2,7 +2,9 @@ package delivery
import (
"context"
"errors"
"fmt"
"log"
"time"
"github.com/kami/maven/internal/loop"
@@ -104,6 +106,10 @@ func (d *Dispatcher) DispatchNudge(ctx context.Context, pn PhrasedNudge, now tim
continue
}
if err := sink.Send(ctx, s); err != nil {
if errors.Is(err, ErrVoiceNoSession) {
log.Printf("dispatcher: no live voice session for %s, falling through", c.Rule.Name)
continue
}
return out, fmt.Errorf("send %s: %w", ch, err)
}
// record AFTER successful send — a failed send must not pollute the
@@ -151,6 +157,10 @@ func (d *Dispatcher) DispatchReminder(ctx context.Context, pr PhrasedReminder, n
continue
}
if err := sink.Send(ctx, s); err != nil {
if errors.Is(err, ErrVoiceNoSession) {
log.Printf("dispatcher: no live voice session for reminder %d, falling through", rd.Reminder.ID)
continue
}
return out, fmt.Errorf("send %s: %w", ch, err)
}
out = append(out, Dispatch{Sendable: s})
+2 -2
View File
@@ -94,8 +94,8 @@ func (s *Sink) Send(ctx context.Context, send delivery.Sendable) error {
}
if err := s.sess.PushToMostRecent(ctx, push); err != nil {
if errors.Is(err, voice.ErrNoSession) {
log.Printf("voicesink: no live voice session for %s — rerouting deferred, dropping", send.RuleName)
return err
log.Printf("voicesink: no live voice session for %s, falling through to away channels", send.RuleName)
return delivery.ErrVoiceNoSession
}
return fmt.Errorf("voicesink: push: %w", err)
}