From ad5eb2d1cf36a81252f690aedb333251dbc0f1b6 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 1 Aug 2026 00:42:23 +0400 Subject: [PATCH] Walk a chain of confirm resolvers instead of three copied blocks --- cmd/mavend/confirm.go | 159 ++++++++++++++++++++++-------------------- 1 file changed, 85 insertions(+), 74 deletions(-) diff --git a/cmd/mavend/confirm.go b/cmd/mavend/confirm.go index bd62509..83171fb 100644 --- a/cmd/mavend/confirm.go +++ b/cmd/mavend/confirm.go @@ -63,89 +63,100 @@ func (h *reactiveHandler) resolveConfirm(ctx context.Context, text string) (stri h.mu.Lock() defer h.mu.Unlock() - // Check routine proposal first (newer feature; checked before tool confirm - // so a routine confirm doesn't get eaten by a stale tool pending). - pr := h.pendingRoutine - if pr != nil && !h.now().After(pr.expiry) { + for _, r := range h.confirmResolvers(ctx) { + if !r.claim() { + continue + } + // The slot is already cleared by claim(): every branch below drops the + // pending, including the unclear one — a confirm that can't be + // answered clearly is safer abandoned than left armed. switch classifyConfirm(text) { case confirmYes: - h.pendingRoutine = 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 + return r.yes(), true case confirmNo: - h.pendingRoutine = nil - if err := h.dataStore.DismissProposedRoutine(ctx, pr.routineID); err != nil { - log.Printf("voice: dismiss proposed routine: %v", err) - } - return "хорошо, не буду.", true + return r.no(), true default: - // unclear: abandon the routine proposal, route normally. - h.pendingRoutine = nil return "", false } } - // Clear expired routine if it existed. - if pr != nil { - h.pendingRoutine = nil - } + return "", false +} - // Check pending Hexis execution confirm. Bound to the exact capability + - // target that was proposed; a stray "да" can only run that, nothing else. - if hx := h.pendingHexis; hx != nil { - if h.now().After(hx.expiry) { - h.pendingHexis = nil - } else { - switch classifyConfirm(text) { - case confirmYes: - h.pendingHexis = nil - return h.execHexis(ctx, hx.capabilityID, hx.capName, hx.entityID, hx.displayName), true - case confirmNo: - h.pendingHexis = nil - return "отменила.", true - default: - h.pendingHexis = nil - return "", false - } - } - } +// confirmResolver — one parked-confirm slot in the chain. claim() reports +// whether this slot holds a live pending, taking it (and dropping an expired +// one) as it goes; yes/no then run the answer. Only ever called with h.mu held. +type confirmResolver struct { + claim func() bool + yes func() string + no func() string +} - // Check tool confirm (existing behavior). - p := h.pending - if p == nil { - return "", false - } - if h.now().After(p.expiry) { - h.pending = nil - return "", false - } - switch classifyConfirm(text) { - case confirmYes: - h.pending = nil - out, err := h.tools.Exec(ctx, p.fn, p.args, true) // confirmed - if err != nil { - log.Printf("voice: tool %s (confirmed): %v", p.fn, err) - if out != "" { - return "не получилось выполнить команду: " + firstLine(out), true - } - return "не получилось выполнить команду.", true - } - if out != "" { - return "готово: " + firstLine(out), true - } - return "готово.", true - case confirmNo: - h.pending = nil - return "отменила.", true - default: - // unclear answer: abandon the confirm, route this utterance normally. - h.pending = nil - return "", false +// confirmResolvers builds the ordered chain resolveConfirm walks. Order is +// deliberate: the routine proposal is checked before the tool confirm so a +// routine confirm doesn't get eaten by a stale tool pending. +func (h *reactiveHandler) confirmResolvers(ctx context.Context) []confirmResolver { + var pr *pendingRoutineConfirm + var hx *pendingHexisExec + var p *pendingAct + + return []confirmResolver{ + // Routine proposal. + { + claim: func() bool { + pr, h.pendingRoutine = h.pendingRoutine, nil + return pr != nil && !h.now().After(pr.expiry) + }, + yes: func() string { + // 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 "не получилось запомнить рутину." + } + return "буду напоминать." + }, + no: func() string { + if err := h.dataStore.DismissProposedRoutine(ctx, pr.routineID); err != nil { + log.Printf("voice: dismiss proposed routine: %v", err) + } + return "хорошо, не буду." + }, + }, + // Hexis execution confirm. Bound to the exact capability + target that + // was proposed; a stray "да" can only run that, nothing else. + { + claim: func() bool { + hx, h.pendingHexis = h.pendingHexis, nil + return hx != nil && !h.now().After(hx.expiry) + }, + yes: func() string { + return h.execHexis(ctx, hx.capabilityID, hx.capName, hx.entityID, hx.displayName) + }, + no: func() string { return "отменила." }, + }, + // Tool confirm. + { + claim: func() bool { + p, h.pending = h.pending, nil + return p != nil && !h.now().After(p.expiry) + }, + yes: func() string { + out, err := h.tools.Exec(ctx, p.fn, p.args, true) // confirmed + if err != nil { + log.Printf("voice: tool %s (confirmed): %v", p.fn, err) + if out != "" { + return "не получилось выполнить команду: " + firstLine(out) + } + return "не получилось выполнить команду." + } + if out != "" { + return "готово: " + firstLine(out) + } + return "готово." + }, + no: func() string { return "отменила." }, + }, } }