Files
Maven/cmd/mavend/routingtrace.go
T
claude e1f84a3474 review: a cancelled turn keeps its trace, and a quiet box still expires (V-629)
Two defects found reviewing the PR.

The insert ran on the turn's own context, so a caller that hung up or timed out
cancelled it. That is exactly the turn worth having. It now runs detached, with
a one-second bound of its own, because a write must not hold the reply.

Retention was enforced on write alone, so a box that goes quiet for a month kept
every row until the next sixty-fourth turn. pruneTracesOnStart closes that, and
RoutingTraceRetention is exported so the daemon reads the same number the store
enforces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117tgnmbgZpHVV3XSNw8Qua
2026-08-06 19:20:24 +04:00

129 lines
5.0 KiB
Go

// mavend/routingtrace.go — persisting the per-turn decision record (V-629).
//
// internal/decision keeps a 25-turn in-memory ring and persisted nothing, on the
// argument that a turn record is read minutes later or never. The owner reversed
// that on 06-08-2026, because the routing heads (V-546) cannot be fitted or
// calibrated without real utterances and there is no other source of them. The
// reversal is written down in docs/plans/21-persisting-the-routing-trace.md.
//
// The ring stays. It is what /trace reads, it is fast, and it is what a test that
// wired no store still gets. This file is the second sink beside it, and it is
// nil unless the daemon has a database — no store, no trace, no error.
package main
import (
"context"
"encoding/json"
"log"
"strings"
"time"
"github.com/kami/maven/internal/decision"
"github.com/kami/maven/internal/store"
)
// traceWriter is the seam the handler persists through. store.Store satisfies
// it. nil ⇒ the ring is the only sink, which is the pre-V-629 behaviour exactly.
type traceWriter interface {
WriteRoutingTrace(ctx context.Context, tr store.RoutingTrace) (int64, error)
}
// traceSink wraps the store, or returns nil when there is none. A typed nil
// pointer assigned straight into the interface would be non-nil and would panic
// on the first turn, which is the classic shape of this bug.
func traceSink(s *store.Store) traceWriter {
if s == nil {
return nil
}
return s
}
// pruneTracesOnStart enforces retention once at wiring time. Pruning on write
// alone is not enough: a box that goes quiet for a month keeps every row until
// the next sixty-fourth turn, and "kept for fourteen days" would then be true
// only of a box in daily use. Called for its effect and never blocks a start.
func pruneTracesOnStart(s *store.Store, now time.Time) {
if s == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := s.PruneRoutingTraces(ctx, now.Add(-store.RoutingTraceRetention)); err != nil {
log.Printf("routing trace: prune on start: %v", err)
}
}
// persistDecision writes one finished record. It takes the same *decision.Record
// the ring takes, so the two sinks cannot disagree about what the turn did.
//
// Errors are logged and swallowed. A trace is diagnostic and training data, and
// a failed insert must never change what the owner hears.
func (h *reactiveHandler) persistDecision(ctx context.Context, rec *decision.Record, src turnSource) {
if h.traces == nil || rec == nil || strings.TrimSpace(rec.Utterance) == "" {
return
}
// Detached from the turn's context, and bounded on its own. Two reasons, and
// the first is the one that matters: the turn is over by the time this runs,
// so a caller that hung up or timed out would cancel the insert, and the turn
// he abandoned halfway is exactly the one worth having. The second is that a
// write must not hold the reply, so it gets a second and no more.
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Second)
defer cancel()
claims, err := json.Marshal(rec.Claims)
if err != nil {
log.Printf("routing trace: marshal claims: %v", err)
return
}
tr := store.RoutingTrace{
Ts: rec.Ts,
Utterance: rec.Utterance,
Source: string(src),
Winner: rec.Winner,
Intent: wonIntent(rec),
ClaimedBeforeHead: claimedBeforeHead(rec),
EncoderID: h.encoderID,
Outcome: wonAt(rec, decision.StageAction),
Claims: claims,
}
if _, err := h.traces.WriteRoutingTrace(ctx, tr); err != nil {
log.Printf("routing trace: write: %v", err)
}
}
// wonIntent — what the winning claimant made the turn. Read from the claim
// rather than from the route, because a pre-route resolver wins without routing
// and its intent is the honest answer to "what was this turn".
func wonIntent(rec *decision.Record) string {
for _, c := range rec.Claims {
if c.Outcome == decision.Won && c.Intent != "" {
return c.Intent
}
}
return ""
}
// wonAt — the claimant that won at one stage. The action stage is what actually
// produced the reply, which is a different question from what was routed: a
// route that reached a gap and a route that ran are not the same turn.
func wonAt(rec *decision.Record, stage string) string {
for _, c := range rec.Claims {
if c.Stage == stage && c.Outcome == decision.Won {
return c.Claimant
}
}
return ""
}
// claimedBeforeHead — a pre-route resolver or a stage-0 grammar answered, so the
// turn teaches nothing about the classifier. Those are a large share of real
// traffic, and fitting a head on them would fit it to the grammars rather than
// to him. Recorded per turn rather than filtered on write, because which share
// that is happens to be the number V-632 needs to know.
func claimedBeforeHead(rec *decision.Record) bool {
stage, _, ok := strings.Cut(rec.Winner, ":")
if !ok {
return false
}
return stage == decision.StagePreRoute || stage == decision.StageZero
}