Appearance
Atendimento Humano — Fluxo completo, expiração de sessão e janela Meta
Este documento descreve o ciclo de vida completo do atendimento humano: como um ticket é criado a partir de um flow, como a sessão é mantida durante o atendimento, como a janela de 24h da Meta é controlada e como o sistema reage quando essa janela expira.
Visão geral
contato envia msg
│
▼
orchestrator
├─ mode="flow" → stream:flow → engine executa
└─ mode="human" → stream:helpdesk → core entrega ao agenteO campo session.mode é a única fonte de verdade para o roteamento. Qualquer troca de estado ("flow" ↔ "human") é persistida no Redis antes de qualquer publicação em stream.
0. Isolamento por router em cenário multi-router
Um mesmo contato pode iniciar conversas com dois Routers diferentes (ex.: dois números de WhatsApp distintos). Essas conversas são completamente independentes — mensagens e tickets nunca se misturam.
Como o isolamento funciona
| Camada | Mecanismo |
|---|---|
| Chat | Chat.routerId determina a qual router a conversa pertence. O orchestrator usa { contactId, routerId, status: "open" } no find-or-create — cada router obtém seu próprio Chat aberto. |
| Sessão | SessionState.routerId identifica o router da sessão. Duas sessões simultâneas do mesmo contato têm IDs de sessão diferentes e estão em routers distintos. |
| Histórico no desk | A query de histórico de contato filtra por chat.routerId, então o agente do Router A só vê tickets de conversas originadas no Router A. |
| Histórico na core-ui | A tela de histórico de tickets filtra por routerId (recebido via param de URL), retornando apenas chats daquele router. |
Dados históricos
Chats criados antes do isolamento têm routerId = null e não aparecem em queries filtradas por router. Isso é intencional — dados antigos não afetam o isolamento das novas conversas.
1. SessionState e campos relevantes
typescript
type SessionState = {
mode: "flow" | "human" | "agent" // controla roteamento de mensagens
routerId: string // router ao qual a sessão pertence
flowId?: string // flow ativo no momento
currentBlockId?: string // bloco onde a sessão está pausada
helpdesk?: {
ticketId?: string // ID do ticket aberto no helpdesk
queueId?: string // ID da fila onde o ticket está
}
metaWindowExpiresAt: number // Unix ms — lastInteractionAt + 24h
expiryTimeout: number // TTL da sessão no Redis (ms)
lastInteractionAt: number // Unix ms — última mensagem do contato
variables: Record<string, string | string[] | null>
redirectHistory?: { flowId: string; blockId: string }[]
}
mode: "agent"cobre o atendimento por agente de IA (blocoAgentBlock), roteado parastream:agent. Este guia foca nos modos"flow"e"human"; quando um ticket de IA (Ticket.kind = "ai") é encerrado, o engine retoma viaagentSessionEndedem vez dehumanSessionEnded.
TTL da sessão por modo
| Modo | TTL |
|---|---|
"flow" | settings.sessionExpiryMs do flow ativo (configurável no builder) |
"human" | Tempo restante da janela Meta (mínimo 1h de buffer) |
O TTL em modo "human" é calculado no momento do handoff:
expiryTimeout = max(metaWindowExpiresAt − Date.now(), HUMAN_SESSION_MIN_TTL_MS /* 1h */)Quando a sessão retorna para "flow" após o ticket ser fechado, o TTL volta para o sessionExpiryMs configurado no flow (activeFlow.definition.settings.sessionExpiryMs).
A constante
DEFAULT_SESSION_TTL_MS(30 min, emflow-ai-engine/src/constants.ts) só é usada como fallback no caminho deAgentBlock— não é o TTL do resume de atendimento humano, que sempre restaura osessionExpiryMsdo flow.
2. Transferência entre bots antes do handoff
A action redirectToBot permite transferir a sessão para outro flow do mesmo router antes do handoff humano:
Flow-A ──[ redirectToBot(flow-b) ]──► Flow-B
│
bloco humanAttendance
│
publishHandoff(routerId, flowId=flow-b)Após o redirect:
session.flowId = flow-bsession.currentBlockId = <bloco humanAttendance em flow-b>session.routerIdpermanece inalterado
O HelpdeskHandoffEvent publicado inclui o flowId do flow que gerou o handoff, mas a resolução de fila usa apenas routerId (centralizado no router).
3. Criação do ticket (handoff)
Fluxo no engine
Quando walkBlocks encontra um bloco humanAttendance:
typescript
session.mode = "human"
// TTL estendido para janela Meta restante
session.expiryTimeout = max(metaWindowExpiresAt - now, 1h)
// Publica HelpdeskHandoffEvent em stream:helpdesk
publishHandoff(ctx)Fluxo no core
O consumer de stream:helpdesk (consumer.ts) recebe o evento e chama handleHelpdeskHandoffEvent:
cacheChatRouting(chatId, { sessionId, to, phoneNumberId })— cache em memória- Resolve a fila —
event.presetQueueId ?? resolveQueue(event):- Se o evento traz
presetQueueId(handoff já direcionado no disparo), usa-o e bypassaresolveQueue - Caso contrário,
resolveQueue(event):- Busca
QueueDistributionRules ativas pelorouterId(não flowId), em ordem ascendente depriority - Avalia condições das regras contra
contextVariables+contact.variables - Primeira regra que bate →
targetQueueId - Se nenhuma bate → fallback à primeira queue ativa do router
- Se não há queue ativa →
AppError 500(ticket não é criado)
- Busca
- Se o evento traz
ticketService.openTicketForHandoff(chatId, queueId, sessionId, event.presetUserId)— cria ticket- Se
presetUserIdestá presente, o ticket nasce já atribuído a esse usuário (statusassigned) - Caso contrário, nasce
waitinge os agentes da fila são notificados viahelpdesk:waiting
- Se
setCurrentHelpdeskTicket(sessionId, { ticketId, queueId })— grava emsession.helpdesk
Distribuição de tickets waiting
Um ticket waiting (sem presetUserId) é atribuído a um agente por um destes caminhos:
- pickup — o agente puxa o próximo da fila (
pickupNextWaitingForAgent, evento socketagent:pickup) - auto-assign —
tryAssign/tryAssignWaitingForQueuechamampickAgentForQueue, que aplica aQueue.distributionStrategy
A DistributionStrategy (enum no Postgres) tem três valores: least_loaded, round_robin, manual_pickup (default least_loaded).
Nota: hoje
applyStrategy(helpdesk/dispatcher.ts) trataround_robinemanual_pickupcomo fallback paraleast_loaded— apenasleast_loaded(menoractiveTicketCount, desempate aleatório) está de fato implementado. As outras estratégias estão reservadas no enum.
Modelo do ticket
Ticket
├─ kind "human" | "ai" (handoff de fluxo → "human")
├─ chatId (vínculo com o chat)
├─ queueId (vínculo com a fila — e indiretamente com o router)
├─ sessionId (ID da SessionState no Redis)
├─ previousTicketId (encadeamento de tickets sucessivos do mesmo chat)
├─ status "waiting" | "assigned" | "closed"
├─ openedByKind "flow" | "agent" | "system" | "ai_agent" (handoff de fluxo → "flow")
├─ openReason "human_handoff" | "agent_transfer" | "queue_transfer" | "ai_session" (handoff → "human_handoff")
└─ assignedUserId null em handoff normal (preenchido no pickup/auto-assign);
já preenchido quando o handoff traz presetUserIdNão há flowId no ticket — o ticket é vinculado ao router (via queue), não ao flow.
4. Durante o atendimento humano
contato envia msg
│
▼
orchestrator detecta mode="human"
│
▼
stream:helpdesk → handleHelpdeskMessageEvent
│
▼
busca ticket pelo sessionId (Redis) ou chatId (Postgres)
│
├─ ticket não encontrado → log warn, nada acontece
├─ ticket sem agente → log warn, nada acontece
└─ ticket com agente → io.emit("ticket:message", ...) ao agenteCada mensagem do contato que o orchestrator processa renova o TTL da sessão no Redis (re-salva com session.expiryTimeout), mantendo a sessão viva enquanto houver interação.
5. Janela de 24h da Meta
A Meta impõe que mensagens "livres" (não-template) só podem ser enviadas dentro de uma janela de 24 horas após a última mensagem recebida do contato.
Controle na sessão
typescript
// Atualizado pelo orchestrator a cada mensagem do contato
session.metaWindowExpiresAt = lastInteractionAt + 86_400_000 // +24hExpiração detectada via erro 131026
Quando um bot ou agente tenta enviar uma mensagem e a Meta rejeita com código 131026 ("message undeliverable" / janela expirada):
meta-api detecta HTTP 4xx com error.code = 131026
│
▼
StatusStreamEvent { status: "send_failed", error: { code: 131026 } }
publicado em stream:status
│
▼
status-consumer.ts no core detecta o código
│
▼
handleWindowExpired(chatId, io, log)O que handleWindowExpired faz
1. Busca ticket aberto pelo chatId
2. Fecha ticket:
closeKind = "meta_window_expired"
closeReason = "Janela de 24h da Meta expirada"
closedByUserId = null (ação do sistema)
3. Publica humanSessionEnded em stream:flow
→ engine retoma no HumanAttendanceBlock, executa outputActions e navega
4. Emite ticket:window_expired via Socket.IO ao agente atribuído6. Encerramento manual do ticket
O agente fecha o ticket via Socket.IO (agent:close) ou HTTP (POST /tickets/:id/close):
ticketService.close(ticketId, { closedByUserId, closeKind: "resolved" })
│
▼
publishHumanSessionEnded({ sessionId, chatId, to })
│
▼
stream:flow → engine → runFlowFromHumanSessionEndedAo retomar, o ticket encerrado entra no contexto do flow na variável
ticket— ver seção 10.
A rota HTTP
POST /tickets/:id/closepassa porcloseTicketWithEffects(emhelpdesk/ticketActions.ts), que ramifica peloTicket.kind: ticket humano publicahumanSessionEnded; ticket de IA (kind = "ai") publicapublishAgentSessionEnded(retoma o flow viaagentSessionEnded). O caminho socketagent:closesó trata o encerramento humano.
O que o engine faz ao receber humanSessionEnded
typescript
// runFlowFromHumanSessionEnded em execute.ts
// 1. Verifica mode
if (session.mode !== "human") → ignora (log warn)
// 2. Sessão frozen por erro de runtime
if (session.frozenByError) → limpa, redireciona para defaultException
// 3. Bloco inválido
if (!blocks[session.currentBlockId]) → defaultException
// 4. Bloco válido (humanAttendance)
session.mode = "flow"
session.expiryTimeout = activeFlow.definition.settings.sessionExpiryMs // TTL do flow
delete session.helpdesk
runActions(humanBlock.outputActions) // + globalActions.outputActions
pickOutputTarget(humanBlock.outputConditions, humanBlock.defaultOutput)
walkBlocks(ctx) → continua execução normalSe o
humanSessionEndedchegar comcurrentBlock.type === "agent"(caso detransferToHumandisparado de dentro de umAgentBlock), o engine limpa tambémsession.agentStatee avalia asoutputConditionsdo próprioAgentBlock.
7. Sessão expirada durante atendimento humano
Se a janela de 24h expirar e o contato não enviar mensagens por mais tempo que o expiryTimeout restante, a sessão pode ser removida do Redis.
Quando o ticket for fechado depois disso, o engine recebe humanSessionEnded mas não encontra a sessão:
typescript
// flow.ts — tratamento defensivo
if (!rawSession) {
if (event.kind === "humanSessionEnded" || event.kind === "agentSessionEnded" || event.kind === "externalJump") {
logger.warn(`${event.kind}: sessão ${event.sessionId} não encontrada — expirou; evento ignorado`)
return // ack, sem processar
}
throw new Error(...) // userInput sem sessão é erro real
}A próxima mensagem do contato cria nova sessão com o initialFlowId do router.
8. Diagrama completo
contato envia msg
│
▼
orchestrator
sessão nova? → earlyRouting: resolve wa:router:{phoneNumberId} → { routerId, initialFlowId }
→ find-or-create Chat com { contactId, routerId, status: "open" }
sessão existe? → carrega do Redis
│
├─ mode="flow" → stream:flow
│ │
│ ▼
│ engine
│ walkBlocks
│ ├─ standard block → publica content, aguarda input
│ ├─ redirectToBot → flowId, currentBlockId, redirectHistory
│ └─ humanAttendance
│ mode = "human"
│ expiryTimeout = max(janela_restante, 1h)
│ publishHandoff → stream:helpdesk
│
└─ mode="human" → stream:helpdesk
│
▼
core
queueId = presetQueueId ?? resolveQueue(routerId)
openTicketForHandoff(chatId, queueId, sessionId, presetUserId) → Ticket
setCurrentHelpdeskTicket(sessionId, { ticketId, queueId })
io.emit("ticket:assigned" | helpdesk:waiting)
[contato envia msgs] → io.emit("ticket:message") ao agente
[Meta retorna 131026]
handleWindowExpired
fecha ticket (meta_window_expired)
publishHumanSessionEnded
io.emit("ticket:window_expired")
[agente fecha ticket]
publishHumanSessionEnded → stream:flow
io.emit("ticket:closed")
│
▼
engine
runFlowFromHumanSessionEnded
mode = "flow"
expiryTimeout = settings.sessionExpiryMs
outputActions + outputConditions
walkBlocks → continua flow9. Referência de valores de closeKind
| Valor | Descrição |
|---|---|
resolved | Fechado pelo agente |
agent_transfer | Transferido para outro agente |
queue_transfer | Transferido para outra fila |
handoff_replaced | Substituído por novo handoff do mesmo chat |
system | Fechado por ação do sistema |
meta_window_expired | Fechado automaticamente por expiração da janela de 24h da Meta |
abandoned | Fechado pelo job de ociosidade — ticket aberto sem novas mensagens por horas |
customer | Encerrado pelo próprio cliente, por palavra-chave configurada no bloco (seção 11) |
10. Dados do ticket encerrado no contexto do flow
Ao sair do atendimento humano, o ticket que acabou de fechar entra no contexto da sessão na variável ticket (#490). Vale para os três caminhos de encerramento — agente fechando (single ou em massa), abandono por ociosidade e expiração da janela de 24h da Meta — porque os três publicam humanSessionEnded.
O flow-ai-core monta o objeto depois do fechamento (é o que garante status, closedAt, closeKind e as tags escolhidas na hora de finalizar) e o carrega no evento; o engine grava em session.variables.ticket como JSON antes de rodar as outputActions e as outputConditions do bloco de atendimento. Por isso o objeto já está disponível na primeira condição do retorno.
Como consumir
| Onde | Como |
|---|---|
| Condição | variável ticket.closeKind — o avaliador aceita path e faz o parse do JSON |
| Mensagem | {{ticket.queue.name}}, {{ticket.closedBy.name}}, {{ticket.tags.0}} |
| Script | entrada {{ticket}} e JSON.parse(ticket) — booleanos, números e listas chegam com o tipo original |
| Integração | {{ticket}} no corpo, ou um path por campo |
js
// Ação "Executar Script" — entrada: ticket = {{ticket}}
function execute(ticket) {
const t = JSON.parse(ticket)
if (t.closedBy.kind === "system") return "nao_pesquisar"
return t.handleSeconds > 600 ? "pesquisa_longa" : "pesquisa_curta"
}Campos
ts
type ClosedTicketContext = {
id: string
sequentialId: null // sem contador sequencial no flow-ai
kind: string // "human" | "ai"
status: string // sempre "closed"
closed: true
closeKind: string | null // resolved | abandoned | meta_window_expired | system | …
closeReason: string | null
closedBy: {
kind: "agent" | "customer" | "system" // customer: encerrou por palavra-chave (#491)
id: string | null
name: string
email: string | null
}
queue: { id: string; name: string; distributionStrategy: string } | null
agent: { id: string; name: string; email: string | null } | null
routerId: string | null
chatId: string
contactId: string
contactIdentity: string // telefone E.164 sem "+"
channel: string // whatsapp | webchat | instagram
openedByKind: string // flow | agent | system | ai_agent
openReason: string // human_handoff | agent_transfer | queue_transfer | ai_session
createdAt: string // ISO 8601
assignedAt: string | null
firstResponseAt: string | null // 1ª mensagem do agente humano no ticket
closedAt: string | null
waitSeconds: number | null // assignedAt − createdAt (TME)
handleSeconds: number | null // closedAt − assignedAt (TMA)
totalSeconds: number | null // closedAt − createdAt
tags: string[] // títulos das tags aplicadas
previousTicketId: string | null
// Pesquisa de satisfação (#496). SEMPRE null no encerramento — a pergunta só vai
// ao contato depois. Quem preenche é o bloco de pesquisa, que regrava o {{ticket}}
// da sessão ao registrar a nota. Ver o guia de pesquisa de satisfação.
rating: number | null
ratingScale: number | null // topo da escala usada na pergunta (5 ou 10)
ratingComment: string | null
priority: null
unreadMessages: null
// Saída do bloco que casou com o motivo (#491) — acrescentado pelo ENGINE,
// ausente quando o bloco não configurou saída para aquele motivo.
exit?: { key: "agent" | "customer" | "inactivity" | "metaWindow"; survey: boolean }
}closedBy sempre identifica quem encerrou, mesmo sem agente: em fechamento automático vem kind: "system" e o name descreve o processo ("Encerrado automaticamente por inatividade", "Encerrado pela expiração da janela de 24h da Meta"). É esse campo, com o closeKind, que sustenta regra de reentrada e decisão de pesquisa de satisfação.
Equivalência com o input.content do BLiP
| BLiP | flow-ai |
|---|---|
id, externalId | id |
sequentialId | null — não existe contador sequencial |
ownerIdentity | routerId |
customerIdentity | contactIdentity |
customerDomain | channel |
agentIdentity | agent.email |
status, closed | status, closed |
storageDate, openDate | createdAt |
closeDate, statusDate | closedAt |
firstResponseDate | firstResponseAt |
team | queue.name |
closedBy | closedBy (objeto, com kind distinguindo agente de processo) |
tags | tags (lista de títulos) |
distributionType, isAutomaticDistribution | queue.distributionStrategy — não há registro por ticket de auto vs. pickup manual |
rating | rating + ratingScale — existe desde a #496, mas chega null neste objeto: a pesquisa acontece depois do encerramento (ver Pesquisa de satisfação) |
priority, unreadMessages | null — sem fonte no flow-ai hoje |
provider | não exposto (conceito do BLiP) |
Limites conhecidos
summaryfica fora. O resumo do atendimento é gerado por LLM de forma assíncrona (o fechamento só publica emstream:summary), então no instante do retorno ao flow ele ainda énull. Quem precisa dele lê oTicketno Postgres.- Sobrescreve. Um segundo atendimento na mesma sessão substitui o
ticketanterior (o log do engine registra a sobrescrita). Para guardar o anterior, copie para outra variável antes de um novo handoff. - Ticket de IA (
kind: "ai") retoma o flow poragentSessionEnded, que ainda não carrega o objeto. - Se o core falhar ao montar o objeto, o atendimento é encerrado e o flow retoma normalmente — apenas sem
(há log de erro com oticketId).
11. Saídas por motivo de encerramento (#491)
O bloco de atendimento humano pode ter um destino próprio para cada motivo de encerramento, em vez de depender de condições escritas à mão sobre o . Configura-se na aba Roteamento do bloco.
Saída (closeExits) | closeKind do ticket | Quando |
|---|---|---|
agent | resolved | o atendente finalizou pelo desk |
customer | customer | o cliente enviou uma palavra de encerramento |
inactivity | abandoned | o worker de ociosidade fechou |
metaWindow | meta_window_expired | a janela de 24h da Meta expirou |
A saída
metaWindowserve para roteamento interno (registrar evento, marcar variável, mandar para um bloco de reengajamento por campanha) — não para enviar mensagem: a janela expirou justamente porque não é mais possível responder o contato, então conteúdo publicado ali não chega. Vale o mesmo parainactivityquando a janela já tiver fechado.
Precedência. Se o motivo do encerramento tem saída configurada, o destino dela vence as outputConditions — é a regra mais específica, e as condições nem são avaliadas. Motivo sem saída configurada (ou encerramento sem no evento) segue pelas outputConditions/defaultOutput, exatamente como antes. Motivos que não retomam o flow (transferências) ou internos (system, handoff_replaced) não têm saída própria.
Marcação de pesquisa. Cada saída tem um survey?: boolean. Ele não envia pesquisa por si: o engine publica a escolha em {{ticket.exit.survey}}, e quem pergunta é o flow — hoje pelo bloco de pesquisa de satisfação (#496), que lê essa marcação quando está com "só perguntar quando a saída marcar pesquisa" ligado. Ver Pesquisa de satisfação. Quem preferir decidir na mão continua podendo:
js
// Ação "Executar Script" — entrada: ticket = {{ticket}}
function execute(ticket) {
const t = JSON.parse(ticket)
if (!t.exit?.survey) return "sem_pesquisa"
return t.closedBy.kind === "agent" ? "pesquisa_completa" : "pesquisa_curta"
}A marcação não existe em todo retorno
exit é anexado pelo engine ao sair do bloco de atendimento. Num retorno sem bloco estacionado (seção "Atendimento que nasce sem bloco", abaixo) o {{ticket}} chega sem exit — então um bloco de pesquisa com "só perguntar quando a saída marcar pesquisa" ligado nunca dispararia nesses casos. É por isso que o padrão do bloco é desligado.
Encerramento pelo cliente por palavra-chave
O bloco aceita uma lista de palavras (customerCloseKeywords). Durante o atendimento, uma mensagem do cliente igual a uma delas encerra o ticket com closeKind: "customer".
- A comparação normaliza acento, caixa e espaços, e exige igualdade da mensagem inteira: "não quero sair agora" contém "sair" e não encerra nada. Encerrar por engano é pior que exigir a palavra exata.
- Só mensagem de texto casa; mídia e botão nunca.
- O caminho: bloco →
customerCloseKeywordsnoHelpdeskHandoffEvent→SessionState.helpdesk.customerCloseKeywords→handlers/message.tscompara a cada inbound do atendimento →closeTicketWithEffectsfecha (mesmo caminho do agente, então emiteticket:closed, atualiza a fila, monta oe publicahumanSessionEnded). - A mensagem é entregue ao agente antes do fechamento, para ele ver o que encerrou.
closedByvem{ kind: "customer", name: "Encerrado pelo cliente" }.- Falha ao ler as palavras ou ao fechar não derruba a entrega da mensagem: loga e segue com o atendimento aberto (sem ack a entry voltaria e o agente receberia a mensagem duas vezes).
Atendimento que nasce sem bloco (#497)
Uma conversa pode começar já em atendimento humano: resposta a envio ativo ou campanha com destino fila, e destino de comentário do Instagram para fila. Nesses casos a sessão nasce em mode: "human" sem currentBlockId — não houve bloco de atendimento, então o encerramento não tem a que voltar, e as saídas desta seção não se aplicam (elas são configuração de um bloco que não participou).
O destino do retorno é resolvido nesta ordem:
| # | Origem | Quando |
|---|---|---|
| 1 | jumpTarget do evento | "finalizar e direcionar" do monitoramento — decisão humana no encerramento, vence tudo |
| 2 | session.humanReturn | o disparo informou fila e flow+bloco (targetQueueId + targetFlowId + targetBlockId) |
| 3 | settings.humanReturnBlockId | padrão do flow, em Configurações do flow → "Retorno de atendimento humano" |
| 4 | defaultExceptionBlockId | nada configurado — comportamento anterior a #497 |
O alvo por-disparo (2) vale uma vez: é consumido no encerramento daquele atendimento e apagado da sessão. Se a mesma sessão depois passar por um bloco de atendimento de verdade, o encerramento roda as saídas do bloco — e não um retorno guardado de um atendimento anterior. Ele também só é considerado com a sessão ainda em mode: "human", para que um close duplicado não reposicione uma conversa que já voltou ao flow.
O bloco de retorno não pode ser um humanAttendance: retomar num bloco de atendimento abriria um ticket novo a cada encerramento, em laço. A regra é validada na escrita (schema do flow, envio ativo e campanha) e de novo em runtime — config gravada antes da validação existir, ou bloco apagado depois, cai no defaultExceptionBlockId com aviso no log em vez de virar laço.
O (#490) está disponível nesse retorno, com closeKind e closedBy, então o bloco de retorno pode ramificar por motivo com condições comuns — o que não existe ali são as saídas por motivo desta seção.
12. Deflexão na entrada — atendimento que não pode começar (#495)
As saídas da seção 11 tratam o FIM do atendimento. Esta trata o caso em que ele não começa: a fila está fora do horário, ou não há atendente que possa receber o ticket. Em vez de o contato entrar numa fila que ninguém vai puxar, o flow continua.
Saída (deflectExits) | Quando |
|---|---|
outOfHours | a fila resolvida está fora de todas as janelas de Queue.schedule |
noAgents | nenhum atendente disponível na fila |
É opt-in. Sem a saída ligada no bloco, o handoff abre ticket e o contato aguarda — comportamento de todos os flows existentes, inalterado.
Por que a decisão é do core, e não do engine
A fila só existe depois do resolveQueue, que avalia regras de distribuição contra as variáveis de contexto; e disponibilidade é por fila (agentes daquela fila, online, com vaga). O engine teria de duplicar essa resolução. Então:
engine: entra no bloco → mode=human → publica handoff (com deflectOn)
core: resolveQueue → avalia horário e disponibilidade
├─ pode atender → abre ticket (caminho de sempre)
└─ não pode → publica handoffDeflected em stream:flow, SEM abrir ticket
engine: devolve mode=flow, grava {{handoffDeflect}}, roteia para a saídaConsequência assumida: há uma janela de milissegundos em que a sessão está em mode: "human" sem ticket (o lag medido entre publicar e consumir é ~2ms). Uma mensagem do contato nesse intervalo é persistida e cai no aviso "sem ticket aberto".
Motivo detalhado no contexto
A configuração tem uma saída para indisponibilidade, mas o motivo exato vai para o contexto, em :
| Campo | Valores |
|---|---|
reason | outOfHours, noAgents, noQueue |
detail | outside_schedule, no_agents_in_queue, none_online, all_at_capacity, queue_inactive, no_viable_queue |
queueId / queueName | fila avaliada (null quando não houve fila) |
at | momento da avaliação, ISO 8601 |
Assim dá para diferenciar "ninguém online" de "todos ocupados" sem precisar de duas saídas configuradas — {{handoffDeflect.detail}} numa condição comum resolve.
Horário de atendimento da fila
Queue.schedule existia no modelo desde o primeiro commit e nunca era lido; agora tem forma e é editável em Filas → editar fila:
ts
type QueueSchedule = {
windows: Array<{
days: number[] // 0 = domingo … 6 = sábado
startTime: string // "HH:mm", inclusivo
endTime: string // "HH:mm", exclusivo
}>
}- Janelas em array porque operação real raramente tem um intervalo só: "seg–sex 08:00–18:00 + sáb 08:00–12:00" são duas. Aberta se qualquer janela casar.
- Sem janela (ou
windows: []) a fila é sempre aberta — o comportamento de todas as filas hoje. - Avaliado no fuso do router (
Router.tz). Router semtznão deflete: abre o ticket e loga aviso, porque errar o fuso erraria por horas. - Janela que cruza a meia-noite é recusada na API (o avaliador compara
HH:mmlexicograficamente e ela nunca casaria).
Deflexão não cria ticket
De propósito: um ticket aberto e fechado no mesmo instante sujaria TME/TMA e o painel de monitoramento. A contrapartida é que "quantos contatos bateram em porta fechada" não é respondível pelos dados de ticket. Quem quiser essa contagem usa a ação "Registrar evento" no bloco de destino da saída — o motivo está no contexto.
Fila inviável mudou de comportamento
Quando nenhuma regra casa e não há fila ativa, resolveQueue lançava: o handler estourava, o consumer não ackava e a sessão ficava em mode: "human" sem ticket — o contato sem resposta até a sessão expirar, o que em modo humano pode levar 24h.
Agora esse caso deflete com reason: "noQueue". Não é configurável (é config quebrada, não decisão de operação): vai para o defaultExceptionBlockId, com o motivo no contexto. Mudança de comportamento assumida: de "conversa presa" para "cai na exceção".
Retrato do instante
Horário e disponibilidade são avaliados no momento do handoff. Um agente que entra dois segundos depois não desfaz a deflexão; e uma fila disponível na checagem pode ficar sem ninguém na hora de atribuir — nesse caso o ticket fica waiting, como sempre. As duas checagens (defletir e distribuir) compartilham as mesmas regras (queueAvailability.ts e dispatcher.ts usam o mesmo par de repositories) para não divergirem.
13. Arquivos relevantes
| Arquivo | Responsabilidade |
|---|---|
services/flow-ai-orchestrator/src/handlers/incoming.ts | Cria/atualiza SessionState, decide stream destino |
services/flow-ai-orchestrator/src/constants.ts | DEFAULT_SESSION_TTL_MS, META_WINDOW_MS |
services/flow-ai-engine/src/runtime/execute.ts | walkBlocks, runFlowFromHumanSessionEnded — controle de modo e TTL |
services/flow-ai-engine/src/handlers/flow.ts | Handler de stream:flow, handleRuntimeError |
services/flow-ai-engine/src/constants.ts | DEFAULT_SESSION_TTL_MS, HUMAN_SESSION_MIN_TTL_MS |
services/flow-ai-core/src/helpdesk/consumer.ts | Consumer de stream:helpdesk |
services/flow-ai-core/src/helpdesk/status-consumer.ts | Consumer de stream:status, detecta 131026 |
services/flow-ai-core/src/helpdesk/handlers/handoff.ts | Cria ticket, resolve fila |
services/flow-ai-core/src/helpdesk/handlers/window-expired.ts | Fecha ticket por expiração de janela Meta |
services/flow-ai-core/src/helpdesk/publishHumanSessionEnded.ts | Publica evento de fim de atendimento humano |
services/flow-ai-core/src/helpdesk/buildClosedTicketContext.ts | Monta o do encerramento (seção 10) |
services/flow-ai-core/src/helpdesk/customerClose.ts | Reconhece a palavra de encerramento do cliente (seção 11) |
services/flow-ai-core/src/services/human-return-target.ts | Valida o bloco de retorno na escrita (#497) |
services/flow-ai-orchestrator/src/handlers/incoming.ts | Cria a sessão em mode: "human" e guarda humanReturn |
services/flow-ai-engine/src/runtime/ticket-context.ts | Grava e escolhe a saída por motivo |
apps/flow-ai-core-ui/components/studio/routing/HumanAttendanceExits.tsx | UI das saídas (encerramento e entrada) e das palavras-chave |
services/flow-ai-core/src/helpdesk/queueAvailability.ts | Horário da fila e disponibilidade de atendente (seção 12) |
services/flow-ai-core/src/helpdesk/publishHandoffDeflected.ts | Publica handoffDeflected em stream:flow |
apps/flow-ai-core-ui/components/helpdesk/QueueScheduleEditor.tsx | UI do horário de atendimento da fila |
services/flow-ai-core/src/consumers/idle-ticket-abandon.worker.ts | Fecha ticket ocioso (abandoned) e retoma o flow |
packages/flow-ai-types/src/ticket-context.ts | Contrato do ClosedTicketContext e nome da variável |
services/flow-ai-core/src/helpdesk/resolveQueue.ts | Resolve fila por regras de distribuição |
packages/flow-ai-database/prisma/schema.prisma | Modelo de Ticket, enum TicketCloseKind |