Compare commits

...

6 Commits

Author SHA1 Message Date
kami 75b067ac51 Merge the accepted-routine fix and drop reminder_id from accept
Two merge fixes on top of the branch:

- migrations: keep both new steps, snooze stays #8, the routine columns
  become #9. Both agents had numbered theirs #8.
- accepting no longer takes a reminder id, on the web surface too. The
  web accept path had the same one-shot-reminder bug the voice path did,
  so both now just flip the status and let the tick loop schedule.

The test that asserted "accept creates a reminder and links it" asserted
the bug. It now asserts that accepting creates no reminder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
2026-07-31 10:07:37 +04:00
kami 424d1b3446 Fire accepted routines every interval, not once (Vikunja #366)
The tick loop now reads accepted routines from the store and nudges when
their interval has passed; accepting no longer builds a one-shot reminder.
Look at routine.DueAccepted for the schedule rule (no catch-up backlog) and
at fireAcceptedRoutines for the restraint gate — routines do not bypass it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
2026-07-31 02:45:30 +04:00
kami c47886c2bc Merge branch 'worktree-agent-ab5b5c61a32cac4fe' into overnight-jul31 2026-07-31 02:42:53 +04:00
kami 54dc43516b Add accepted-routine timestamps to the store (Vikunja #366)
Data layer only. Migration #8 adds accepted_ts and last_fired_ts to
proposed_routines, plus ListAcceptedRoutines and MarkRoutineFired so the
tick loop can own the schedule. Accepting no longer links a reminder id.
Look at the TODO(vikunja#366) in cmd/mavend/tick.go for the next commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
2026-07-31 02:41:58 +04:00
kami d30618ecb7 Stop the router repetition loop
Route now sets RepeatPenalty on the request, and the grammar's string rule is
capped at 120 characters. Two of 76 fixture cases looped one sentence inside
the text field until MaxTokens, which cut the JSON in half.
Reviewers: the new constant and the grammar string rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
2026-07-31 02:11:38 +04:00
kami 17b47ce206 Route questions to query, not fact
The router prompt tested "reports current state -> fact" before "wants
information -> query", so a question naming a fact key was written as a fact.
Query now comes first, plus an explicit question test.
Reviewers: the prompt block in llmrouter.go, and the note about the
training-side copy of the prompt that needs the same edit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
2026-07-31 02:10:37 +04:00
18 changed files with 479 additions and 95 deletions
+1 -1
View File
@@ -166,7 +166,7 @@ func (l *lockedAPI) ListProposedRoutines(ctx context.Context) ([]ipc.ProposedRou
return nil, errLocked
}
func (l *lockedAPI) DismissProposedRoutine(ctx context.Context, id int64) error { return errLocked }
func (l *lockedAPI) AcceptProposedRoutine(ctx context.Context, id, remID int64) error {
func (l *lockedAPI) AcceptProposedRoutine(ctx context.Context, id int64) error {
return errLocked
}
func (l *lockedAPI) LookupTool(ctx context.Context, name string) (ipc.Tool, error) {
+62
View File
@@ -186,6 +186,10 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
// LLM-phrased — so a routine can't hallucinate. severity comes from config.
t.fireRoutines(ctx, now, state)
// accepted routines: patterns the user confirmed. read straight from the
// store each tick so the schedule survives a restart.
t.fireAcceptedRoutines(ctx, now, state)
// morning routines: daily checklists (medicine/water/pets/...), nagged at
// most once per day per routine, and only for items still unevidenced at
// nudge time. See internal/morning for the "why not four timers" rationale.
@@ -377,6 +381,64 @@ func (t *tickLoop) fireRoutines(ctx context.Context, now time.Time, state loop.S
}
}
// fireAcceptedRoutines nudges about the routines the user accepted, once per
// interval (Vikunja #366). Accepting used to create a single reminder, so a
// non-weekly routine fired once and went quiet forever; the schedule lives in
// the proposed_routines row now and the loop re-reads it every tick.
//
// A routine is a care-class nudge and goes through the restraint gate like any
// other: quiet hours, away presence and snooze all suppress it. Reminders bypass
// that gate; routines must not. A suppressed nudge is NOT marked fired, so it
// goes out on the next tick that the gate allows — one nudge, held, not dropped
// and not repeated.
//
// The body is literal text built from the detected action and object, not
// LLM-phrased, so a routine can't hallucinate. It nudges; it never acts.
func (t *tickLoop) fireAcceptedRoutines(ctx context.Context, now time.Time, state loop.State) {
rows, err := t.store.ListAcceptedRoutines(ctx)
if err != nil {
log.Printf("tick: list accepted routines: %v", err)
return
}
accepted := make([]routine.Accepted, 0, len(rows))
for _, r := range rows {
if r.AcceptedTs == nil {
continue // accepted before the schedule column existed — no clock to start from.
}
accepted = append(accepted, routine.Accepted{
ID: r.ID,
Name: r.Action + " " + r.Object,
IntervalDays: r.IntervalDays,
Accepted: *r.AcceptedTs,
LastFired: r.LastFiredTs,
})
}
for _, a := range routine.DueAccepted(accepted, now) {
rule := loop.Rule{Name: "routine:" + a.Name, Severity: loop.Sev1}
if !loop.Gate(state, rule) {
continue
}
body := "пора: " + a.Name
pn := delivery.PhrasedNudge{
Candidate: loop.Candidate{Rule: rule, Severity: rule.Severity, State: state},
Body: body,
Summary: body,
}
sent, err := t.dispatcher.DispatchNudge(ctx, pn, now)
if err != nil {
log.Printf("tick: dispatch accepted routine %d: %v", a.ID, err)
continue
}
if len(sent) == 0 {
continue // routing dropped it — leave it due.
}
if err := t.store.MarkRoutineFired(ctx, a.ID, now); err != nil {
log.Printf("tick: mark routine %d fired: %v", a.ID, err)
}
}
}
// fireMorningRoutines checks each configured checklist against today's facts
// and dispatches a nag listing exactly what's still missing, at most once per
// routine per calendar day. Fact reads happen here (not in loop.Gatherer)
+109
View File
@@ -96,6 +96,115 @@ func TestTickFiresRoutineWhenScheduleCrosses(t *testing.T) {
}
}
// TestTickFiresAcceptedRoutineEveryInterval — Vikunja #366. An accepted routine
// with a 3-day interval must nudge every 3 days, not once. It also must not
// replay the occurrences it slept through: after a 30-day gap it nudges once.
func TestTickFiresAcceptedRoutineEveryInterval(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
accepted := refNow()
id, err := st.CreateProposedRoutine(ctx, "полить", "цветы", 3.0, accepted)
if err != nil {
t.Fatalf("CreateProposedRoutine: %v", err)
}
if err := st.AcceptProposedRoutine(ctx, id, accepted); err != nil {
t.Fatalf("AcceptProposedRoutine: %v", err)
}
sink := &fakeSink{}
tl := newTestTickLoop(t, st, sink, nil)
const rule = "routine:полить цветы"
// Same day as the accept: not due yet.
markPresent(t, st, ctx, accepted)
tl.tick(ctx, accepted.Add(time.Hour))
if n := countSends(sink, rule); n != 0 {
t.Fatalf("routine fired %d times before its first interval passed, want 0", n)
}
// Three days later: the first nudge.
first := accepted.Add(3 * 24 * time.Hour)
markPresent(t, st, ctx, first)
tl.tick(ctx, first)
if n := countSends(sink, rule); n != 1 {
t.Fatalf("first interval: sends = %d, want 1", n)
}
// Next day: still inside the interval, silent.
sink.sends = nil
markPresent(t, st, ctx, first.Add(24*time.Hour))
tl.tick(ctx, first.Add(24*time.Hour))
if n := countSends(sink, rule); n != 0 {
t.Fatalf("mid-interval: sends = %d, want 0", n)
}
// Three days after the first nudge: it fires again. This is the bug —
// a one-shot reminder would never come back.
second := first.Add(3 * 24 * time.Hour)
markPresent(t, st, ctx, second)
tl.tick(ctx, second)
if n := countSends(sink, rule); n != 1 {
t.Fatalf("second interval: sends = %d, want 1 (a routine repeats)", n)
}
// A long silence must not turn into a backlog of missed nudges.
sink.sends = nil
late := second.Add(30 * 24 * time.Hour)
markPresent(t, st, ctx, late)
tl.tick(ctx, late)
if n := countSends(sink, rule); n != 1 {
t.Fatalf("after a 30-day gap: sends = %d, want exactly 1 (no backlog)", n)
}
}
// TestTickAcceptedRoutineRespectsQuietHours — routines are not reminders: they
// do not inherit the reminder gate bypass. Away presence drops a care-class
// nudge, and the routine stays due so it nudges once the user is back.
func TestTickAcceptedRoutineRespectsGate(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
accepted := refNow()
id, err := st.CreateProposedRoutine(ctx, "полить", "цветы", 3.0, accepted)
if err != nil {
t.Fatalf("CreateProposedRoutine: %v", err)
}
if err := st.AcceptProposedRoutine(ctx, id, accepted); err != nil {
t.Fatalf("AcceptProposedRoutine: %v", err)
}
sink := &fakeSink{}
tl := newTestTickLoop(t, st, sink, nil)
const rule = "routine:полить цветы"
// No presence probes at all ⇒ away ⇒ the care gate blocks the nudge.
due := accepted.Add(3 * 24 * time.Hour)
tl.tick(ctx, due)
if n := countSends(sink, rule); n != 0 {
t.Fatalf("away: sends = %d, want 0 (routine must not bypass the gate)", n)
}
// Back at the desk a minute later: the nudge that was held now goes out.
back := due.Add(time.Minute)
markPresent(t, st, ctx, back)
tl.tick(ctx, back)
if n := countSends(sink, rule); n != 1 {
t.Fatalf("present again: sends = %d, want 1", n)
}
}
// countSends counts captured sends for one rule name.
func countSends(sink *fakeSink, rule string) int {
n := 0
for _, s := range sink.sends {
if s.RuleName == rule {
n++
}
}
return n
}
// refNow — fixed tick time so presence decay + since durations are deterministic.
func refNow() time.Time { return time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC) }
+5 -16
View File
@@ -1421,23 +1421,12 @@ func (h *reactiveHandler) resolveConfirm(ctx context.Context, text string) (stri
switch classifyConfirm(text) {
case confirmYes:
h.pendingRoutine = nil
// Create a recurring reminder at the detected interval.
// Weekly patterns get a cron expression; arbitrary intervals
// fire once and the detector re-proposes on the next cycle.
intervalDur := time.Duration(pr.interval * 24 * float64(time.Hour))
fire := h.now().Add(intervalDur)
cron := ""
if pr.interval >= 6.5 && pr.interval <= 7.5 {
cron = fmt.Sprintf("0 %d * * %d", fire.Hour(), int(fire.Weekday()))
}
payload := fmt.Sprintf(`{"text":"%s %s"}`, pr.action, pr.object)
remID, err := h.api.CreateReminder(ctx, fire, payload, cron)
if err != nil {
log.Printf("voice: create routine reminder: %v", err)
return "не получилось поставить напоминание.", true
}
if err := h.dataStore.AcceptProposedRoutine(ctx, pr.routineID, remID); err != nil {
// Only record the acceptance. The tick loop reads accepted
// routines and nudges on their own interval. Building a reminder
// here made a routine fire exactly once (Vikunja #366).
if err := h.dataStore.AcceptProposedRoutine(ctx, pr.routineID, h.now()); err != nil {
log.Printf("voice: accept proposed routine: %v", err)
return "не получилось запомнить рутину.", true
}
return "буду напоминать.", true
case confirmNo:
+11 -8
View File
@@ -928,7 +928,6 @@ type routineCore struct {
routines []ipc.ProposedRoutine
dismissed int64
acceptedID int64
acceptedRe int64
remCron string
}
@@ -941,8 +940,8 @@ func (c *routineCore) DismissProposedRoutine(_ context.Context, id int64) error
return nil
}
func (c *routineCore) AcceptProposedRoutine(_ context.Context, id, remID int64) error {
c.acceptedID, c.acceptedRe = id, remID
func (c *routineCore) AcceptProposedRoutine(_ context.Context, id int64) error {
c.acceptedID = id
return nil
}
@@ -990,18 +989,22 @@ func TestHandleRoutines_Accept_RequiresStepUp(t *testing.T) {
}
}
func TestHandleRoutines_Accept_CreatesReminderAndLinksIt(t *testing.T) {
// Accepting only flips the status. It used to also create a one-shot reminder,
// which is why a non-weekly routine fired once and then went quiet forever
// (Vikunja #366). The tick loop owns the schedule now, so a reminder here would
// be a second, competing schedule.
func TestHandleRoutines_Accept_FlipsStatusAndMakesNoReminder(t *testing.T) {
core := weeklyRoutineCore()
rr := httptest.NewRecorder()
handleRoutines(rr, postRoutine("accept", "3"), core, stepUpSession(), false)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
}
if core.acceptedID != 3 || core.acceptedRe != 77 {
t.Fatalf("accepted id=%d reminder=%d, want 3 and 77", core.acceptedID, core.acceptedRe)
if core.acceptedID != 3 {
t.Fatalf("accepted id = %d, want 3", core.acceptedID)
}
if core.remCron == "" {
t.Fatal("a weekly pattern should get a cron expression")
if core.remCron != "" {
t.Fatalf("accepting must not create a reminder, got cron %q", core.remCron)
}
}
+5 -14
View File
@@ -898,20 +898,11 @@ func acceptRoutine(ctx context.Context, core ipc.CoreAPI, id int64) error {
return errors.New("no such proposed routine")
}
fire := time.Now().Add(time.Duration(found.IntervalDays * 24 * float64(time.Hour)))
cron := ""
if found.IntervalDays >= 6.5 && found.IntervalDays <= 7.5 {
cron = fmt.Sprintf("0 %d * * %d", fire.Hour(), int(fire.Weekday()))
}
payload, err := json.Marshal(map[string]string{"text": found.Action + " " + found.Object})
if err != nil {
return err
}
remID, err := core.CreateReminder(ctx, fire, string(payload), cron)
if err != nil {
return err
}
return core.AcceptProposedRoutine(ctx, id, remID)
// No reminder is created here. Accepting only flips the status; the tick
// loop reads accepted routines and nudges on the interval (Vikunja #366).
// The old code made a one-shot reminder, so a non-weekly routine fired
// once and then went quiet forever.
return core.AcceptProposedRoutine(ctx, id)
}
func handleTrace(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
+1 -1
View File
@@ -446,7 +446,7 @@ func (r *recordingAPI) RevertFact(_ context.Context, _ string) (int64, error) {
func (r *recordingAPI) ListProposedRoutines(_ context.Context) ([]ipc.ProposedRoutine, error) {
return nil, nil
}
func (r *recordingAPI) AcceptProposedRoutine(_ context.Context, _, _ int64) error {
func (r *recordingAPI) AcceptProposedRoutine(_ context.Context, _ int64) error {
return nil
}
func (r *recordingAPI) DismissProposedRoutine(_ context.Context, _ int64) error {
+4 -5
View File
@@ -238,8 +238,7 @@ type dismissProposedRoutineReq struct {
}
type acceptProposedRoutineReq struct {
ID int64 `json:"id"`
ReminderID int64 `json:"reminder_id"`
ID int64 `json:"id"`
}
// CoreAPI — what core exposes to modules. One Go interface, satisfied by:
@@ -291,9 +290,9 @@ type CoreAPI interface {
ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error)
// DismissProposedRoutine flips a proposed routine to 'dismissed'.
DismissProposedRoutine(ctx context.Context, id int64) error
// AcceptProposedRoutine flips a proposed routine to 'accepted' and links
// the reminder that will fire it. The caller creates the reminder first.
AcceptProposedRoutine(ctx context.Context, id, reminderID int64) error
// AcceptProposedRoutine flips a proposed routine to 'accepted'. The tick
// loop takes the schedule from there — no reminder is created (Vikunja #366).
AcceptProposedRoutine(ctx context.Context, id int64) error
// TickTrace returns the most recent tick's rule trace. The daemon caches
// this after every tick; the store adapter returns an error (trace is not
+2 -2
View File
@@ -430,8 +430,8 @@ func (c *Client) DismissProposedRoutine(ctx context.Context, id int64) error {
return c.call(ctx, MethodDismissProposedRoutine, dismissProposedRoutineReq{ID: id}, nil)
}
func (c *Client) AcceptProposedRoutine(ctx context.Context, id, reminderID int64) error {
return c.call(ctx, MethodAcceptProposedRoutine, acceptProposedRoutineReq{ID: id, ReminderID: reminderID}, nil)
func (c *Client) AcceptProposedRoutine(ctx context.Context, id int64) error {
return c.call(ctx, MethodAcceptProposedRoutine, acceptProposedRoutineReq{ID: id}, nil)
}
func (c *Client) Chat(ctx context.Context, text string) (string, error) {
+1 -1
View File
@@ -478,7 +478,7 @@ func (a *chatTestAPI) DeleteTool(ctx context.Context, name string) error {
func (a *chatTestAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
return nil, ErrUnknownMethod
}
func (a *chatTestAPI) AcceptProposedRoutine(ctx context.Context, id, remID int64) error {
func (a *chatTestAPI) AcceptProposedRoutine(ctx context.Context, id int64) error {
return nil
}
func (a *chatTestAPI) DismissProposedRoutine(ctx context.Context, id int64) error {
+3 -3
View File
@@ -253,8 +253,8 @@ func (a *storeAPI) DismissProposedRoutine(ctx context.Context, id int64) error {
return mapErr(a.s.DismissProposedRoutine(ctx, id))
}
func (a *storeAPI) AcceptProposedRoutine(ctx context.Context, id, reminderID int64) error {
return mapErr(a.s.AcceptProposedRoutine(ctx, id, reminderID))
func (a *storeAPI) AcceptProposedRoutine(ctx context.Context, id int64) error {
return mapErr(a.s.AcceptProposedRoutine(ctx, id, time.Now().UTC()))
}
func toTool(t store.Tool) Tool {
@@ -783,7 +783,7 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
if err := unmarshalParams(req.Params, &p); err != nil {
return nil, err
}
return marshalResult(nil), api.AcceptProposedRoutine(ctx, p.ID, p.ReminderID)
return marshalResult(nil), api.AcceptProposedRoutine(ctx, p.ID)
case MethodRevertFact:
var p struct {
+33 -8
View File
@@ -23,17 +23,27 @@ func NewLLMRouter(c Completer) *LLMRouter { return &LLMRouter{c: c} }
// routeGrammar — GBNF constraining the model to a JSON ARRAY of fixed-shape
// action objects (one per ask; compound utterances → multiple). Enum + key set
// prevent free-form drift from a sub-1B model.
// prevent free-form drift from a sub-1B model. The string rule is length-bounded
// so a repetition loop cannot fill the whole token budget with one field and
// truncate the JSON.
const routeGrammar = `
root ::= "[" ws action ("," ws action)* ws "]"
action ::= "{" ws "\"intent\"" ws ":" ws intent ("," ws field)* ws "}"
intent ::= "\"fact\"" | "\"reminder\"" | "\"note\"" | "\"query\"" | "\"act\"" | "\"chat\"" | "\"system\""
field ::= key ws ":" ws string
key ::= "\"key\"" | "\"value\"" | "\"text\"" | "\"verb\""
string ::= "\"" ([^"\\] | "\\" .)* "\""
string ::= "\"" ([^"\\] | "\\" .){0,120} "\""
ws ::= [ \t\n]*
`
// routeSystem — the router prompt. Changed 31-07-2026: the query test now sits
// above the fact test and there is an explicit question test. Before that, a
// question naming a fact key ("сколько воды я выпил с утра") matched the fact
// rule first and was stored as an assertion — 15 of 76 fixture cases.
//
// The training workspace keeps its own copy of this prompt for relabelling, and
// `llm/check_prompt_parity.py` there compares the two. That copy is in another
// repo and was not touched, so parity will fail until it gets the same edit.
const routeSystem = `Классифицируй ровно одно сообщение пользователя. Верни ОДИН JSON-массив действий.
Ровно одно намерение: fact, reminder, note, query, act, chat, system.
@@ -41,22 +51,26 @@ const routeSystem = `Классифицируй ровно одно сообще
Классифицируй по цели пользователя. Порядок решения:
1. Хочет напоминание в будущем reminder
2. Явно просит сохранить информацию note
3. Сообщает или обновляет текущее состояние/событие fact
4. Хочет получить информацию query
5. Просит выполнить работу act
6. Про ассистента, настройки или память system
7. Иначе chat
3. Задаёт вопрос: есть вопросительное слово (сколько, что, какой, когда, где, кто, почему, как) или знак «?» query
4. Хочет получить информацию, в том числе о своих же данных query
5. Утверждает: сообщает или обновляет текущее состояние/событие fact
6. Просит выполнить работу act
7. Про ассистента, настройки или память system
8. Иначе chat
Различия:
- note сохранить информацию, без напоминания. text = суть.
- reminder уведомить позже. text = что напомнить.
- fact неявное обновление: пользователь сообщает, что что-то в мире изменилось (текущее/изменённое состояние, случившееся событие). key/value.
- query против fact решает форма реплики, а не тема. Вопрос о состоянии это query, даже если названо то же самое, что бывает в fact. Только утверждение это fact.
Примеры:
"запиши пароль" {"intent":"note","text":"пароль"}
"напомни купить молоко" {"intent":"reminder","text":"купить молоко"}
"запиши купить молоко" {"intent":"note","text":"купить молоко"}
"я выпил воду" {"intent":"fact","key":"water","value":"выпил"}
"сколько воды я выпил с утра" {"intent":"query","text":"сколько воды я выпил с утра"}
"сколько раз я ел вчера?" {"intent":"query","text":"сколько раз я ел вчера"}
"мой любимый фильм — Интерстеллар" {"intent":"note","text":"любимый фильм — Интерстеллар"}
"что такое docker?" {"intent":"query","text":"что такое docker"}
"напиши письмо" {"intent":"act","verb":"написать письмо"}
@@ -65,6 +79,11 @@ const routeSystem = `Классифицируй ровно одно сообще
Ответ JSON-массив: по одному объекту на каждую просьбу. Обычно один. Если в реплике несколько просьб по объекту на каждую. "напомни купить молоко, и запиши что кофе кончился" [{"intent":"reminder","text":"купить молоко"},{"intent":"note","text":"кофе кончился"}]. Только JSON, без пояснений.`
// routeRepeatPenalty — the sub-1B model loops one sentence inside the text field
// until it runs out of tokens, which truncates the JSON. 1.15 is enough to break
// the loop without hurting short slot values.
const routeRepeatPenalty = 1.15
type routeAction struct {
Intent string `json:"intent"`
Key string `json:"key"`
@@ -74,7 +93,13 @@ type routeAction struct {
}
func (lr *LLMRouter) Route(ctx context.Context, utterance string, now time.Time) (Decision, bool, error) {
raw, err := lr.c.Complete(ctx, llm.Req{System: routeSystem, User: utterance, Grammar: routeGrammar, MaxTokens: 128})
raw, err := lr.c.Complete(ctx, llm.Req{
System: routeSystem,
User: utterance,
Grammar: routeGrammar,
MaxTokens: 128,
RepeatPenalty: routeRepeatPenalty,
})
if err != nil {
return Decision{}, false, err
}
+48 -2
View File
@@ -3,15 +3,61 @@ package router
import (
"context"
"fmt"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/llm"
)
type mockLLM struct{ out string; err error }
type mockLLM struct {
out string
err error
got *llm.Req // last request, when the test wants to inspect it
}
func (m mockLLM) Complete(_ context.Context, _ llm.Req) (string, error) { return m.out, m.err }
func (m mockLLM) Complete(_ context.Context, r llm.Req) (string, error) {
if m.got != nil {
*m.got = r
}
return m.out, m.err
}
// Without a repeat penalty the model loops inside the text field until MaxTokens
// and the truncated JSON fails to parse.
func TestLLMRouterSetsRepeatPenalty(t *testing.T) {
var got llm.Req
lr := NewLLMRouter(mockLLM{out: `{"intent":"chat","text":"привет"}`, got: &got})
if _, _, err := lr.Route(context.Background(), "привет", time.Now()); err != nil {
t.Fatalf("route: %v", err)
}
if got.RepeatPenalty <= 1 {
t.Fatalf("want repeat penalty above 1, got %v", got.RepeatPenalty)
}
}
// An unbounded string rule lets one field eat the whole token budget.
func TestRouteGrammarBoundsStrings(t *testing.T) {
if !strings.Contains(routeGrammar, `string ::= "\"" ([^"\\] | "\\" .){0,120} "\""`) {
t.Fatal("grammar string rule lost its length bound")
}
}
// A question naming a fact key used to be stored as a fact because the fact rule
// was tested first. Keep the query rule above it.
func TestRoutePromptTestsQueryBeforeFact(t *testing.T) {
query := strings.Index(routeSystem, "→ query")
fact := strings.Index(routeSystem, "состояние/событие → fact")
if query < 0 || fact < 0 {
t.Fatalf("prompt lost a rule: query=%d fact=%d", query, fact)
}
if query > fact {
t.Fatal("query rule must come before the fact rule")
}
if !strings.Contains(routeSystem, "Задаёт вопрос") {
t.Fatal("prompt lost the explicit question test")
}
}
func TestLLMRouterFactMapping(t *testing.T) {
lr := NewLLMRouter(mockLLM{out: `{"intent":"fact","key":"water","value":"выпил"}`})
+41
View File
@@ -55,6 +55,47 @@ func Validate(routines []Routine) error {
return nil
}
// Accepted — an accepted routine proposal as the tick driver sees it. This is a
// different shape from Routine: the schedule is a plain interval the pattern
// detector measured, not an operator-written cron expression. Accepted is when
// the human said yes; LastFired is nil until the first nudge.
type Accepted struct {
ID int64
Name string
IntervalDays float64
Accepted time.Time
LastFired *time.Time
}
// DueAccepted returns the accepted routines whose interval has passed. It does
// not mutate anything — the caller persists the new last-fired time, because
// that has to survive a restart (unlike Due's in-memory map).
//
// The clock starts at LastFired, or at Accepted for a routine that has never
// nudged. A routine with a non-positive interval never fires: a bad interval
// should mean silence, not a nudge every tick.
//
// One occurrence per call, no catch-up: the caller stamps the fire time as now,
// so a routine that was silent for a month nudges once and then waits a full
// interval. Never a backlog.
func DueAccepted(rs []Accepted, now time.Time) []Accepted {
var out []Accepted
for _, r := range rs {
if r.IntervalDays <= 0 {
continue
}
since := r.Accepted
if r.LastFired != nil {
since = *r.LastFired
}
gap := time.Duration(r.IntervalDays * 24 * float64(time.Hour))
if !now.Before(since.Add(gap)) {
out = append(out, r)
}
}
return out
}
// Due returns the routines whose schedule crossed since their last fire and
// records now as the new last-fire time for each one returned. The caller owns
// `last` (the tick driver holds it across ticks); Due mutates it in place.
+34
View File
@@ -5,6 +5,40 @@ import (
"time"
)
func TestDueAcceptedFiresOncePerInterval(t *testing.T) {
accepted := time.Date(2026, 7, 1, 9, 0, 0, 0, time.UTC)
fired := accepted.Add(3 * 24 * time.Hour)
rs := []Accepted{
{ID: 1, Name: "полить цветы", IntervalDays: 3, Accepted: accepted},
{ID: 2, Name: "покормить рыб", IntervalDays: 3, Accepted: accepted, LastFired: &fired},
{ID: 3, Name: "битый интервал", IntervalDays: 0, Accepted: accepted},
}
// One day in: nothing has waited a full interval.
if got := DueAccepted(rs, accepted.Add(24*time.Hour)); len(got) != 0 {
t.Fatalf("want nothing due after 1 day, got %+v", got)
}
// Three days in: the never-fired one is due. The one that already fired at
// day 3 starts its next three days from there. A zero interval never fires.
got := DueAccepted(rs, fired)
if len(got) != 1 || got[0].ID != 1 {
t.Fatalf("want only routine 1 due at day 3, got %+v", got)
}
// Six days in: both real routines are due.
if got := DueAccepted(rs, accepted.Add(6*24*time.Hour)); len(got) != 2 {
t.Fatalf("want both routines due at day 6, got %+v", got)
}
// A month later the zero-interval routine is still silent.
for _, r := range DueAccepted(rs, accepted.Add(30*24*time.Hour)) {
if r.ID == 3 {
t.Fatal("a routine with a zero interval must never fire")
}
}
}
func TestValidate(t *testing.T) {
ok := []Routine{{Name: "morning", Cron: "0 8 * * *", Body: "доброе утро"}}
if err := Validate(ok); err != nil {
+2
View File
@@ -72,6 +72,8 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
CREATE INDEX IF NOT EXISTS idx_facts_resolution_pending ON facts (resolution_state) WHERE resolution_state = 'pending';`, // #7 — entity-aware memory (Vikunja #279): facts about a subject get resolved to a Nexus entity_id async
`CREATE INDEX IF NOT EXISTS idx_nudges_snoozed ON nudges (outcome_ts) WHERE outcome = 'snoozed';`, // #8 — SnoozedUntil runs every tick; keep it off a full scan (Vikunja #364)
`ALTER TABLE proposed_routines ADD COLUMN accepted_ts INTEGER;
ALTER TABLE proposed_routines ADD COLUMN last_fired_ts INTEGER;`, // #9 — accepted routines keep firing (Vikunja #366): the tick loop needs to know when a routine was accepted and when it last nudged
}
// migrate applies every migration with a number greater than the DB's current
+70 -20
View File
@@ -16,10 +16,15 @@ const (
RoutineDismissed = "dismissed"
)
// ProposedRoutine — a detected pattern the system wants to turn into a
// recurring reminder. Status 'proposed' means awaiting human confirmation;
// 'accepted' means the human confirmed and a reminder was created (reminder_id
// set); 'dismissed' means the human declined and we won't re-propose.
// ProposedRoutine — a detected pattern the system wants to nudge about on a
// repeating interval. Status 'proposed' means awaiting human confirmation;
// 'accepted' means the human confirmed and the tick loop now owns the schedule;
// 'dismissed' means the human declined and we won't re-propose.
//
// AcceptedTs is when the human said yes; it is the clock start for the first
// nudge. LastFiredTs is when the last nudge went out, nil until the first one.
// ReminderID is only set on rows accepted before Vikunja #366, when accepting
// created a one-shot reminder instead.
type ProposedRoutine struct {
ID int64
Action string
@@ -27,7 +32,9 @@ type ProposedRoutine struct {
IntervalDays float64
Status string // proposed | accepted | dismissed
CreatedTs time.Time
ReminderID *int64 // set when accepted
ReminderID *int64
AcceptedTs *time.Time
LastFiredTs *time.Time
}
var (
@@ -74,7 +81,7 @@ func (s *Store) CreateProposedRoutine(ctx context.Context, action, object string
// nil (no error) when no row exists.
func (s *Store) LookupProposedRoutine(ctx context.Context, action, object string) (*ProposedRoutine, error) {
row := s.db.QueryRowContext(ctx, `
SELECT id, action, object, interval_days, status, created_ts, reminder_id
SELECT id, action, object, interval_days, status, created_ts, reminder_id, accepted_ts, last_fired_ts
FROM proposed_routines
WHERE action = ? AND object = ?`, action, object)
r, err := scanProposedRoutine(row)
@@ -95,12 +102,8 @@ func (s *Store) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, er
// ListProposedRoutinesByStatus returns routines in one status, newest first.
// An empty status returns every row.
//
// TODO(vikunja#46): the tick loop should read the accepted ones from here so a
// routine the human said yes to has a home the loop can see, instead of only
// the reminder row that accepting happened to create.
func (s *Store) ListProposedRoutinesByStatus(ctx context.Context, status string) ([]ProposedRoutine, error) {
q := `SELECT id, action, object, interval_days, status, created_ts, reminder_id
q := `SELECT id, action, object, interval_days, status, created_ts, reminder_id, accepted_ts, last_fired_ts
FROM proposed_routines`
var args []any
if status != "" {
@@ -133,15 +136,14 @@ func (s *Store) ListProposedRoutinesByStatus(ctx context.Context, status string)
// `AND status = 'proposed'` makes the move one-way: an answered proposal can
// never be answered again.
//
// AcceptProposedRoutine flips status to 'accepted', links a reminder_id.
// Returns error if not in 'proposed' status.
//
// TODO(vikunja#46): the /routines page calls this through ipc to flip status
// from the authed surface.
func (s *Store) AcceptProposedRoutine(ctx context.Context, id, reminderID int64) error {
// AcceptProposedRoutine flips status to 'accepted' and records when. From that
// timestamp the tick loop owns the schedule: it re-reads accepted rows every
// tick and nudges when the interval has passed. Returns an error if the row is
// not in 'proposed' status.
func (s *Store) AcceptProposedRoutine(ctx context.Context, id int64, ts time.Time) error {
res, err := s.db.ExecContext(ctx,
`UPDATE proposed_routines SET status = 'accepted', reminder_id = ? WHERE id = ? AND status = 'proposed'`,
reminderID, id)
`UPDATE proposed_routines SET status = 'accepted', accepted_ts = ? WHERE id = ? AND status = 'proposed'`,
ts.UnixMilli(), id)
if err != nil {
return fmt.Errorf("accept proposed routine: %w", err)
}
@@ -152,6 +154,42 @@ func (s *Store) AcceptProposedRoutine(ctx context.Context, id, reminderID int64)
return nil
}
// ListAcceptedRoutines returns every accepted routine, oldest first. The tick
// loop reads this each tick and decides which ones are due.
func (s *Store) ListAcceptedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, action, object, interval_days, status, created_ts, reminder_id, accepted_ts, last_fired_ts
FROM proposed_routines
WHERE status = 'accepted'
ORDER BY id`)
if err != nil {
return nil, fmt.Errorf("list accepted routines: %w", err)
}
defer rows.Close()
var out []ProposedRoutine
for rows.Next() {
r, err := scanProposedRoutine(rows)
if err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// MarkRoutineFired records that a routine just nudged. The stored time is the
// nudge time, not the time it was theoretically due, so a routine that was
// silent for a while starts its next interval from now — missed occurrences are
// dropped, never replayed as a backlog.
func (s *Store) MarkRoutineFired(ctx context.Context, id int64, ts time.Time) error {
if _, err := s.db.ExecContext(ctx,
`UPDATE proposed_routines SET last_fired_ts = ? WHERE id = ?`,
ts.UnixMilli(), id); err != nil {
return fmt.Errorf("mark routine fired: %w", err)
}
return nil
}
// DismissProposedRoutine flips status to 'dismissed'. Idempotent.
func (s *Store) DismissProposedRoutine(ctx context.Context, id int64) error {
_, err := s.db.ExecContext(ctx,
@@ -168,12 +206,24 @@ func scanProposedRoutine(sc scanner) (ProposedRoutine, error) {
var r ProposedRoutine
var created int64
var reminderID sql.NullInt64
if err := sc.Scan(&r.ID, &r.Action, &r.Object, &r.IntervalDays, &r.Status, &created, &reminderID); err != nil {
var accepted, lastFired sql.NullInt64
if err := sc.Scan(&r.ID, &r.Action, &r.Object, &r.IntervalDays, &r.Status, &created, &reminderID, &accepted, &lastFired); err != nil {
return ProposedRoutine{}, err
}
r.CreatedTs = time.UnixMilli(created).UTC()
if reminderID.Valid {
r.ReminderID = &reminderID.Int64
}
r.AcceptedTs = millisToTime(accepted)
r.LastFiredTs = millisToTime(lastFired)
return r, nil
}
// millisToTime turns a nullable unix-millis column into a *time.Time.
func millisToTime(v sql.NullInt64) *time.Time {
if !v.Valid {
return nil
}
t := time.UnixMilli(v.Int64).UTC()
return &t
}
+47 -14
View File
@@ -43,12 +43,7 @@ func TestCreateAndAcceptProposedRoutine(t *testing.T) {
}
// Accept
// First create a reminder to link
remID, err := s.CreateReminder(ctx, now.Add(7*24*time.Hour), `{"text":"refill cat water"}`, "0 10 * * 0")
if err != nil {
t.Fatalf("CreateReminder: %v", err)
}
if err := s.AcceptProposedRoutine(ctx, id, remID); err != nil {
if err := s.AcceptProposedRoutine(ctx, id, now); err != nil {
t.Fatalf("AcceptProposedRoutine: %v", err)
}
@@ -60,8 +55,50 @@ func TestCreateAndAcceptProposedRoutine(t *testing.T) {
if r.Status != "accepted" {
t.Fatalf("want status=accepted, got %s", r.Status)
}
if r.ReminderID == nil || *r.ReminderID != remID {
t.Fatalf("want reminder_id=%d, got %v", remID, r.ReminderID)
if r.AcceptedTs == nil || !r.AcceptedTs.Equal(now.Truncate(time.Millisecond)) {
t.Fatalf("want accepted_ts=%v, got %v", now, r.AcceptedTs)
}
if r.LastFiredTs != nil {
t.Fatalf("a freshly accepted routine has not fired yet, got %v", r.LastFiredTs)
}
}
// TestAcceptedRoutineFiredTimestamp — the tick loop's two reads: the accepted
// list, and the last-fired stamp it writes back after a nudge.
func TestAcceptedRoutineFiredTimestamp(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Millisecond)
id, err := s.CreateProposedRoutine(ctx, "полить", "цветы", 3.0, now)
if err != nil {
t.Fatalf("CreateProposedRoutine: %v", err)
}
if err := s.AcceptProposedRoutine(ctx, id, now); err != nil {
t.Fatalf("AcceptProposedRoutine: %v", err)
}
list, err := s.ListAcceptedRoutines(ctx)
if err != nil {
t.Fatalf("ListAcceptedRoutines: %v", err)
}
if len(list) != 1 || list[0].ID != id {
t.Fatalf("want the one accepted routine, got %+v", list)
}
if list[0].IntervalDays != 3.0 {
t.Fatalf("want interval_days=3, got %v", list[0].IntervalDays)
}
fired := now.Add(3 * 24 * time.Hour)
if err := s.MarkRoutineFired(ctx, id, fired); err != nil {
t.Fatalf("MarkRoutineFired: %v", err)
}
list, err = s.ListAcceptedRoutines(ctx)
if err != nil {
t.Fatalf("ListAcceptedRoutines: %v", err)
}
if list[0].LastFiredTs == nil || !list[0].LastFiredTs.Equal(fired) {
t.Fatalf("want last_fired_ts=%v, got %v", fired, list[0].LastFiredTs)
}
}
@@ -165,7 +202,7 @@ func TestDismissedProposedRoutineStaysDismissed(t *testing.T) {
if err := s.DismissProposedRoutine(ctx, id); err != nil {
t.Fatalf("second DismissProposedRoutine: %v", err)
}
if err := s.AcceptProposedRoutine(ctx, id, 1); !errors.Is(err, ErrProposedRoutineNotFound) {
if err := s.AcceptProposedRoutine(ctx, id, time.Now().UTC()); !errors.Is(err, ErrProposedRoutineNotFound) {
t.Fatalf("want ErrProposedRoutineNotFound accepting a dismissed routine, got %v", err)
}
r, err := s.LookupProposedRoutine(ctx, "clean", "litter_box")
@@ -190,11 +227,7 @@ func TestListProposedRoutinesByStatus(t *testing.T) {
if err != nil {
t.Fatalf("CreateProposedRoutine: %v", err)
}
remID, err := s.CreateReminder(ctx, now.Add(4*24*time.Hour), `{"text":"water plants"}`, "")
if err != nil {
t.Fatalf("CreateReminder: %v", err)
}
if err := s.AcceptProposedRoutine(ctx, keep, remID); err != nil {
if err := s.AcceptProposedRoutine(ctx, keep, now); err != nil {
t.Fatalf("AcceptProposedRoutine: %v", err)
}
if err := s.DismissProposedRoutine(ctx, drop); err != nil {