Fecha agente.md secao 72-86 (motor preditivo) e 77-79 (CPS distribuido,
reserva de leads, lock de campanha). Uma campanha RUNNING agora origina
chamadas sozinha, respeitando capacidade de agentes, CPS hierarquico e
taxa de abandono — sem intervencao manual.
Deliberadamente fora do escopo (agente.md secao 72: "nao e' so' `for lead
-> originate`"): mod_avmd (opcional), callbacks agendados, disposicoes de
agente — ficam pra fase CDR.
## Novo servico apps/predictive-dialer
Mesmo padrao arquitetural de fs-events/fs-config: Node standalone em
Docker, ESL propria, tick a cada 2s sobre tenants ativos x campanhas
RUNNING/WAITING_SCHEDULE.
- Lock de campanha (dialer:campaign:{id}, secao 79): TTL/ownership/
renewal/safe-release via Lua compare-and-delete.
- CPS distribuido (secao 77, 62): token bucket janela 1s, hierarquia
GLOBAL/TENANT/TRUNK/CAMPAIGN numa unica chamada Lua atomica — nivel
esgotado bloqueia todos SEM incremento parcial dos que passariam.
- Reserva atomica de leads (secao 78): FOR UPDATE SKIP LOCKED dentro da
mesma transacao withTenantContext.
- CallAttempt/CampaignStats (schema novo): state machine da chamada
(secao 82) + EWMA (secao 75) de answer_probability/average_answer_delay/
average_talk_time/abandon_rate por campanha.
- Capacidade em tempo real + pacing (secao 73-76, 84-85): conta agentes
por estado via Tier->Agent.state, previsao de liberacao (horizonte
unico de 15s, simplificacao documentada dos 4 buckets da especificacao),
controle de abandono reduz pacing progressivamente, nunca origina sem
capacidade prevista.
## Modo simulacao (secao 185-186)
DIALER_SIMULATION=true (default, ja estava no .env desde o inicio da
sessao) sorteia ANSWER/BUSY/NO_ANSWER/FAILED em software, sem PSTN real.
So' quando ANSWERED e' que uma chamada sintetica (null/dummy, sem PSTN)
entra na fila real via mod_callcenter de verdade — escolha deliberada pra
maximizar codigo real exercitado em vez de simular tudo em memoria. Os
identificadores da secao 81 (b2bcall_tenant_id/call_id/attempt_id/
campaign_id/lead_id) vao como channel variables nessa perna, entregando
tenantId real no WebSocket sem fan-out.
Real Outbound Safety (secao 186): as duas flags checadas no boot, nunca
ativadas automaticamente — caminho PSTN real implementado mas nunca
exercitado (sem trunk/operadora real neste laboratorio).
## Dois bugs reais achados e corrigidos testando esta fase
- Perna sintetica (null/dummy) nao tem midia do outro lado — nunca
desligava sozinha depois de bridgear com um agente. Corrigido com
hangup agendado via uuid_kill no talk_time simulado.
- Corrida entre queue:sync e tier:sync (dois canais Redis independentes,
sem ordem garantida): atribuir tier logo depois de criar a fila podia
rodar tier add antes do queue reload terminar ("-ERR Queue not found!",
erro real, diferente do ja conhecido "already exist"). Corrigido com
retry curto (ate 3 tentativas) em agent-sync.ts::addTierWithRetry.
## GET /campaigns/:id/stats
Secao 227.7 "visualizar pacing" — CampaignStats + agentes por estado +
calls em andamento, sem esperar a fase Frontend.
Verificado ponta a ponta: campanha RUNNING originando 3 tentativas por
tick, outcomes simulados corretos com retry agendado (BUSY 15min/
NO_ANSWER 60min/FAILED 30min), uma tentativa ANSWERED completando o ciclo
real inteiro (fila -> agente -> bridge -> hangup -> EWMA atualizada),
stop nao derruba chamada ativa (secao 66), calls_answered=3 confirmado no
`queue list` do FreeSWITCH. CPS limiter e lock de campanha testados
isoladamente (hierarquia sem incremento parcial, ownership nunca
roubado). typecheck do workspace inteiro limpo.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X1HxY46WGU4G1zmVDNKcWw
10 KiB
CPS Limiter / Predictive Dialer Engine
Agente.md secao 72-86 (motor preditivo) e 77-79 (CPS distribuído, reserva
de leads, lock de campanha). Fecha o discador de verdade — depois desta
fase, uma campanha RUNNING origina chamadas sozinha, respeitando
capacidade de agentes, CPS hierárquico e taxa de abandono, sem intervenção
manual.
Deliberadamente não implementado nesta fase (agente.md secao 72: "não é
só for lead -> originate"): mod_avmd (secao 87, opcional), callbacks
agendados (secao 88), disposições de agente (secao 89) — ficam pra fase
CDR, que é onde o conceito de "resultado de uma chamada" ganha uma tabela
própria de verdade.
Novo serviço: apps/predictive-dialer
Mesmo padrão arquitetural de apps/freeswitch-events/apps/freeswitch- config: processo Node standalone em Docker, conexão ESL própria, tsx
direto (sem dist/), tenant-scoped via withTenantContext em toda query.
Um tick a cada 2s: para cada tenant ativo, para cada campanha RUNNING/
WAITING_SCHEDULE, tenta processar.
Lock de campanha (secao 79)
dialer:campaign:{id} no Redis — SET NX PX pra adquirir, renovado a
cada metade do TTL (10s) enquanto o tick daquela campanha está em
andamento, liberado com compare-and-delete (só quem detém o ownerToken
consegue renovar/liberar — testado isoladamente: outro dono nunca rouba
nem renova o lock de quem já tem). Garante que, mesmo rodando mais de uma
réplica deste serviço, só um worker processa uma dada campanha por vez.
CPS distribuído (secao 77, 62)
Token bucket por janela fixa de 1s (INCR + PEXPIRE, script Lua
atômico) — não é sleep(), múltiplos workers batem no mesmo Redis. A
hierarquia (GLOBAL → TENANT → TRUNK → CAMPAIGN, node omitido — só existe
um node FreeSWITCH neste deploy) é uma única chamada Lua com todas as
chaves aplicáveis: só incrementa TODAS se TODAS tiverem espaço — testado
isoladamente que um nível esgotado bloqueia mesmo com os outros níveis
tendo espaço de sobra, sem incrementar parcialmente os que passariam
(a chave da campanha ficou em 1, não 2, na segunda tentativa bloqueada
pelo tenant).
Reserva atômica de leads (secao 78)
SELECT ... FOR UPDATE SKIP LOCKED (raw SQL dentro da mesma transação
withTenantContext, RLS + lock de linha coexistindo). SKIP LOCKED evita
dois workers reservando o mesmo lead — quem chegar depois pula pro
próximo, nunca espera. READY/NEW → RESERVED no mesmo UPDATE dentro
da transação.
Dados em tempo real e pacing (secao 73-76)
pacing.ts::computeCapacity conta agentes por estado (via Tier →
Agent.state, mesma fonte de verdade da fase Agents/Realtime Monitoring)
e estima quantos ficam disponíveis nos próximos 15s — a partir de
stateUpdatedAt + averageTalkTime/wrapUpTime. Simplificação
deliberada: a especificação pede 4 buckets de previsão (5/10/15/20s);
aqui é um único horizonte de 15s. Refinar pros 4 buckets fica pra quando
houver dado real suficiente pra validar se faz diferença prática.
pacing.ts::decidePacing aplica o cálculo conceitual da secao 76
(expected_agent_capacity = available + predicted, calls_to_originate = round(expected_agent_capacity * pacing_factor / answer_probability) - calls_in_flight) e o controle de abandono da secao 84: abandon_rate
acima do alvo reduz o pacing; acima de 2x força o mínimo; acima de 3x
suspende originações nesse tick (nunca desliga a campanha sozinha, só pula
o tick). Nunca origina sem capacidade prevista (secao 85: if (expectedAgentCapacity <= 0) return 0).
EWMA (secao 75)
ewma.ts, alpha=0.25 — answer_probability/average_answer_delay/
average_talk_time/abandon_rate persistidos em CampaignStats,
atualizados a cada tentativa concluída (call-attempt.ts::completeAttempt).
Sem Machine Learning (secao 74: "preferir algoritmo estatístico
determinístico e explicável") — é só a fórmula de suavização exponencial,
nada de modelo treinado.
Modo simulação (secao 185-186) — como funciona de verdade
DIALER_SIMULATION=true por padrão (já estava no .env desde o início da
sessão). Nesse modo, nenhuma chamada PSTN real acontece: o desfecho
("o cliente atendeu?") é sorteado inteiramente em software
(simulation.ts, 40% ANSWERED / 15% BUSY / 35% NO_ANSWER / 10% FAILED,
com delay e talk time aleatórios) — o FreeSWITCH nem é acionado pra
BUSY/NO_ANSWER/FAILED.
Quando o sorteio dá ANSWERED, aí sim uma chamada real entra no
FreeSWITCH — mas sintética (null/dummy, sem PSTN nenhum envolvido),
direto pra &callcenter(fila@domínio). Escolha deliberada: a partir desse
ponto, quem decide o resto (oferecer pro agente, bridgear, abandono por
timeout) é o mod_callcenter real, o mesmo já testado e verificado nas
fases Queues/Agents/Realtime Monitoring — maximiza código real exercitado
em vez de simular tudo em memória. Os identificadores da secao 81
(b2bcall_tenant_id/b2bcall_call_id/b2bcall_attempt_id/
b2bcall_campaign_id/b2bcall_lead_id) vão como channel variables nessa
perna sintética — os eventos dela chegam com tenantId já resolvido no
WebSocket (fase Realtime Monitoring), sem precisar do fan-out usado por
outros eventos de callcenter.
Achado real durante o teste desta fase: uma perna null/dummy não tem
mídia do outro lado — nada faz ela desligar sozinha depois de bridgear com
um agente (diferente de uma ligação de verdade, onde o cliente desliga).
Sem tratar isso, o canal ficava ativo pra sempre. Corrigido: depois de
originar a perna sintética, um setTimeout chama uuid_kill no
talk_time simulado (matando um canal que já terminou sozinho — ex.:
abandonado na fila — não dá erro, sem efeito).
event-listener.ts assina o mesmo canal Redis b2bcall:events que a fase
Realtime Monitoring já usa, correlaciona pelo origination_uuid
(registry em memória, queued-attempts-registry.ts) e fecha o
CallAttempt quando CALL_BRIDGED/QUEUE_MEMBER_LEFT/CALL_ENDED
chegam — AGENT_OFFERED_CALL só anota qual agente pegou, sem fechar nada
ainda.
Real Outbound Safety (secao 186)
originateRealPstnLeg (originate.ts) existe, arquiteturalmente completo
(Sofia Gateway real, sofia/gateway/<trunk>/<numero>), mas só é chamado
quando as duas condições da secao 186 são verdadeiras
(DIALER_SIMULATION=false E ALLOW_REAL_OUTBOUND_CALLS=true) — nunca
ativado automaticamente, checado uma vez no boot do worker
(main.ts::readOutboundSafetyFlags, log de warning claro em cada estado).
Nunca exercitado nesta sessão — não existe trunk/operadora real
disponível neste laboratório. O caminho de answer_delay real (via evento
CALL_ANSWERED, distinto do delay pré-sorteado do modo simulação) também
está implementado mas não testado pelo mesmo motivo.
Achado real: corrida entre queue:sync e tier:sync
Descoberto testando esta fase: criar uma fila e atribuir um tier logo em
seguida (fluxo normal de setup de uma campanha) pode fazer tier add
rodar antes do queue reload da fila terminar no FreeSWITCH — erro
real (-ERR Queue not found!), diferente do já conhecido "already exist"
(esse sim inofensivo). Como queues:sync e tiers:sync são dois canais
Redis independentes sem ordem garantida entre si, o login do agente (que
já resincroniza tiers, ver docs/AGENTS.md) não bastava — os dois disparos
corriam antes do reload terminar. Corrigido com retry curto (até 3
tentativas, backoff de 500ms/1s/1.5s) especificamente pra esse erro, em
apps/freeswitch-config/src/agent-sync.ts::addTierWithRetry.
GET /campaigns/:id/stats (secao 227.7 "visualizar pacing")
Expõe CampaignStats (atualizado a cada tick) + contagem de agentes por
estado + chamadas em andamento, calculado na hora. Suficiente pro
acceptance criteria "visualizar pacing" sem esperar a fase Frontend.
Verificado ponta a ponta
Campanha RUNNING, 1 agente disponível, 3 leads:
tick -> originando 3 tentativas (pacing_factor=1.05, answer_probability=0.4)
Lead 1: FAILED (simulado) -> retry em 30min
Lead 2: NO_ANSWER (simulado) -> retry em 60min
Lead 3: ANSWERED (simulado) -> entra na fila real -> AGENT_OFFERED_CALL
-> CALL_BRIDGED (agente conectado, evento real) -> hangup agendado
-> CALL_ENDED -> CallAttempt COMPLETED, EWMA atualizada
Corrida queue/tier: "tier add" falhando com "Queue not found" -> retry
automático -> "tier sincronizado" -> tier list confirma o agente na fila
CPS limiter isolado: maxPerSecond=2, 4 tentativas -> true,true,false,false
CPS hierarquia: nível tenant esgotado bloqueia mesmo com campanha livre,
SEM incremento parcial (contador da campanha ficou em 1, não 2)
Lock de campanha isolado: dono B nunca rouba nem renova lock do dono A;
dono C adquire normalmente depois do release do dono A
stop numa campanha com chamada ativa -> não derruba a chamada em curso
(secao 66), só para novas originações — confirmado observando o canal
continuar ativo até seu próprio hangup agendado
queue list mostra calls_answered=3 ao final dos testes, confirmando 3
conexões reais de agente via o pipeline completo
typecheck do workspace inteiro limpo.
O que falta
- Previsão de 4 buckets (5/10/15/20s, secao 74) — simplificado pra um único horizonte de 15s.
mod_avmd(secao 87), callbacks agendados (secao 88), disposições de agente (secao 89) — fora do escopo desta fase.- Caminho PSTN real (secao 80-83, 186) — implementado, nunca exercitado (sem trunk real disponível).
- CPS a nível de trunk (
Trunk.maxCps) — campo existe no modelo desde a fase Trunks, mas só é aplicado quando o caminho PSTN real rodar (a perna simulada não passa por um trunk de verdade, não faz sentido contar CPS de trunk pra ela). - Memória do worker ficou em ~266MB depois de alguns ciclos de teste (contra ~30-55MB de fs-events/fs-config) — dentro do orçamento da VM, mas vale reavaliar se crescer mais rodando por mais tempo/mais campanhas simultâneas.
- Relatório de campanha (secao 160), TME/TMA/Service Level/Abandon Rate agregados de verdade — dependem de CDR (próxima fase da ordem do agente.md secao 232).