Compare commits

...

2 Commits

Author SHA1 Message Date
claude 5b622389c5 dialogue: a restart expires the parked question (V-385)
The decision, not a behaviour change: ClarifyStore stays in memory, and she
does not announce the loss either.

The TTL and the attempt count measure a pause in one conversation. A restart
is a gap of unknown length, so a restored question is either dead already or
lying about its age, and the request behind it is one he has likely given up
on. Announcing it would mean storing a marker that outlives the thing it
describes, to say one sentence in the rare window where he speaks within 90s
of a restart. His next words route fresh, which is right either way.

Written down in docs/design.md, pinned at both ends by a comment, and held by
a test that builds a second handler over the same store.
2026-08-04 03:33:19 +04:00
claude a820a95ebb store: wake the routines accepted before the fire-forever fix (V-377)
Routines accepted before Vikunja #366 carry accepted_ts NULL and a live
reminder row. The tick loop reads accepted_ts to decide when a routine is
next due, so those rows have been silent since the fix landed, while the
reminder they still point at keeps firing on its own schedule.

Migration #19 cancels that reminder first, then dates the acceptance from
created_ts and lets the reminder id go. Order matters: the second update
clears the id the first one needs.
2026-08-04 03:29:51 +04:00
6 changed files with 152 additions and 1 deletions
+20
View File
@@ -530,3 +530,23 @@ func TestClarifyIsPerConversation(t *testing.T) {
func voiceCtx() context.Context {
return withDialogueID(context.Background(), dialogueIDFor(sourceVoice, ""))
}
// TestARestartExpiresTheParkedQuestion pins the Vikunja #385 decision: the
// question dies with the process, and she does not claim to have let it go —
// the words that follow are routed as a fresh request. Restarting is modelled
// the way the daemon does it, by building a second handler over the same store.
func TestARestartExpiresTheParkedQuestion(t *testing.T) {
h, _, _ := newClarifyHandler(t)
ctx := voiceCtx()
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
t.Fatal("expected a question before the restart")
}
restarted, _, _ := newClarifyHandler(t)
if _, handled := restarted.resolveClarifyAnswer(ctx, "в 11:00"); handled {
t.Fatal("a question parked before the restart must not eat the next utterance")
}
if notice := restarted.clarifyExpiredNotice(ctx); notice != "" {
t.Fatalf("notice = %q, want silence: nothing survived to expire", notice)
}
}
+4 -1
View File
@@ -238,7 +238,10 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
// ----- dialogue (multi-turn slot carry-over; 2-min follow-up window) -----
// Store-backed when the daemon passes a store, so a restart mid-conversation
// keeps the thread (Vikunja #363). Sessions past their TTL are dropped on
// load, never revived. Clarify's parked question stays in memory only.
// load, never revived. Clarify's parked question stays in memory only, and
// that is a decision rather than an omission (Vikunja #385, docs/design.md):
// a restart expires it, so the thread comes back and the open question does
// not.
var dialogueSessions *dialogue.SessionStore
if dataStore != nil {
dialogueSessions = dialogue.NewPersistentSessionStore(2*time.Minute, dataStore)
+24
View File
@@ -223,6 +223,30 @@ Not alternatives — layers:
Router contract: `[{"intent":<enum>, key?, value?, text?, verb?}, ...]` over
7 intents (`fact, reminder, note, query, act, chat, system`).
#### A restart expires a parked question
Decided 2026-08-04 (Vikunja #385). The follow-up dialogue session survives a
restart; the clarify question parked behind it does not, and neither do the
three yes/no confirms in `voice.go`. `ClarifyStore` stays in memory.
Three reasons, in the order they settle it:
- The clock stops meaning anything. A parked question carries a 90s TTL and an
attempt count. A restart is a gap of unknown length, so a restored question is
either already dead or pretending to be young.
- Restoring the question restores the request behind it. He asked for something,
she asked back, and then the daemon went away. Acting on that minutes later,
against words he has probably given up on, is the misroute the stage 3 gate
exists to avoid.
- She does not announce it either. The expiry notice needs to know a question
was parked, and knowing that across a restart means storing it. One sentence,
in the rare window where he speaks within 90s of a restart, does not pay for a
marker that outlives the thing it describes. His next words route fresh, which
is the correct answer with or without the notice.
So the notice stays what it is: the in-process TTL case, where she really did
wait and really did let go.
### save-where — the two-memory routing axis
One discriminator: **does the loop evaluate a predicate against it?**
+8
View File
@@ -57,6 +57,14 @@ func (q *PendingQuestion) CanAsk() bool {
// ClarifyStore holds the parked questions. Same shape and locking as
// SessionStore: keyed by dialogue id, expired entries dropped on read.
//
// Memory only, deliberately, unlike SessionStore — a restart expires every
// parked question and she does not announce that it happened (Vikunja #385,
// written down in docs/design.md). The 90s TTL and the attempt count measure a
// pause in one conversation, and a restart is a gap of unknown length, so a
// restored question would either be dead already or lying about its age. His
// next words route fresh, which is the right answer with or without a notice.
// Do not give this store a persister without re-arguing that.
type ClarifyStore struct {
mu sync.RWMutex
questions map[string]*PendingQuestion
+29
View File
@@ -219,6 +219,35 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
// event, and the old rows would otherwise be recited as extra meetings.
// The filter is exact — it keeps any key whose summary part still has a
// letter or a digit in it.
// #19 — unstick the routines accepted before the fire-forever fix
// (Vikunja #377, follow-up to #366). Accepting used to leave accepted_ts
// NULL and a live one-shot reminder behind, and the tick loop skips a row
// with no accepted_ts, so every non-weekly routine accepted before that fix
// has been silent ever since.
//
// Three statements, in this order, per stuck row: adopt created_ts as the
// acceptance time, cancel the reminder that is still holding the schedule,
// then let go of it. Cancelling before clearing matters — clearing first
// loses the only pointer to the reminder and leaves it to fire on its own.
//
// created_ts rather than a fresh timestamp because a migration has no
// clock, and because the first interval should be measured from when he
// said yes. A routine whose interval has already elapsed nudges on the next
// tick, which is what being unstuck looks like.
//
// Weekly rows are included deliberately. Theirs was the case that kept
// working, because the cron reminder reschedules itself — so leaving them
// alone would give them both a cron reminder and a tick-loop schedule for
// one habit, and he would hear it twice.
`UPDATE reminders
SET status = 'cancelled'
WHERE status = 'pending'
AND id IN (SELECT reminder_id FROM proposed_routines
WHERE status = 'accepted' AND accepted_ts IS NULL AND reminder_id IS NOT NULL);
UPDATE proposed_routines
SET accepted_ts = created_ts, reminder_id = NULL
WHERE status = 'accepted' AND accepted_ts IS NULL;`,
}
// migrate applies every migration with a number greater than the DB's current
+67
View File
@@ -3,6 +3,7 @@ package store
import (
"context"
"testing"
"time"
)
func userVersion(t *testing.T, s *Store) int {
@@ -80,3 +81,69 @@ func TestCollapsedCalendarKeysAreDropped(t *testing.T) {
t.Fatalf("%d calendar rows left, want the 2 that identify their event", got)
}
}
// TestStuckRoutinesAreBackfilled — routines accepted before the fire-forever
// fix have accepted_ts NULL and a live reminder, so the tick loop skips them
// and they have been silent ever since (Vikunja #377). The migration touches
// live reminders, which is why it is tested against a real store.
func TestStuckRoutinesAreBackfilled(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
created := time.Date(2026, 7, 1, 9, 0, 0, 0, time.UTC)
rem, err := s.CreateReminder(ctx, created.Add(time.Hour), "полить цветы", "")
if err != nil {
t.Fatal(err)
}
healthy, err := s.CreateReminder(ctx, created.Add(2*time.Hour), "не трогать", "")
if err != nil {
t.Fatal(err)
}
if _, err := s.db.ExecContext(ctx,
`INSERT INTO proposed_routines (action, object, interval_days, status, created_ts, reminder_id, accepted_ts)
VALUES ('water', 'plants', 7, 'accepted', ?, ?, NULL)`,
created.UnixMilli(), rem); err != nil {
t.Fatal(err)
}
// An already-healthy accepted row, and a still-open proposal: neither is
// this migration's business.
if _, err := s.db.ExecContext(ctx,
`INSERT INTO proposed_routines (action, object, interval_days, status, created_ts, accepted_ts)
VALUES ('feed', 'cat', 1, 'accepted', ?, ?)`,
created.UnixMilli(), created.UnixMilli()); err != nil {
t.Fatal(err)
}
if _, err := s.db.ExecContext(ctx, migrations[18]); err != nil {
t.Fatalf("migration 19: %v", err)
}
accepted, err := s.ListAcceptedRoutines(ctx)
if err != nil || len(accepted) != 2 {
t.Fatalf("ListAcceptedRoutines = %d rows, err=%v, want 2", len(accepted), err)
}
stuck := accepted[0]
if stuck.Object != "plants" {
stuck = accepted[1]
}
if stuck.AcceptedTs == nil || !stuck.AcceptedTs.Equal(created) {
t.Fatalf("accepted_ts = %v, want the creation time", stuck.AcceptedTs)
}
if stuck.ReminderID != nil {
t.Fatalf("reminder_id = %v, want it let go", stuck.ReminderID)
}
// The reminder it was holding is cancelled, and nothing else is.
var status string
if err := s.db.QueryRowContext(ctx, `SELECT status FROM reminders WHERE id = ?`, rem).Scan(&status); err != nil {
t.Fatal(err)
}
if status != ReminderCancelled {
t.Fatalf("linked reminder status = %q, want cancelled", status)
}
if err := s.db.QueryRowContext(ctx, `SELECT status FROM reminders WHERE id = ?`, healthy).Scan(&status); err != nil {
t.Fatal(err)
}
if status != "pending" {
t.Fatalf("unrelated reminder status = %q, want it untouched", status)
}
}