Count the rides a question takes, without the reset (V-663)

MaxSuspends did not move the number it was written for. Twenty-six of 140
turns carried a parked clarify tail before it landed and twenty-six after.

Two bounds rearm each other. An aside spends no attempt, so MaxAttempts
never reaches it. A turn reading as a failed answer zeroes Suspends, so
MaxSuspends never reaches the asides. Alternating them restores each bound
with the other's traffic. Measured on 2026-08-08: one question about a
reminder's day rode turns 7 to 13.

PendingQuestion.Rides is the same event counted without the resets. Set
once, incremented only in noteSuspended, carried across the re-park in
askRemainingGap, read by nothing that could lower it. MaxRides is 4, one
looser than MaxSuspends so the tighter statement about a run stays
reachable.

It ends the measured ride one turn early and no more. Most of that ride is
attempts, spent because classifyTurnRole reads "спасибо" and "привет" as
failed answers. Said so in the constant and in the design doc rather than
claiming a fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
This commit is contained in:
2026-08-08 21:38:32 +04:00
parent bbefda66e2
commit de9884e063
4 changed files with 147 additions and 3 deletions
+12 -2
View File
@@ -489,15 +489,19 @@ func (h *reactiveHandler) noteSuspended(ctx context.Context, q *dialogue.Pending
if !q.CanResume() {
h.clarifyStore.Delete(dialogueIDOf(ctx))
h.noteDropped(ctx)
log.Printf("voice: clarify — the question about %s stepped aside %d times; letting the request go", q.Missing[0], q.Suspends)
log.Printf("voice: clarify — letting the question about %s go: %d asides in a row, %d rides in all", q.Missing[0], q.Suspends, q.Rides)
return
}
q.Suspends++
// Rides is the same event counted without the reset (V-663). Incremented
// beside Suspends and never anywhere else, so the two cannot disagree about
// what happened, only about how much of it they remember.
q.Rides++
q.Asked = h.now()
h.clarifyStore.Put(dialogueIDOf(ctx), q)
rt.resume = question
rt.suspended = true
log.Printf("voice: clarify — is its own request; suspending the question about %s and resuming it in the same reply (suspend %d of %d)", q.Missing[0], q.Suspends, dialogue.MaxSuspends)
log.Printf("voice: clarify — is its own request; suspending the question about %s and resuming it in the same reply (suspend %d of %d, ride %d of %d)", q.Missing[0], q.Suspends, dialogue.MaxSuspends, q.Rides, dialogue.MaxRides)
}
// foldAnswerIntoUtterance appends an answered subject to the original words,
@@ -540,6 +544,11 @@ func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.Pendi
// Suspends is not carried, and by this point it is already zero: the answer
// path resets it (V-654). Left off the literal so the zero is stated where
// the struct is built, rather than inherited from a field nobody names.
//
// Rides IS carried, and that is the whole point of it (V-663). This is the
// same request under a second question, not a new one, so the turns it has
// already ridden still count against it. Dropping the field here is exactly
// the re-basing that let one question ride twenty-six replies.
h.clarifyStore.Put(dialogueIDOf(ctx), &dialogue.PendingQuestion{
Intent: q.Intent,
Slots: merged,
@@ -550,6 +559,7 @@ func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.Pendi
TTL: clarifyTTL,
Attempts: q.Attempts + 1,
MaxAttempts: q.MaxAttempts,
Rides: q.Rides,
})
log.Printf("voice: clarify — one gap filled, still missing %s for intent=%s, asking again (attempt %d)", remaining[0], intent, q.Attempts+1)
return question, true