Fala, Rafa!
Tudo bem?
Investigando uma reclamação de duplicação de resposta numa instalação minha, encontrei duas causas distintas — sem relação com ai_dispatch_mode, isso eu já descartei. São as duas em lib/waha/ingest.ts e lib/agent-engine/edge/crm/drain.ts. Anonimizei os dados de cliente antes de escrever aqui.
1. Eco do próprio envio duplica a linha em messages
Em handleOutboundFromUserPhone (~linhas 561-629), o comentário que já está no código ("ECO DO PRÓPRIO ENVIO — não duplicar") descreve certo o problema, mas a solução não fecha a janela: o dedup é SELECT ... WHERE external_id IN (fullId, bareId) e só depois INSERT — não atômico. Quando o eco do WAHA chega perto o suficiente do insert que o próprio envio (composer/IA) já fez, o SELECT roda antes de esse insert commitar, e as duas linhas nascem. O unique(organization_id, external_id) não pega isso como rede de segurança porque os dois lados gravam formas diferentes do mesmo id — o envio grava o bare (3EB0…), o eco grava o composto (true_<chat>_3EB0…) — strings literalmente diferentes, então o 23505 nunca dispara.
Vi isso em produção: dois pares de linhas duplicadas, mesmo corpo, mesma conversa, ~1.3s e ~2s de diferença entre a linha sent_via='ai' (external_id bare) e a sent_via='external_device' (external_id composto terminando no mesmo bare). Não é envio duplicado pro cliente — é o mesmo WhatsApp físico — mas duplica na timeline do CRM, e infla qualquer métrica que conte linhas de messages.
Sugestão: gravar bareWaMessageId(p.id) como external_id neste insert também (em vez do p.id cru). As duas trilhas de escrita passam a usar a mesma string de identidade, e o unique(organization_id, external_id) + captura de 23505 — o padrão que o próprio arquivo já usa no handler de inbound — fecha o dedup sem precisar do SELECT. O matching de ack (~linhas 667-677) já busca as duas formas, então acho que continua funcionando sem ajuste. Não mexe em schema.
2. Debounce de rajada inbound não é deslizante
Em drain.ts (coalescência ~linhas 252-264) e o comentário de intenção em lib/agent-engine/env.ts:69-70:
"Coalescência de rajada inbound: mensagens do MESMO contato dentro desta janela viram UM job (responder em rajada é gatilho de ban)."
A intenção é clara — e é anti-ban, o mesmo motivo do throttle de envio — mas a implementação não entrega isso. A checagem é status='pending' AND run_after > now() contra o job da PRIMEIRA mensagem. run_after = created_at + INBOUND_DEBOUNCE_MS (default 8000ms) é fixado na criação e nunca estendido quando chega mensagem nova. Numa rajada humana típica — bolhas de WhatsApp com 10-20s de intervalo, bem comum — cada mensagem que chega depois que o run_after do job anterior já passou não acha nada pra pegar carona e cria um job novo. O agente responde cada bolha separadamente.
Vi isso também em produção: 3 mensagens do mesmo contato em ~30s (gaps de ~12.1s e ~17.5s), nenhuma coalesceu — 3 jobs inbound_turn, 3 respostas separadas em menos de 2 minutos, todas no mesmo assunto. É exatamente o cenário que o debounce existe pra evitar, só que escapando pela borda de fora da janela. Do lado do dono, parece "a IA respondendo em duplicidade".
Sugestão: quando uma mensagem encontra um job pendente pra coalescer (hoje só faz return 'processado'), também UPDATE job_queue SET run_after = now() + debounceMs WHERE id = ... — estendendo a janela a cada mensagem nova. Talvez valha um teto (uns 30-45s desde a primeira mensagem da rajada) pra não atrasar indefinidamente se o contato não parar de mandar mensagem. Mexe em roteamento/debounce, então por doutrina do próprio repo precisa de pnpm test:db local antes de mergear.
Sem pressa nas duas — só sinalizando o que achei. Se fizer sentido eu mando um PR pra alguma delas, me avisa. Valeu! 🙏
Fala, Rafa!
Tudo bem?
Investigando uma reclamação de duplicação de resposta numa instalação minha, encontrei duas causas distintas — sem relação com
ai_dispatch_mode, isso eu já descartei. São as duas emlib/waha/ingest.tselib/agent-engine/edge/crm/drain.ts. Anonimizei os dados de cliente antes de escrever aqui.1. Eco do próprio envio duplica a linha em
messagesEm
handleOutboundFromUserPhone(~linhas 561-629), o comentário que já está no código ("ECO DO PRÓPRIO ENVIO — não duplicar") descreve certo o problema, mas a solução não fecha a janela: o dedup éSELECT ... WHERE external_id IN (fullId, bareId)e só depoisINSERT— não atômico. Quando o eco do WAHA chega perto o suficiente do insert que o próprio envio (composer/IA) já fez, oSELECTroda antes de esse insert commitar, e as duas linhas nascem. Ounique(organization_id, external_id)não pega isso como rede de segurança porque os dois lados gravam formas diferentes do mesmo id — o envio grava o bare (3EB0…), o eco grava o composto (true_<chat>_3EB0…) — strings literalmente diferentes, então o23505nunca dispara.Vi isso em produção: dois pares de linhas duplicadas, mesmo corpo, mesma conversa, ~1.3s e ~2s de diferença entre a linha
sent_via='ai'(external_id bare) e asent_via='external_device'(external_id composto terminando no mesmo bare). Não é envio duplicado pro cliente — é o mesmo WhatsApp físico — mas duplica na timeline do CRM, e infla qualquer métrica que conte linhas demessages.Sugestão: gravar
bareWaMessageId(p.id)comoexternal_idneste insert também (em vez dop.idcru). As duas trilhas de escrita passam a usar a mesma string de identidade, e ounique(organization_id, external_id)+ captura de23505— o padrão que o próprio arquivo já usa no handler de inbound — fecha o dedup sem precisar do SELECT. O matching de ack (~linhas 667-677) já busca as duas formas, então acho que continua funcionando sem ajuste. Não mexe em schema.2. Debounce de rajada inbound não é deslizante
Em
drain.ts(coalescência ~linhas 252-264) e o comentário de intenção emlib/agent-engine/env.ts:69-70:A intenção é clara — e é anti-ban, o mesmo motivo do throttle de envio — mas a implementação não entrega isso. A checagem é
status='pending' AND run_after > now()contra o job da PRIMEIRA mensagem.run_after = created_at + INBOUND_DEBOUNCE_MS(default 8000ms) é fixado na criação e nunca estendido quando chega mensagem nova. Numa rajada humana típica — bolhas de WhatsApp com 10-20s de intervalo, bem comum — cada mensagem que chega depois que orun_afterdo job anterior já passou não acha nada pra pegar carona e cria um job novo. O agente responde cada bolha separadamente.Vi isso também em produção: 3 mensagens do mesmo contato em ~30s (gaps de ~12.1s e ~17.5s), nenhuma coalesceu — 3 jobs
inbound_turn, 3 respostas separadas em menos de 2 minutos, todas no mesmo assunto. É exatamente o cenário que o debounce existe pra evitar, só que escapando pela borda de fora da janela. Do lado do dono, parece "a IA respondendo em duplicidade".Sugestão: quando uma mensagem encontra um job pendente pra coalescer (hoje só faz
return 'processado'), tambémUPDATE job_queue SET run_after = now() + debounceMs WHERE id = ...— estendendo a janela a cada mensagem nova. Talvez valha um teto (uns 30-45s desde a primeira mensagem da rajada) pra não atrasar indefinidamente se o contato não parar de mandar mensagem. Mexe em roteamento/debounce, então por doutrina do próprio repo precisa depnpm test:dblocal antes de mergear.Sem pressa nas duas — só sinalizando o que achei. Se fizer sentido eu mando um PR pra alguma delas, me avisa. Valeu! 🙏