Files
Maven/internal/delivery/dispatcher.go
T
kami 76a6a007ef Pin the resident model to Qwen3.5-0.8B and name Qwen3-1.7B as the target
The most load-bearing decision in the project was stated four incompatible
ways: the docs said Qwen3-1.7B, deploy/mavend.json said Qwen3.5-2B, the repo's
models/llm/ held an LFM2.5-1.2B gguf, and five code comments still said LFM.
Answering "which model is deployed" meant re-deriving it from scratch every
time.

Two facts the review missed, found while resolving it:

- /mnt/hdd1/llms is bind-mounted over /opt/maven/models/llm, which shadows the
  repo's models/llm/. The LFM2.5 gguf sitting there was never loaded by
  anything, so it was not evidence of the deployed model at all.
- That library holds Qwen3.5-0.8B, -2B and -4B, and no Qwen3-1.7B. The config
  pointed at a file that does exist; the docs' Qwen3-1.7B was the stale claim,
  the reverse of the assumed direction. Qwen3-1.7B is the CPT target, and that
  training is still in flight (Vikunja #122), so no such gguf exists yet.

phraser.model_path moves to Qwen3.5-0.8B (Q4_K_M) — the smallest checkpoint on
disk, chosen for latency, and relevant to whether the LLM router is affordable
on this box. Docs and comments now say the same thing in one voice: 0.8B
resident now, CPT'd Qwen3-1.7B as the target, and the bind-mount shadowing
written down so the next reader does not mistake models/llm/ for ground truth.
Comments name the model, never a filename, so a swap stays a one-line config
change.

n_gpu_layers: 99 is correct and stays — compose passes /dev/dri and the render
gid for Vulkan offload to the Vega iGPU. CLAUDE.md's "CPU-only" was the stale
half of that contradiction and is corrected.

phraser.go also dropped a wrong "sub-1b, prompted not trained" size claim: the
target is trained end-to-end (RU CPT + joint persona/router SFT).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X5JApcrCRVGmqrxnhynSik
2026-07-30 23:40:33 +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 (the
// resident model, Qwen3-1.7B) 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
}
}