# 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//`), 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, TME/TMA/Service Level/Abandon Rate~~ — implementado na fase CDR (ver docs/CDR.md).