a turn hands back its trace id, and one wire op corrects it (V-630)

The correction is the only supervised signal in the box, so the cost of
giving one has to be near zero. That means the surface needs the trace id of
the turn it is showing, which it had no way to learn: handleText returns one
string and the trace was written after the reply left.

The id rides back on ChatReply through the same context sink querySource
uses, so the mic, telegram and the web keep the one signature they share.
CorrectTurn takes a trace id and an optional target, which is deliberately
reach-agnostic: nothing about it assumes a browser.

store.ErrNoSuchTrace gets a wire twin. A turn past the retention bound is
gone, and that is the expected outcome of correcting an old turn, not a
broken database.
This commit is contained in:
2026-08-06 19:45:37 +04:00
parent e5ec4abe04
commit 4d97280d74
11 changed files with 162 additions and 7 deletions
+48 -2
View File
@@ -16,6 +16,7 @@ import (
"encoding/json" "encoding/json"
"log" "log"
"strings" "strings"
"sync"
"time" "time"
"github.com/kami/maven/internal/decision" "github.com/kami/maven/internal/decision"
@@ -38,6 +39,44 @@ func traceSink(s *store.Store) traceWriter {
return s return s
} }
// The trace id rides the context, the same seam querysource.go uses and for the
// same reason: handleText answers every reach through one string, and threading
// a second value through the whole action dispatch would change a signature the
// mic, telegram and the web all share. A caller that wants the id asks for a
// sink; the mic path does not, and pays nothing.
type traceIDKey struct{}
type traceIDSink struct {
mu sync.Mutex
id int64
}
func (s *traceIDSink) note(id int64) {
s.mu.Lock()
defer s.mu.Unlock()
s.id = id
}
// ID is the persisted trace for the turn, or 0 when nothing was persisted.
func (s *traceIDSink) ID() int64 {
s.mu.Lock()
defer s.mu.Unlock()
return s.id
}
// withTraceIDSink returns a context that collects the persisted trace id, and
// the sink to read after the turn has answered.
func withTraceIDSink(ctx context.Context) (context.Context, *traceIDSink) {
sink := &traceIDSink{}
return context.WithValue(ctx, traceIDKey{}, sink), sink
}
func noteTraceID(ctx context.Context, id int64) {
if sink, ok := ctx.Value(traceIDKey{}).(*traceIDSink); ok {
sink.note(id)
}
}
// pruneTracesOnStart enforces retention once at wiring time. Pruning on write // 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 // 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 // the next sixty-fourth turn, and "kept for fourteen days" would then be true
@@ -58,7 +97,8 @@ func pruneTracesOnStart(s *store.Store, now time.Time) {
// //
// Errors are logged and swallowed. A trace is diagnostic and training data, and // Errors are logged and swallowed. A trace is diagnostic and training data, and
// a failed insert must never change what the owner hears. // a failed insert must never change what the owner hears.
func (h *reactiveHandler) persistDecision(ctx context.Context, rec *decision.Record, src turnSource) { func (h *reactiveHandler) persistDecision(turnCtx context.Context, rec *decision.Record, src turnSource) {
ctx := turnCtx
if h.traces == nil || rec == nil || strings.TrimSpace(rec.Utterance) == "" { if h.traces == nil || rec == nil || strings.TrimSpace(rec.Utterance) == "" {
return return
} }
@@ -85,9 +125,15 @@ func (h *reactiveHandler) persistDecision(ctx context.Context, rec *decision.Rec
Outcome: wonAt(rec, decision.StageAction), Outcome: wonAt(rec, decision.StageAction),
Claims: claims, Claims: claims,
} }
if _, err := h.traces.WriteRoutingTrace(ctx, tr); err != nil { id, err := h.traces.WriteRoutingTrace(ctx, tr)
if err != nil {
log.Printf("routing trace: write: %v", err) log.Printf("routing trace: write: %v", err)
return
} }
// The id goes back to whoever asked for it, so /chat can offer a correction
// on the turn it is already showing (V-630). Noted on the ORIGINAL context,
// not the detached one above: the sink belongs to the caller's turn.
noteTraceID(turnCtx, id)
} }
// wonIntent — what the winning claimant made the turn. Read from the claim // wonIntent — what the winning claimant made the turn. Read from the claim
+46
View File
@@ -77,3 +77,49 @@ func TestEmptyUtteranceIsNotPersisted(t *testing.T) {
} }
var _ traceWriter = (*store.Store)(nil) var _ traceWriter = (*store.Store)(nil)
// The trace id rides back to the caller, which is what makes a correction one
// gesture: /chat already has the id, so saying "that was wrong" costs a button
// and no lookup (V-630).
func TestTurnHandsBackItsTraceID(t *testing.T) {
h := traceHandler(t, decision.NewRing())
h.traces = traceSink(h.dataStore)
ctx, sink := withTraceIDSink(context.Background())
if reply := h.handleText(ctx, "web", "сколько сейчас времени"); reply == "" {
t.Fatal("turn produced no reply")
}
id := sink.ID()
if id == 0 {
t.Fatal("no trace id came back, so /chat can offer no correction")
}
// And it names the turn that just ran, so the correction lands on the right
// utterance.
if err := h.dataStore.CorrectTurn(context.Background(), id, "query", h.now()); err != nil {
t.Fatal(err)
}
labels, err := h.dataStore.RoutingLabels(context.Background(), 5)
if err != nil {
t.Fatal(err)
}
if len(labels) != 1 || labels[0].Utterance != "сколько сейчас времени" {
t.Fatalf("labels %+v, want the turn that just ran", labels)
}
}
// A turn nobody asked the id of costs nothing, which is the mic path.
func TestTurnWithNoSinkStillPersists(t *testing.T) {
h := traceHandler(t, decision.NewRing())
h.traces = traceSink(h.dataStore)
if reply := h.handleText(context.Background(), "web", "сколько сейчас времени"); reply == "" {
t.Fatal("turn produced no reply")
}
got, err := h.dataStore.RecentRoutingTraces(context.Background(), 5)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 {
t.Fatalf("persisted %d traces, want 1", len(got))
}
}
+10 -1
View File
@@ -97,10 +97,19 @@ func (d *daemonAPI) Chat(ctx context.Context, conversation, text string) (ipc.Ch
return ipc.ChatReply{}, errors.New("mavend: chat not available") return ipc.ChatReply{}, errors.New("mavend: chat not available")
} }
ctx, sink := withQuerySourceSink(ctx) ctx, sink := withQuerySourceSink(ctx)
// The trace id rides back the same way (V-630), so /chat can offer a
// correction on the turn it is already showing. 0 when nothing persisted.
ctx, traces := withTraceIDSink(ctx)
reply := d.chatFn(ctx, conversation, text) reply := d.chatFn(ctx, conversation, text)
return ipc.ChatReply{Reply: reply, Source: sink.Name()}, nil return ipc.ChatReply{Reply: reply, Source: sink.Name(), TraceID: traces.ID()}, nil
} }
// CorrectTurn is NOT overridden here, and that is deliberate (V-630). Every other
// diagnostic on this type exists because the daemon holds something the store
// cannot answer from a table. A correction is a table, so the embedded store
// adapter is already the right answer and a second implementation here would be
// a second place for it to drift.
// MCPServers — the configured MCP servers and their health (Vikunja #251). // MCPServers — the configured MCP servers and their health (Vikunja #251).
// Empty, not an error, when the mcp block is absent: "not configured" is the // Empty, not an error, when the mcp block is absent: "not configured" is the
// default state and the web surface renders it as such. // default state and the web surface renders it as such.
+21 -2
View File
@@ -724,8 +724,15 @@ type chatReq struct {
Conversation string `json:"conversation,omitempty"` Conversation string `json:"conversation,omitempty"`
} }
type chatResp struct { type chatResp struct {
Reply string `json:"reply"` Reply string `json:"reply"`
Source string `json:"source,omitempty"` Source string `json:"source,omitempty"`
TraceID int64 `json:"trace_id,omitempty"`
}
// correctTurnReq — the owner correcting one persisted turn (V-630).
type correctTurnReq struct {
TraceID int64 `json:"trace_id"`
ShouldBe string `json:"should_be,omitempty"`
} }
// ChatReply — one text turn's answer plus which query source claimed it. // ChatReply — one text turn's answer plus which query source claimed it.
@@ -738,6 +745,12 @@ type chatResp struct {
type ChatReply struct { type ChatReply struct {
Reply string Reply string
Source string Source string
// TraceID is the persisted routing trace for this turn (V-629), and it is
// what makes a correction one gesture: the surface already has the id, so
// saying "that was wrong" costs a button and no lookup. 0 ⇒ nothing was
// persisted, which is a box with no database, and the surface offers no
// correction rather than a broken one.
TraceID int64
} }
type proposeToolReq struct { type proposeToolReq struct {
@@ -937,6 +950,12 @@ var ErrTaskDuplicate = errors.New("ipc: another live task already has this text"
// is down" must not read the same to a caller deciding whether to store an id. // is down" must not read the same to a caller deciding whether to store an id.
var ErrNoEntity = errors.New("ipc: no such entity") var ErrNoEntity = errors.New("ipc: no such entity")
// ErrNoSuchTrace — the turn a correction names is not in routing_traces. Given
// a wire twin because it is the expected outcome of correcting a turn older than
// the retention bound, and "that turn is gone" and "the database is broken" must
// not read the same to the surface offering the gesture.
var ErrNoSuchTrace = errors.New("ipc: no such routing trace")
// ErrTaskResolved — a resolved task is not editable. // ErrTaskResolved — a resolved task is not editable.
var ErrTaskResolved = errors.New("ipc: task is resolved") var ErrTaskResolved = errors.New("ipc: task is resolved")
+7 -1
View File
@@ -247,6 +247,8 @@ func hydrate(e *RpcError) error {
return fmt.Errorf("%w: %s", ErrReminderState, e.Message) return fmt.Errorf("%w: %s", ErrReminderState, e.Message)
case codeToolNotFound: case codeToolNotFound:
return fmt.Errorf("%w: %s", ErrToolNotFound, e.Message) return fmt.Errorf("%w: %s", ErrToolNotFound, e.Message)
case codeNoSuchTrace:
return fmt.Errorf("%w: %s", ErrNoSuchTrace, e.Message)
case codeUnknownMethod: case codeUnknownMethod:
return fmt.Errorf("%w: %s", ErrUnknownMethod, e.Message) return fmt.Errorf("%w: %s", ErrUnknownMethod, e.Message)
case codeBadParams: case codeBadParams:
@@ -661,7 +663,11 @@ func (c *Client) Chat(ctx context.Context, conversation, text string) (ChatReply
if err := c.call(ctx, MethodChat, chatReq{Text: text, Conversation: conversation}, &r); err != nil { if err := c.call(ctx, MethodChat, chatReq{Text: text, Conversation: conversation}, &r); err != nil {
return ChatReply{}, err return ChatReply{}, err
} }
return ChatReply{Reply: r.Reply, Source: r.Source}, nil return ChatReply{Reply: r.Reply, Source: r.Source, TraceID: r.TraceID}, nil
}
func (c *Client) CorrectTurn(ctx context.Context, traceID int64, shouldBe string) error {
return c.call(ctx, MethodCorrectTurn, correctTurnReq{TraceID: traceID, ShouldBe: shouldBe}, nil)
} }
func (c *Client) TickTrace(ctx context.Context) (TickTrace, error) { func (c *Client) TickTrace(ctx context.Context) (TickTrace, error) {
+8
View File
@@ -176,6 +176,14 @@ type SystemAPI interface {
// has run since the daemon started. // has run since the daemon started.
TurnDecisions(ctx context.Context, n int) ([]TurnDecision, error) TurnDecisions(ctx context.Context, n int) ([]TurnDecision, error)
// CorrectTurn records that one persisted turn was routed wrongly, and what
// it should have been (V-630). shouldBe empty means "wrong, target
// unstated", which is a usable negative and must not cost more to give than
// the full answer. Unlike TurnDecisions this DOES reach a table, because a
// correction is the only supervised signal the box gets and it has to
// outlive the trace that carried it.
CorrectTurn(ctx context.Context, traceID int64, shouldBe string) error
// RecentEcosystemTraces reads the ecosystem call log, which lives in its // RecentEcosystemTraces reads the ecosystem call log, which lives in its
// own table so machine-rate traces never crowd out human-rate facts. // own table so machine-rate traces never crowd out human-rate facts.
RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemTrace, error) RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemTrace, error)
+1
View File
@@ -31,6 +31,7 @@ var mapErrPairs = []struct {
{"ErrReminderNotFound", store.ErrReminderNotFound, ErrReminderNotFound}, {"ErrReminderNotFound", store.ErrReminderNotFound, ErrReminderNotFound},
{"ErrReminderState", store.ErrReminderState, ErrReminderState}, {"ErrReminderState", store.ErrReminderState, ErrReminderState},
{"ErrToolNotFound", store.ErrToolNotFound, ErrToolNotFound}, {"ErrToolNotFound", store.ErrToolNotFound, ErrToolNotFound},
{"ErrNoSuchTrace", store.ErrNoSuchTrace, ErrNoSuchTrace},
{"ErrTaskNoDoneWhen", store.ErrTaskNoDoneWhen, ErrTaskNoDoneWhen}, {"ErrTaskNoDoneWhen", store.ErrTaskNoDoneWhen, ErrTaskNoDoneWhen},
{"ErrTaskDuplicate", store.ErrTaskDuplicate, ErrTaskDuplicate}, {"ErrTaskDuplicate", store.ErrTaskDuplicate, ErrTaskDuplicate},
{"ErrTaskResolved", store.ErrTaskResolved, ErrTaskResolved}, {"ErrTaskResolved", store.ErrTaskResolved, ErrTaskResolved},
+4 -1
View File
@@ -514,7 +514,10 @@ var methodTable = map[Method]handlerFunc{
}), }),
MethodChat: withParams(func(ctx context.Context, api CoreAPI, p chatReq) (chatResp, error) { MethodChat: withParams(func(ctx context.Context, api CoreAPI, p chatReq) (chatResp, error) {
reply, err := api.Chat(ctx, p.Conversation, p.Text) reply, err := api.Chat(ctx, p.Conversation, p.Text)
return chatResp{Reply: reply.Reply, Source: reply.Source}, err return chatResp{Reply: reply.Reply, Source: reply.Source, TraceID: reply.TraceID}, err
}),
MethodCorrectTurn: withParams(func(ctx context.Context, api CoreAPI, p correctTurnReq) (struct{}, error) {
return struct{}{}, api.CorrectTurn(ctx, p.TraceID, p.ShouldBe)
}), }),
MethodTickTrace: withoutParams(func(ctx context.Context, api CoreAPI) (TickTrace, error) { MethodTickTrace: withoutParams(func(ctx context.Context, api CoreAPI) (TickTrace, error) {
return api.TickTrace(ctx) return api.TickTrace(ctx)
+9
View File
@@ -213,6 +213,13 @@ func (a *storeAPI) TickTrace(ctx context.Context) (TickTrace, error) {
return TickTrace{}, errors.New("store: tick trace not available via direct store API") return TickTrace{}, errors.New("store: tick trace not available via direct store API")
} }
// CorrectTurn — unlike TickTrace and TurnDecisions this one is a table, so the
// store adapter answers it for real (V-630). A correction has to land whether
// the caller reached the daemon or the store directly.
func (a *storeAPI) CorrectTurn(ctx context.Context, traceID int64, shouldBe string) error {
return mapErr(a.s.CorrectTurn(ctx, traceID, shouldBe, time.Now()))
}
// TurnDecisions — same story as TickTrace: the arbitration record is a daemon // TurnDecisions — same story as TickTrace: the arbitration record is a daemon
// ring, not a table, so there is nothing here to read it from (V-564). // ring, not a table, so there is nothing here to read it from (V-564).
func (a *storeAPI) TurnDecisions(ctx context.Context, n int) ([]TurnDecision, error) { func (a *storeAPI) TurnDecisions(ctx context.Context, n int) ([]TurnDecision, error) {
@@ -412,6 +419,8 @@ func mapErr(err error) error {
return ErrReminderState return ErrReminderState
case errors.Is(err, store.ErrToolNotFound): case errors.Is(err, store.ErrToolNotFound):
return ErrToolNotFound return ErrToolNotFound
case errors.Is(err, store.ErrNoSuchTrace):
return ErrNoSuchTrace
case errors.Is(err, store.ErrTaskNoDoneWhen): case errors.Is(err, store.ErrTaskNoDoneWhen):
return ErrTaskNoDoneWhen return ErrTaskNoDoneWhen
case errors.Is(err, store.ErrTaskDuplicate): case errors.Is(err, store.ErrTaskDuplicate):
+4
View File
@@ -144,6 +144,10 @@ func (UnimplementedCoreAPI) RevertFact(ctx context.Context, key string) (int64,
func (UnimplementedCoreAPI) TickTrace(ctx context.Context) (TickTrace, error) { func (UnimplementedCoreAPI) TickTrace(ctx context.Context) (TickTrace, error) {
return TickTrace{}, ErrNotImplemented return TickTrace{}, ErrNotImplemented
} }
func (UnimplementedCoreAPI) CorrectTurn(ctx context.Context, traceID int64, shouldBe string) error {
return ErrNotImplemented
}
func (UnimplementedCoreAPI) TurnDecisions(ctx context.Context, n int) ([]TurnDecision, error) { func (UnimplementedCoreAPI) TurnDecisions(ctx context.Context, n int) ([]TurnDecision, error) {
return nil, ErrNotImplemented return nil, ErrNotImplemented
} }
+4
View File
@@ -49,6 +49,7 @@ const (
MethodRevertFact Method = "revert_fact" MethodRevertFact Method = "revert_fact"
MethodTickTrace Method = "tick_trace" MethodTickTrace Method = "tick_trace"
MethodTurnDecisions Method = "turn_decisions" MethodTurnDecisions Method = "turn_decisions"
MethodCorrectTurn Method = "correct_turn"
MethodMorningStatus Method = "morning_status" MethodMorningStatus Method = "morning_status"
MethodMCPServers Method = "mcp_servers" MethodMCPServers Method = "mcp_servers"
MethodDayPlan Method = "day_plan" MethodDayPlan Method = "day_plan"
@@ -125,6 +126,7 @@ const (
codeReminderMissing = "reminder_not_found" codeReminderMissing = "reminder_not_found"
codeReminderState = "reminder_state" codeReminderState = "reminder_state"
codeToolNotFound = "tool_not_found" codeToolNotFound = "tool_not_found"
codeNoSuchTrace = "no_such_trace"
codeUnknownMethod = "unknown_method" codeUnknownMethod = "unknown_method"
codeBadParams = "bad_params" codeBadParams = "bad_params"
codeForbidden = "forbidden" codeForbidden = "forbidden"
@@ -160,6 +162,8 @@ func codeOf(err error) string {
return codeReminderState return codeReminderState
case errors.Is(err, ErrToolNotFound): case errors.Is(err, ErrToolNotFound):
return codeToolNotFound return codeToolNotFound
case errors.Is(err, ErrNoSuchTrace):
return codeNoSuchTrace
case errors.Is(err, ErrUnknownMethod): case errors.Is(err, ErrUnknownMethod):
return codeUnknownMethod return codeUnknownMethod
case errors.Is(err, ErrBadParams): case errors.Is(err, ErrBadParams):