diff --git a/cmd/mavend/clarify_test.go b/cmd/mavend/clarify_test.go index cd879f8..db1f140 100644 --- a/cmd/mavend/clarify_test.go +++ b/cmd/mavend/clarify_test.go @@ -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) + } +} diff --git a/cmd/mavend/voicewire.go b/cmd/mavend/voicewire.go index 8316490..9c47646 100644 --- a/cmd/mavend/voicewire.go +++ b/cmd/mavend/voicewire.go @@ -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) diff --git a/docs/design.md b/docs/design.md index a5db622..9c9e01d 100644 --- a/docs/design.md +++ b/docs/design.md @@ -223,6 +223,30 @@ Not alternatives — layers: Router contract: `[{"intent":, 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?** diff --git a/internal/dialogue/clarify.go b/internal/dialogue/clarify.go index e3a49c3..98850c2 100644 --- a/internal/dialogue/clarify.go +++ b/internal/dialogue/clarify.go @@ -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