Skip to content

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 agente

O 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

CamadaMecanismo
ChatChat.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ãoSessionState.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 deskA 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-uiA 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 (bloco AgentBlock), roteado para stream:agent. Este guia foca nos modos "flow" e "human"; quando um ticket de IA (Ticket.kind = "ai") é encerrado, o engine retoma via agentSessionEnded em vez de humanSessionEnded.

TTL da sessão por modo

ModoTTL
"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, em flow-ai-engine/src/constants.ts) só é usada como fallback no caminho de AgentBlocknão é o TTL do resume de atendimento humano, que sempre restaura o sessionExpiryMs do 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-b
  • session.currentBlockId = <bloco humanAttendance em flow-b>
  • session.routerId permanece 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:

  1. cacheChatRouting(chatId, { sessionId, to, phoneNumberId }) — cache em memória
  2. Resolve a fila — event.presetQueueId ?? resolveQueue(event):
    • Se o evento traz presetQueueId (handoff já direcionado no disparo), usa-o e bypassa resolveQueue
    • Caso contrário, resolveQueue(event):
      • Busca QueueDistributionRules ativas pelo routerId (não flowId), em ordem ascendente de priority
      • 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)
  3. ticketService.openTicketForHandoff(chatId, queueId, sessionId, event.presetUserId) — cria ticket
    • Se presetUserId está presente, o ticket nasce já atribuído a esse usuário (status assigned)
    • Caso contrário, nasce waiting e os agentes da fila são notificados via helpdesk:waiting
  4. setCurrentHelpdeskTicket(sessionId, { ticketId, queueId }) — grava em session.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 socket agent:pickup)
  • auto-assigntryAssign / tryAssignWaitingForQueue chamam pickAgentForQueue, que aplica a Queue.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) trata round_robin e manual_pickup como fallback para least_loaded — apenas least_loaded (menor activeTicketCount, 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 presetUserId

Nã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 agente

Cada 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  // +24h

Expiraçã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ído

6. 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 → runFlowFromHumanSessionEnded

Ao retomar, o ticket encerrado entra no contexto do flow na variável ticket — ver seção 10.

A rota HTTP POST /tickets/:id/close passa por closeTicketWithEffects (em helpdesk/ticketActions.ts), que ramifica pelo Ticket.kind: ticket humano publica humanSessionEnded; ticket de IA (kind = "ai") publica publishAgentSessionEnded (retoma o flow via agentSessionEnded). O caminho socket agent:close só 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 normal

Se o humanSessionEnded chegar com currentBlock.type === "agent" (caso de transferToHuman disparado de dentro de um AgentBlock), o engine limpa também session.agentState e avalia as outputConditions do próprio AgentBlock.


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 flow

9. Referência de valores de closeKind

ValorDescrição
resolvedFechado pelo agente
agent_transferTransferido para outro agente
queue_transferTransferido para outra fila
handoff_replacedSubstituído por novo handoff do mesmo chat
systemFechado por ação do sistema
meta_window_expiredFechado automaticamente por expiração da janela de 24h da Meta
abandonedFechado pelo job de ociosidade — ticket aberto sem novas mensagens por horas
customerEncerrado 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

OndeComo
Condiçãovariável ticket.closeKind — o avaliador aceita path e faz o parse do JSON
Mensagem{{ticket.queue.name}}, {{ticket.closedBy.name}}, {{ticket.tags.0}}
Scriptentrada {{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

BLiPflow-ai
id, externalIdid
sequentialIdnull — não existe contador sequencial
ownerIdentityrouterId
customerIdentitycontactIdentity
customerDomainchannel
agentIdentityagent.email
status, closedstatus, closed
storageDate, openDatecreatedAt
closeDate, statusDateclosedAt
firstResponseDatefirstResponseAt
teamqueue.name
closedByclosedBy (objeto, com kind distinguindo agente de processo)
tagstags (lista de títulos)
distributionType, isAutomaticDistributionqueue.distributionStrategy — não há registro por ticket de auto vs. pickup manual
ratingrating + ratingScale — existe desde a #496, mas chega null neste objeto: a pesquisa acontece depois do encerramento (ver Pesquisa de satisfação)
priority, unreadMessagesnull — sem fonte no flow-ai hoje
providernão exposto (conceito do BLiP)

Limites conhecidos

  • summary fica fora. O resumo do atendimento é gerado por LLM de forma assíncrona (o fechamento só publica em stream:summary), então no instante do retorno ao flow ele ainda é null. Quem precisa dele lê o Ticket no Postgres.
  • Sobrescreve. Um segundo atendimento na mesma sessão substitui o ticket anterior (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 por agentSessionEnded, 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 o ticketId).

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 ticketQuando
agentresolvedo atendente finalizou pelo desk
customercustomero cliente enviou uma palavra de encerramento
inactivityabandonedo worker de ociosidade fechou
metaWindowmeta_window_expireda janela de 24h da Meta expirou

A saída metaWindow serve 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 para inactivity quando 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 → customerCloseKeywords no HelpdeskHandoffEventSessionState.helpdesk.customerCloseKeywordshandlers/message.ts compara a cada inbound do atendimento → closeTicketWithEffects fecha (mesmo caminho do agente, então emite ticket:closed, atualiza a fila, monta o e publica humanSessionEnded).
  • A mensagem é entregue ao agente antes do fechamento, para ele ver o que encerrou.
  • closedBy vem { 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:

#OrigemQuando
1jumpTarget do evento"finalizar e direcionar" do monitoramento — decisão humana no encerramento, vence tudo
2session.humanReturno disparo informou fila e flow+bloco (targetQueueId + targetFlowId + targetBlockId)
3settings.humanReturnBlockIdpadrão do flow, em Configurações do flow → "Retorno de atendimento humano"
4defaultExceptionBlockIdnada 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
outOfHoursa fila resolvida está fora de todas as janelas de Queue.schedule
noAgentsnenhum 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ída

Consequê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 :

CampoValores
reasonoutOfHours, noAgents, noQueue
detailoutside_schedule, no_agents_in_queue, none_online, all_at_capacity, queue_inactive, no_viable_queue
queueId / queueNamefila avaliada (null quando não houve fila)
atmomento 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 sem tz nã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:mm lexicograficamente 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

ArquivoResponsabilidade
services/flow-ai-orchestrator/src/handlers/incoming.tsCria/atualiza SessionState, decide stream destino
services/flow-ai-orchestrator/src/constants.tsDEFAULT_SESSION_TTL_MS, META_WINDOW_MS
services/flow-ai-engine/src/runtime/execute.tswalkBlocks, runFlowFromHumanSessionEnded — controle de modo e TTL
services/flow-ai-engine/src/handlers/flow.tsHandler de stream:flow, handleRuntimeError
services/flow-ai-engine/src/constants.tsDEFAULT_SESSION_TTL_MS, HUMAN_SESSION_MIN_TTL_MS
services/flow-ai-core/src/helpdesk/consumer.tsConsumer de stream:helpdesk
services/flow-ai-core/src/helpdesk/status-consumer.tsConsumer de stream:status, detecta 131026
services/flow-ai-core/src/helpdesk/handlers/handoff.tsCria ticket, resolve fila
services/flow-ai-core/src/helpdesk/handlers/window-expired.tsFecha ticket por expiração de janela Meta
services/flow-ai-core/src/helpdesk/publishHumanSessionEnded.tsPublica evento de fim de atendimento humano
services/flow-ai-core/src/helpdesk/buildClosedTicketContext.tsMonta o do encerramento (seção 10)
services/flow-ai-core/src/helpdesk/customerClose.tsReconhece a palavra de encerramento do cliente (seção 11)
services/flow-ai-core/src/services/human-return-target.tsValida o bloco de retorno na escrita (#497)
services/flow-ai-orchestrator/src/handlers/incoming.tsCria a sessão em mode: "human" e guarda humanReturn
services/flow-ai-engine/src/runtime/ticket-context.tsGrava e escolhe a saída por motivo
apps/flow-ai-core-ui/components/studio/routing/HumanAttendanceExits.tsxUI das saídas (encerramento e entrada) e das palavras-chave
services/flow-ai-core/src/helpdesk/queueAvailability.tsHorário da fila e disponibilidade de atendente (seção 12)
services/flow-ai-core/src/helpdesk/publishHandoffDeflected.tsPublica handoffDeflected em stream:flow
apps/flow-ai-core-ui/components/helpdesk/QueueScheduleEditor.tsxUI do horário de atendimento da fila
services/flow-ai-core/src/consumers/idle-ticket-abandon.worker.tsFecha ticket ocioso (abandoned) e retoma o flow
packages/flow-ai-types/src/ticket-context.tsContrato do ClosedTicketContext e nome da variável
services/flow-ai-core/src/helpdesk/resolveQueue.tsResolve fila por regras de distribuição
packages/flow-ai-database/prisma/schema.prismaModelo de Ticket, enum TicketCloseKind

flow-ai — plataforma proprietária de atendimento via WhatsApp