Repository navigation
feat: extend AI debounce timer on an inbound typing/presence signal - #12
mateusbellozupko wants to merge 6 commits into
Conversation
Presence competing for the same WithTries(1) lock Process uses risked silently dropping a real message that lost the race against a purely cosmetic presence pulse. The operation is idempotent (GetState + SETEX), so no lock is needed; a race against a stage transition leaves at worst a stale timer key, already rendered harmless by advanceToAI's own TimerExists/Stage re-check under its own lock. TestPresence_NoopDuringAIStage previously drove the pair through a zero-DebounceTime config, so the real DebounceEngine's own no-op-at-zero behavior masked whether Presence's StageDebounce guard ran at all — deleting the guard still passed. Rewritten with a mock debounce engine (provable renew-call assertion) and a dispatch-completion assertion proving the in-flight AI/dispatch stage was not cancelled.
Reviewer's GuideThe PR adds an authenticated Sequence diagram for presence-driven debounce renewalsequenceDiagram
participant Caller
participant Handler
participant PipelineService
participant PipelineRepository
participant DebounceEngine
participant TimerStore
Caller->>Handler: POST /events/presence
Handler->>Handler: PresenceEvent.Validate()
Handler->>PipelineService: Presence(ctx, contactID, conversationID)
PipelineService->>PipelineRepository: GetState(contactID, conversationID)
alt StageDebounce is active
PipelineService->>DebounceEngine: RenewTimer(ctx, contactID, conversationID, botConfig)
DebounceEngine->>TimerStore: SetTimer(pair, debounce TTL)
TimerStore-->>DebounceEngine: TTL renewed
DebounceEngine-->>PipelineService: success
else No state or stage is not StageDebounce
PipelineService-->>Handler: no-op
end
Handler-->>Caller: 202 Accepted
Flow diagram for presence signal handlingflowchart TD
A[POST /events/presence] --> B[bindAndValidate]
B -->|Invalid payload| C[400 Bad Request]
B -->|Valid payload| D[PipelineService.Presence]
D --> E[GetState]
E --> F{StageDebounce active?}
F -->|No| G[Return without changes]
F -->|Yes| H[RenewTimer]
H --> I[SetTimer with debounce TTL]
I --> J[202 Accepted]
G --> J
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
Hey - I've found 1 issue
Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments
### Comment 1
<location path="pkg/pipeline/service/pipeline_service.go" line_range="295-296" />
<code_context>
+ return nil
+ }
+
+ if err := s.debounce.RenewTimer(ctx, contactID, conversationID, state.BotConfig); err != nil {
+ return fmt.Errorf("pipeline.presence.renew_timer: %w", err)
+ }
+
</code_context>
<issue_to_address>
**issue (bug_risk):** Presence renews the timer whenever Redis still reports `StageDebounce`, even if the debounce timer key has already expired. `RenewTimer` then recreates the expired key, causing `advanceToAI` to see a timer and defer processing, so a presence pulse arriving after expiry can resurrect and indefinitely postpone the reply.
**Triggers:** When the timer expires but the poller has not yet transitioned the state out of `StageDebounce`, and a presence request arrives in that interval.
**Suggested fix:** Renew only an existing timer, using an atomic repository operation that refreshes the TTL only if the timer key still exists; do not implement this as an unconditional `SETEX` after a separate state check.
</issue_to_address>Sourcery assessment
Needs a human reviewer. 1 finding to address first, and if the renewal or presence-state check is wrong, an active conversation could wait longer than intended before AI processing, though no message data is changed or deleted. Reverting stops future renewals, but timers already extended remain until they expire, which is bounded and repairable.
Blocking findings: pkg/pipeline/service/pipeline_service.go:296
|
Fixed in 7e2482a: added |
Sourcery review on evolution-foundation#12: RenewTimer used an unconditional SET, so a presence pulse landing in the ~100ms window after pollDebounceExpiry sees the timer key gone (but before it advances the pair to StageAI) would recreate the key and indefinitely postpone the reply. Added PipelineRepository.RenewTimerIfExists (Redis SET ... XX — only sets if the key already exists) and wired DebounceEngine.RenewTimer through it instead of the plain SetTimer used by Start/Reset, which legitimately need to create a fresh key.
Summary
POST /events/presence, a lightweight signal a caller can send when the customer is composing/recording, so an in-flight AI-response debounce timer gets renewed instead of expiring mid-thought.PipelineService.Presenceonly renews when a debounce window (StageDebounce) is actually active for that pair — it never touches the message buffer, never starts a new debounce window, and never interrupts an in-flightStageAI/StageDispatchstage (a presence pulse alone must never cancel a reply already being generated or sent).Processuses: the operation is an idempotentGetState+SETEX, and losing a lock race against a real message would otherwise risk that message being dropped (the existing lock usesWithTries(1), no retry).DebounceEngine.RenewTimerextends the timer TTL only, sharing the existingStart/Resettimer-setting logic via a small internal helper.Testing
RenewTimer,PresenceEvent.Validate,PipelineService.Presence(renews when debounce active, no-ops when no active debounce, and — using a mock debounce engine plus a dispatch-completion assertion — proves an in-flight AI/dispatch stage is neither renewed against nor cancelled), and the new HTTP handler.go test ./...), includingtest/e2e.🤖 Generated with Claude Code
Summary by Sourcery
Extend the pipeline with best-effort presence handling that safely renews only active debounce timers.
New Features:
POST /events/presenceendpoint for customer composing or recording signals.Bug Fixes:
Enhancements:
Tests: