Fecha o furo anti-spam achado pelo Rafael no walkthrough: - Migration 0064: índice unique-live (pointer_id,contact_id) -> (organization_id, contact_id) com DEDUP FIRST (window function, self-host-safe) + agent_id no enrollment. Apêndice idempotente no baseline + MANIFEST. - Gate resolve o agente que arma o pointer (pick determinístico = menor uuid); silence-sweep e manual-enroll pinam agent_id; fila mostra agent_name. - Persona-in-send DEFERIDA (segue o número via channel_session; usar o agente pinado exige cirurgia no harness portado — agent_id já persistido p/ o wire-up). Prova: test:invariants 2x verde (exclusividade RED->GREEN, dedup, agent-pin); unit 564/564; typecheck/lint 0. DESKCOMM_GOV_INVARIANTS_EDIT=1: followup-silence-sweep.test.ts estendido com a prova de exclusividade/dedup/agent-pin desta task; followup-reactivity.test.ts corrigido porque o índice virar org-wide tornou impossível seedar 3 enrollments vivos p/ o MESMO contato (o STOP test) — agora 1 vivo + 1 terminal. Consequência de schema legítima da decisão do Rafael, não invariante incômodo. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0143T3gYRomX8kFxK3KhJK1U
65 KiB
HANDOFF — Sistema de Follow-up Inteligente
⚠️ INSTRUÇÃO PERMANENTE (não remover): Este documento DEVE ser lido no INÍCIO de toda sessão que trabalhe nesta feature, e ATUALIZADO + COMMITADO ao final de CADA avanço (task concluída, decisão tomada, bug encontrado, bug corrigido, teste rodado). Regra do Rafael: progresso só conta com PROVA VISÍVEL (output de teste real, curl real, screenshot Playwright). Nada de "implementado" sem evidência registrada aqui. Medidas de front-end são verificadas por ferramenta (Playwright getBoundingClientRect/getComputedStyle), nunca a olho. COMMITAR este arquivo a cada atualização — mudança só no working tree se perde quando um subagent limpa a árvore (já aconteceu 1x).
Contexto fixo
- Feature: sistema de follow-up inteligente — grafo versionado + enrollment + relógio único; builder visual React Flow; fila UI; seletor no agente.
- Spec:
docs/superpowers/specs/2026-07-21-followup-system-design.md(aprovada pelo Rafael 2026-07-21). - Plano:
docs/superpowers/plans/2026-07-21-followup-system.md— 8 ondas, critérios de aceite por onda. NENHUMA onda avança sem a anterior provada. - Pesquisa (Fase 0):
docs/research/followup-reference-mining.md— padrões de odysseus/hermes/openclaw + autópsia do TomikCRM (as 3 causas-raiz: janela 24h ignorada no agendamento; ai_classify sem grace; pausas sem consumidor de retomada). - Onde: worktree
.claude/worktrees/followup, branchfeat/followup-flows(basefeat/operacao-visivel@4408958). Checkout principal NÃO tocar. - Método: subagent-driven (implementer + reviewer por task), ledger em
.superpowers/sdd/progress.md. Após CADA task: prova + atualizar+commitar este arquivo. - Ambiente:
.env.locale.e2e-creds.jsoncopiados do checkout principal. Testes de invariante: Postgres 17 efêmero dobaseline.sql(npm run test:invariants). E2E:npm run test:e2e(creds do seed). - Fundações que JÁ EXISTEM (não recriar):
cron_jobs+job_queue(kindfollowup_turn) da migration 0050; toolschedule_followup(lib/agent-engine/agent/schedule-followup.ts); handlerfollowup_turncom re-entrada temporal (lib/agent-engine/agent/followup-turn.ts); before-send guardrails/pacing/STOP/send_ledger;agent_inbox_items; flywheel. O sistema novo ORQUESTRA essas peças.
Estado atual
- Onda: 5 ✅. Onda 6 ✅. Onda 7 ✅. Onda 8 EM ANDAMENTO: Task 8.1 ✅ — gatilho de silêncio. Task 8.3 (jornada E2E) ✅ —
tests/e2e/followup-journey.spec.ts, 2/2 rodadas verdes. Ver Log de avanços. Falta na Onda 8:stage_change(não pedido nesta task — brief 8.1 focou só emsilence), flywheel, e o restante da Task 8.3 (checklist DoD item a item + doc do PRD — o controller cobre essa parte separadamente). - Task 7.1 ✅ — endpoint fila + aba + cancelar.
GET /api/v1/ai/followups/queue(viewer+): UNION app-side defollowup_enrollments+cron_jobs(kind='at'+job_kind='followup_turn'), ordenado(next_fire_at asc nulls last, id asc), cursor seek{next_fire_at,id}aplicado às 2 fontes, janelalimit+1por fonte + merge (k-way lookahead — sem pular/duplicar entre páginas).status/pointer_idpulam a fonte de promessas (não têm esses campos);qresolve pracontact_ids primeiro, aplicado nas 2 fontes.POST /api/v1/ai/followups/enrollments/:id/cancel(manager+): 409already_terminalse já encerrado, senão cancela + eventocancelled_manual+ auditfollowup_enrollment.cancelled(ação nova).app/app/ai/followups/page.tsxvirouTabs(Fluxos/Fila) — o gate de página que exigia manager+ pra ver QUALQUER coisa foi relaxado pra só exigir org ativa (Fila é viewer+; Fluxos manteve seu própriocanWrite).QueueTab.tsx+useFollowupQueue.ts(useInfiniteQuery, mesmo padrão deuseAdminIncidents) — filtros status/fluxo/busca (debounce 250ms), relativo+absoluto viadate-fns/ptBR(reuso, sem dep nova),AlertDialogde cancelar. PROVA AO VIVO (tests/e2e/followup-queue.spec.ts, 2/2 verde,E2E_PORT=3010): setup 100% via API real (fluxo publicado, contato, enrollment) +scripts/seed-e2e-followup-promise.tsnovo (mesmo padrão deseed-e2e-queue.ts, service role — não existe rota pública pra criarcron_jobs, só a tool do agente em runtime). Os 4 casos de aceite cobertos SEM DEFERIR NENHUM: (1) linha do enrollment com contato/fluxo/nó/próximo-disparo/status; (2) filtro status esconde e filtro por fluxo estreita pra exatamente 1 linha; (3) cancelar via dialog → some do filtro Ativo + toast + prova via API (GET queue?status=cancelledcontém o id cancelado) + RBAC extra verificada ad-hoc (viewer faz POST direto em/cancel→ 403forbidden_roleserver-side, não só botão escondido); (4) busca por nome da promessa seedada estreita a fila INTEIRA da org pra exatamente 1 linha (flow_name="Promessa", motivo, "Agendada", "em 3 dias", sem botão Cancelar). Screenshots emtest-results/followup-7.1-0{1..6}-*.png— a 01 mostra dado 100% real incluindo 3 promessas de uso genuíno da IA já existentes no dev DB ("Prova Multimodal").npm run typecheck0,npm run lint0 novo (2 erros pré-existentes intocados da Task 2.1),npm run test:unit547/547 sem regressão. Sem migration (só leitura de tabelas já existentes;cancelled_manualéevent_type textlivre, sem CHECK a alterar). Detalhe completo em.superpowers/sdd/task-7.1-report.md. - Task 6.3 ✅ — editor de condição de aresta.
lib/followup/edge-condition-options.ts(novo, puro):edgeConditionOptions(sourceNode)—ai_classify→always+ 1class_matchpor classe declarada +class_match:no_reply;condition→always+cond_result:true/false; qualquer outro tipo → sóalways. MaisconditionKey/conditionLabel(pt-br: classe crua,no_reply→"Sem resposta",true→"Sim",false→"Não",always→"Sempre") — 9 testes unit.app/app/ai/followups/[id]/_components/EdgeConfigPanel.tsx(novo): painel docado (mesmo estilo do NodeConfigPanel), mostra origem→destino e um Select com as opções exatas do nó de origem.FlowCanvas.tsx:onEdgeClickseleciona a aresta (fecha o painel de nó, e vice-versa — mutuamente exclusivos),edgesForRender(memo) injetalabelderivado dedata.conditionem TODA aresta pro fio mostrar "positivo"/"Sem resposta"/"Sempre"/"Sim"/"Não" (React Flow's built-in edge label, sem edge customizado — confirmado no dist instalado queBaseEdge/EdgeTextjá suportamlabelnos 4 tipos default).graph-mappers.ts: só umexportadicionado emtoFlowNode(já existia, não exportada) — reuso, ZERO mudança de comportamento/round-trip. PROVA (Playwright,tests/e2e/followup-builder.spec.ts, describe "Task 6.3"): fluxo trigger→ai_classify(2 classes: positivo/objecao)→2 ações→2 fins (2 fins são necessários, não decorativos —addEdgedo React Flow recusa uma 2ª aresta entre o mesmo par origem/destino, entãono_replye o fallbackalwaysdo classify não podem mirar o MESMO nó "fim"). Checagem negativa primeiro: publica o grafo com TODAS as 4 arestas de saída do classify aindaalways(estado pré-6.3) → 422 real,Fluxo reprovado na validação, nó do classify com anel vermelho + mensagem"não tem edge class_match para a classe 'positivo'", exatamente 1 nó com erro (screenshotfollowup-6.3-02-publish-422-all-always.png). Depois abre cada aresta de saída do classify via o painel novo, selecionapositivo/objecao/Sem resposta(aalwaysde fallback já vem certa, confirmada explicitamente no painel) → Publicar → SUCESSO: badge "Ativo", toast "Fluxo publicado.", zero erros no nó classify, e as 4 arestas mostram seus labels no fio (screenshotfollowup-6.3-04-published-branching.png) — o MESMO grafo que falhou acima agora publica porque o editor existe. Descoberta de implementação: o grid de posição default da paleta (220px de espaçamento < 224px de largura do card) sobrepõe cards com >4 nós e gera bezier loopy que esconde o label de aresta atrás de um card vizinho — o teste reposiciona os 6 nós manualmente (drag pelo header, fora dos handles Top/Bottom) num layout de árvore de verdade antes de conectar; efitViewdo React Flow reajusta zoom (até 2x) a cada nó recém-medido, então o teste estabiliza via zoom-out ANTES de calcular posições-alvo (mesma cautela já documentada no teste 6.2). PROVA: 9/9 unit novos (edge-condition-options.test.ts), suíte unit completa 547/547, typecheck 0, lint 0 novo (os 2 erros pré-existentes emgraph-schema.test.tsda Task 2.1 continuam intocados),npm run buildOK, e2efollowup-builder.spec.tscompleto 7/7 (6.1+6.2+6.3, sem regressão). Sem migration nesta task. - RACE do classify lento — RESOLVIDA na 5.2.
processNode(node-handlers.ts) ganhouwokeEarly?: boolean: quandowaitElapsed=trueEwokeEarly=true, reenfileira classify em vez de rotearno_reply.engine.tscomputawokeEarlychecando o marker${node}:${steps}:wakenos eventos do enrollment — gravado porlib/followup/reactivity.tsquando um inbound chega durantewaiting_replysemcancel_on_reply. Distinto do idempotency_key de passo quewaitElapsedjá checava (Task 5.1), então os dois sinais nunca se confundem. Provado emtests/invariants/followup-reactivity.test.ts(o TESTE CRÍTICO: 1º tick enfileira classify, reactivity empurranext_eval_atpra agora ANTES do grace vencer, 2º tick do engine real reenfileira classify — não roteiano_reply). - Dev DB: migrations 0054 e 0056 APLICADAS no projeto rrydmwnporysaiysiztn via Management API (token do CLI no keychain, entrada "Supabase CLI"/"access-token", formato go-keyring-base64).
database.types.tsregenerado (public,storage,graphql_public). 0057 (Task 4.1, kindfollowup_dead) ainda NÃO aplicada no dev DB remoto — não bloqueou a prova ao vivo da Task 4.2 porque o cenário provado (trigger→wait→condition→end) nunca passa pelo caminhomarkDead/agent_inbox_items.kind='followup_dead'; aplicar antes de qualquer prova futura que precise do caminho dead-letter. - Migration seguinte livre: 0058.
- Pendências deliberadas: aplicar 0054 no dev DB remoto + regenerar
lib/database.types.ts→ fazer na preparação da Onda 3 (controller faz; subagents sem MCP Supabase). Minors do review 1.1 p/ triagem final: (1) idiomduplicate_objectnas policies difere da convençãodrop policy if existsdo repo; (2) sem índice org-only emfollowup_flow_versions/followup_enrollment_events.
Decisões tomadas
- 2026-07-21: UM motor/UM relógio (
followup_enrollments.next_eval_at); nós de IA viajob_queue; envio at-most-once; validação de janela 24h no PUBLISH; grace obrigatório no classify;paused_handoffcom retomada por evento. (Spec §2.) - 2026-07-21:
@xyflow/reactaprovado pelo Rafael para o canvas (dynamic import, medir bundle). - 2026-07-21 (Task 4.1):
AdminClientdo engine NÃO éSupabaseClient— é uma interface própria e estreita (poucos métodos nomeados: claim/loadGraph/loadLeadFacts/loadEvents/insertEvent/updateEnrollment/loadPointerName/insertDeadInbox). Motivo:tests/invariants/**roda contra Postgres cru (pg.Pool, sem PostgREST —NEXT_PUBLIC_SUPABASE_URLaponta pra porta inalcançável de propósito novitest.db.config.ts), então umAdminClient=SupabaseClientreal seria intestável ali.lib/followup/engine.tsexportacreateSupabaseAdminClient(admin)pra produção (ainda sem consumidor — a rota de cron é task futura) e o teste DB implementa o adapterpg-puro inline. Próximas tasks que precisarem de uma rota real usando o engine devem usarcreateSupabaseAdminClient, não reinventar.
Log de avanços (mais recente primeiro)
-
2026-07-23: Task 8.6 ✅ — furo anti-spam do Rafael fechado: 1 follow-up vivo por lead ORG-WIDE + pin do agente no enrollment. Migration 0062 (
20260722160000_0062_followup_enrollment_exclusivity.sql) + apêndice idempotente nobaseline.sql+ linha no MANIFEST: (a) DEDUP FIRST via window function genérica —(org, contact)com >1 vivo (active/waiting_reply/paused_handoff) mantém ostarted_atmais recente e cancela o resto (cancelled/exclusivity_backfill/next_eval_at null) ANTES do índice (self-host-safe); (b)idx_followup_enrollments_one_live(MESMO nome, WHERE inalterado) de(pointer_id, contact_id)→(organization_id, contact_id); (c)agent_id uuid references ai_agents(id) on delete set null+ índice. Gate refatorado (agent-followup-gate.ts): 1 métodoloadEnabledPublishedFollowupAgents(uma query, deriva booleano+pick).resolveAgentForAutomaticTrigger→ agent_id do agente que arma o pointer, pick determinístico = MENOR uuid quando >1 (documentado+testado). silence-sweep colapsa gate+pick no memo e pinaagent_id; manual-enroll aceitaagent_idopcional (valida org — nunca do body p/ tenancy) OU resolve do pointer. Fila (queue route + QueueTab + hook): embedai_agents:agent_id(name)→agent_name, mostrado como subtexto "agente X" sob o fluxo; mapeamento extraído p/enrollmentToQueueRowpuro. PERSONA-IN-SEND DEFERIDO (scoped follow-up, honesto): a persona do envio vem deloadPublishedAgentConfig(pool, org, channelSessionId)— segue o NÚMERO (channel_session), nãoagent_id. Usar a persona PINADA exigiria mudar a assinatura de harness portado (runAgentTurn/AgentTurnInput/loadPublishedAgentConfig) + o enqueue do motor = cirurgia profunda que o brief mandou não fazer. Oagent_idjá fica PERSISTIDO no enrollment; o wire-up futuro é só no seam do turno, sem migration. Ressalva: número segue a conversa (continuidade preservada) e o agente pinado normalmente É o do número — divergência só com1 agente armando o mesmo fluxo (raro no MVP). PROVA:
test:invariants2x verde (40 files, 247 passed | 1 skipped, incl. baseline installON_ERROR_STOP=1+ update) — exclusividade RED→GREEN (índice antigo enrolla nos 2 fluxos = spam; o novo barra o 2º via 23505 org-wide), dedup 0062 (2 vivos → 1 + cancelled/ exclusivity_backfill), agent-pin (menor uuid).test:unit564/564, typecheck 0, lint 0 erros. Consequência tratada+disclosed: o índice virar org-wide quebrou o STOP test da reactivity (seedava 3 vivos p/ o MESMO contato — estado agora impossível por decisão do Rafael) → corrigido p/ 1 vivo (paused_handoff) + 1 terminal (completed), sobDESKCOMM_GOV_INVARIANTS_EDIT=1. Pendente do controller: aplicar 0062 no dev DB remoto + regenerardatabase.types.ts. Detalhe:.superpowers/sdd/task-8.6-report.md. -
2026-07-23: Task 8.3 (jornada) — fix de review CRITICAL: try/finally cobria tarde demais. O
tryoriginal só abria DEPOIS do enrollment confirmado — mas o pointer vira elegível pro silence sweep quando o AGENT é publicado comfollowup.enabled=true(bem antes disso), então ~80 linhas (publish do agent, seed do contato, o loop de varredura) ficavam fora da proteção: uma falha ali deixava o pointeractive+agentpublishedórfãos, reabrindo o MESMO incidente do parágrafo anterior. Fix:tryagora abre logo antes da seção 2 (criar/publicar o agent);agentId/seedviraramletfora do try (indefinidos até serem atribuídos);finallyroda 4 passos INDEPENDENTES (disable do fluxo +cleanup-flow-enrollments, sempre; archive do agent seagentIdexistir;cleanup-contactseseed?.contactIdexistir), cada um no seu próprio try/catch — uma falha em cleanup-contact nunca impede o disable (o passo que realmente estanca o sweep). Prova do fix: throw injetado logo após o publish do agent (a linha exata onde a janela abre) → teste falhou como esperado → Postgres real confirmou ofinallyMESMO ASSIM desativou o pointer + arquivou o agent (0 pointers silence ativos, 0 enrollments vivos org-wide) → throw removido → 2 rodadas reais seguidas 1/1 verde cada → Postgres real limpo depois (0/0/0). Detalhe completo (throw + saídas coladas) em.superpowers/sdd/task-8.3-report.md, seção "Fix de review". -
2026-07-23: Task 8.3 (jornada) ✅ —
tests/e2e/followup-journey.spec.ts, a prova ponta-a-ponta que amarra o sistema inteiro: silêncio → enroll (gate do agente) → engine avança nó a nó → resposta do lead → classify roteia → outcome → fila. O seam de LLM (honestidade real-vs-injetado, explícito no arquivo e no report): dev gateway não tem model ids reais utilizáveis — a spec NUNCA roda um agente de IA de verdade. Ela chamacompleteTurnForEnrollment(turn-bridge.ts) — a MESMA função que o worker 24/7 chamaria depois de uma chamada real de LLM — com resultado CONTROLADO ({kind:'sent'}no nó action,{kind:'classified', class:'positivo'}no ai_classify). TUDO o resto é real: build/publish/link do fluxo na UI, silence sweep gateado por agente publicado, engine avançando nó a nó via tick real do cron (fn_claim_due_followup_ enrollments), enqueue real emjob_queue, roteamento de aresta, event_log real (emit_event+event-log-drain) acordandoreactivity.ts, fila.scripts/e2e-followup-journey-helpers.ts(novo) — CLI de subcomandos (pg direto viaSUPABASE_DB_URL, mesmocreatePgAdminClientde produção que o worker 24/7 usa) cobrindo o que a API pública genuinamente não expõe:seed-silent-contact(contato+conversa comlast_inbound_atno passado),fast-forward-enrollment(pula os 5min/15min reais de wait/grace sem dormir),simulate-inbound(inseremessages+ chamaemit_event('message.received', ...)— a MESMA função quelib/waha/ingest.tschama no webhook real),complete-turn(o seam acima),find-job/get-enrollment/find-enrollment/list-events(leitura de prova),cleanup-contact/cleanup-flow-enrollments(teardown). Achado de UI real (não bypass):trigger_config(kind='silence', threshold) não tem NENHUM controle no canvas do builder ainda — confirmado lendoNodeConfigPanel.tsx(o branchtriggersó mostra texto explicativo). A spec seta viaPATCH /api/v1/ai/followup-flows/:id— o MESMO endpoint quehandoff_policyjá usa no PublishBar, só sem widget dedicado ainda. Registrado como gap de UI conhecido, não escondido. Achado operacional sério (2ª rodada, corrigido antes de fechar a task): o gatilho de silêncio é CROSS-CONTATO por design (varre a org inteira) — uma 1ª tentativa que travou num timeout de teste (antes de eu ajustartest.setTimeout) deixou um fluxo de testeactivesem cleanup, e ele começou a enrollar contatos REAIS do dev DB (Rafael Melgaço,Cliente Fila E2E, etc.) nas rodadas seguintes — 12 enrollments espúrios encontrados e removidos manualmente. Fix estrutural: (1) o teste desativa o pointer IMEDIATAMENTE após confirmar o próprio enrollment (não espera o fim do teste — desativar não afeta enrollments já vivos, só impede o sweep de pegar mais um), (2) todo o corpo pós-enroll roda emtry/finallycomcleanup-flow-enrollments(apaga TODO enrollment daquele pointer, não só o do contato desta run) — nunca deixa um pointeractiveou um enrollment órfão se uma asserção falhar no meio, (3) as asserções de fila escopam por CONTATO+FLUXO (.filter({hasText: flowName})), não só contato, porque o mesmo contato pode aparecer em mais de um pointer de teste entre rodadas. Fixtures de agente pra PUBLICAR (não só salvar draft):seed-e2e-followup-agent.ts(Task 7.2) cria a credential SEMvalidated_ate achannel_sessionnasceSTARTING— suficiente pra salvar rascunho, masfn_publish_ai_agent_versionexigecredential.validated_at is not nullechannel_session.status='WORKING'.prepare-agent-fixtures(novo subcomando) seta os dois via UPDATE direto antes do publish — não mexe no seed script original (mantém 7.2 intocado). Grafo: trigger→wait(fixed 5min, floor do schema —duration_msmínimo é 300_000, não dá pra ir mais "pequeno")→action(ai_message)→ai_classify(1 classe "positivo", grace 15min — floor do schema)→3 fins (positivo/no_reply/always-fallback:validateFlowForPublishexige as 3 arestas de saída do classify —missing_class_edge/missing_no_reply_edge/missing_always_fallback— e oaddEdgedo React Flow recusa 2 arestas source→target iguais, então precisa de 3 nós de fim distintos, não 1).steps_takenesperado em cada marco documentado inline no spec (contagem exata: trigger-advance=1, wait-start=2, wait-elapsed=3, action-enqueue=4, classify-enqueue=6 — ocomplete-turn('sent')entre os dois soma +1 fora do tick). PROVA — 2 rodadas seguidas, síncronas, 1/1 verde cada (porta 3033,E2E_PORT=3033): rodada 1 = 1m18s, rodada 2 = 1m14s (timeout do teste elevado pra 300s viatest.setTimeout— múltiplos round-trips reais de cron/drain, mesmo padrão dewebhooks.spec.ts). Confirmado no Postgres real após CADA rodada: 0 enrollments órfãos, 0 contatos de teste remanescentes, pointer/agent desativados/arquivados. Screenshots eme2e-artifacts/followup-8.3-0{1..4}-*.png(fluxo publicado, seção Follow-up do agente, fila com o enrollment ativo, fila com o outcome "Concluído").npm run typecheck0,npm run lint0 erros (117 warnings pré-existentes deno-console, mesma classe tolerada de todoscripts/seed-e2e-*.ts; nenhum novo além do padrão). Sem migration nesta task (só leitura/escrita via rotas e tabelas já existentes). Detalhe completo em.superpowers/sdd/task-8.3-report.md. Pendente: checklist DoD item a item, doc do PRD, HANDOFF final — o brief 8.3 divide essa parte pro controller (fora do escopo desta sessão). -
2026-07-22: Task 8.1 ✅ — gatilho de SILÊNCIO (varredura TIME-DRIVEN no cron) + primeiro consumidor real do gate
isPointerEnabledForAutomaticTrigger. Decisão de arquitetura (dada, não descoberta aqui): silêncio é TIME-DRIVEN (varredura periódica), NÃO event-driven — não entra emlib/followup/reactivity.ts(que reage aevent_log). Vive no cronapp/api/v1/cron/followup-flow-worker/route.ts, que já roda a cada minuto com admin client:runSilenceSweeproda DEPOIS derunFollowupTick(e da sua auditoria), num try/catch ISOLADO — sweep falhando NUNCA aborta a resposta do tick (só loga, sem PII).lib/followup/silence-sweep.ts(novo) —runSilenceSweep(deps): acha pointersstatus='active'comtrigger_config.kind='silence'de TODAS as orgs (mesmo desenho cross-org defn_claim_due_followup_enrollments) → GATEIA cada um viaisPointerEnabledForAutomaticTrigger(memoizado pororgId:pointerIddentro da varredura) → acha contatos silenciosos da org → cria enrollment nascendo no nótriggerdo grafo pinado,next_eval_at=now. InterfaceSilenceSweepDbestreita, mesma doutrina deAdminClient/ReactivityAdminClient/FollowupGateDb;createSupabaseSilenceSweepDb(admin)é o adapter de produção. "Última inbound" — achado central:conversations.last_inbound_atJÁ EXISTIA (populado porfn_ingest_message, já usado porworkers/ai-response-worker.tselib/routing/queue.ts) — zero migration. Ressalva real: é POR CONVERSA, o enrollment é POR CONTATO (um contato pode ter 2+ conversas/channel_sessions) —loadSilentContactIdsbusca todas as conversas da org com embed de contato (mesmo padrão 1:1 deai-response-worker.ts:255) e reduz client-side prolast_inbound_atMAIS RECENTE por contato (Map). Decisão deliberada: NÃO criei uma function SQL pra fazer isso no Postgres (oGROUP BY MAXcabe numa reduction em memória pro volume do perfil PME do CLAUDE.md — evita migration+baseline+MANIFEST pra uma agregação pequena; troca é um refactor local se a escala pedir).segments: não havia NENHUMA primitiva de segmento de contato modelada (enabled_segmentsemreentry-knobs.tsé de outro sistema — Vendaval, valores opacos). Interpretei como overlap comcontacts.tags(única coluna real, já com GIN index). Vazio/ausente = todos os contatos silenciosos — documentado como interpretação deferida no header do módulo. Idempotência:insertEnrollmenté INSERT puro — 23505 (índice únicoidx_followup_enrollments_one_live) viraskipped_existing, nunca erro. Um contato que completou/cancelou pode re-enrollar na varredura seguinte se continuar silencioso — aceitável no MVP, sem cooldown table (fora de escopo por instrução explícita do brief).app/api/v1/cron/followup-flow-worker/route.ts— chamarunSilenceSweepcomcreateSupabaseSilenceSweepDb(admin)+createSupabaseFollowupGateDb(admin)depois do tick; auditafollowup.silence_sweep_run(nova action emlib/audit/actions.ts, union tipada) em sucesso. Response contract do tick (ok(summary,...)) INTOCADO — o sweep não polui o body de resposta, só audita separadamente — preservatests/api/followup-cron-worker.test.tssem precisar editá-lo. PROVA (DB-real,tests/invariants/followup-silence-sweep.test.ts, 8 testes novos): (1) pointer silence gateado + contato silencioso > threshold → 1 enrollment no nó trigger; 2ª varredura não duplica (unique-live); (2) gate-out em 2 sabores (sem agente publicado habilitando / agente publicado comenabled=false) → 0 enrollments — prova que o gate É chamado, não só importado; (3) boundary — silêncio < threshold → 0 enrollments, gate passou (isola o que bloqueou); (4) gate SQL integration (reviewer Minor #2 da Task 7.2 — requisito explícito desta task):isPointerEnabledForAutomaticTriggercontraai_agent_versionsREAL via adapter pg-backed espelhando a query decreateSupabaseFollowupGateDb— publicado+enabled+membro→true, draft→false, enabled=false→false, cross-org→false. Flake achado e corrigido na 1ª rodada (mesma classe já documentada no fix da Task 5.2):loadActiveSilencePointersé cross-org de propósito, então um pointerkind='silence'de umitanterior (nunca desativado) contaminavapointers_scanned/pointers_gated_outdoitseguinte — fix:beforeEachdeletandofollowup_flow_pointers where trigger_config->>'kind' = 'silence'(escopo estreito, cascade viaon delete cascade). PROVA: typecheck 0, lint 0 novo (os 2 erros pré-existentes degraph-schema.test.ts, Task 2.1, intocados), unit 551/551 sem regressão (nenhum unit novo — brief só pediu invariantes pra esta task). Invariantes: 3 rodadas seguidas, síncronas — 3/3 limpo: 39/39 arquivos, 234 passed | 1 skipped em TODAS (era 226+1 antes — exatos +8, +1 arquivo, zero regressão). Detalhe completo em.superpowers/sdd/task-8.1-report.md. Sem migration nesta task. -
2026-07-22: Task 7.2 ✅ — seletor de fluxo no editor do agente + gate de gatilho automático. Onda 7 fechada. Achado que muda o brief: a config versionada de agente (
ai_agent_versions) NÃO é um jsonb único — é coluna real por campo (trigger_config/handoff_keywordsetc., confirmado lendosupabase/baseline.sqlantes de codar). A suposição do brief ("aditivo, sem migration, é tudo jsonb") não batia; o campo novo precisou de migration real:ai_agent_versions.followup jsonb not null default '{"enabled":false,"flow_pointer_ids":[]}'::jsonb. Ainda assim aditivo — versões existentes nascem com o default, zero regressão em agentes que nunca falaram de follow-up. Colisão de numeração real (multi-worktree):HANDOFFdizia "migration seguinte livre: 0058", mas o dev DB remoto é COMPARTILHADO entre worktrees paralelos — outro branch já tinha aplicado0058_media_multimodal,0059_agent_split_messages,0060_message_templates(supabase migration list+ Management API confirmaram;information_schema.columnsjá mostravamultimodal_input/split_messagesemai_agent_versionsque meugit log/ls supabase/migrations/local não conheciam). Renumerado pra 0061 antes de aplicar — a lição: SEMPRE conferir o número livre contra o REMOTO (supabase migration listou a Management API), não só contrals supabase/migrations/local, quando o dev DB é compartilhado entre worktrees.fn_ai_agent_version_content_immutable()(0051) re-assentada (create or replace, forward-fix idempotente) incluindofollowupno veto de mutação de versão PUBLICADA — sem isso o campo ficaria mutável por fora do contrato de imutabilidade que o resto da tabela já respeita (achado ao ler a trigger antes de mexer no schema). Aplicação sem MCP: as toolsmcp__plugin_supabase_supabase__*não estavam carregadas nesta sessão.SUPABASE_DB_URL(pooler, roleagent_worker) não tem ownership pra DDL (must be owner of table ai_agent_versions) — aplicado via Management API (POST /v1/projects/:ref/database/query) com o access token do keychain (security find-generic-password -s "Supabase CLI" -a "access-token" -w, decodificado dego-keyring-base64:), mesmo mecanismo que o CLI usa. Achado extra: a API da Management exigeUser-Agentou o Cloudflare WAF devolve 403 (error code: 1010) sem explicar — resolvido comUser-Agent: supabase-cli/2.95.4. Migration re-aplicada 2x (idempotência provada) antes de seguir.lib/database.types.ts: regen completo viasupabase gen typestraria TAMBÉM as tabelas/colunas de OUTROS worktrees (multimodal_input,split_messages,message_templates...) — fora do escopo desta task e do meu branch. Optei por um patch cirúrgico manual (sófollowup: Jsonnos 3 shapes deai_agent_versions), impacto mínimo, sem misturar schema alheio no diff. Zod:followupConfigSchema({enabled: boolean.default(false), flow_pointer_ids: uuid[].max(20).default([])}) emlib/ai/agents/validation.ts, campofollowupemversionShapeSchema— aditivo confirmado pelos 551/551 unit (era 547/547 na Task 7.1) sem quebrar nenhum teste de agente existente. UI:app/app/ai/agents/[id]/_components/FollowupFlowPicker.tsx(novo) — multi-select que ESPELHAToolPicker.tsx(fieldset+checkbox, mesmo padrão visual Sage), filtrauseFollowupFlows()client-side prastatus==='active'(a rotaGET /api/v1/ai/followup-flowsnão tem?status=— checado, fora do escopo mínimo mudar a rota), empty-state linka pra/app/ai/followups.AgentForm.tsxganha a seção "Follow-up" depois de "Handoff humano":Switchprofollowup.enabled+ o picker proflow_pointer_ids.VersionDiff.tsxganha uma seção "Follow-up" (pills de fluxo adicionado/removido + enabled A→B) — nice-to-have barato pra não deixar o diff de versão cego a este campo novo. Gate (onda 8 ainda não existe):lib/followup/agent-followup-gate.ts(novo) —isPointerEnabledForAutomaticTrigger(db, orgId, pointerId): um gatilho AUTOMÁTICO (silence/stage_change/ conversation_end) só pode enrollar nesse pointer se algum agente PUBLICADO da org tiverfollowup.enabled=trueefollowup.flow_pointer_idsincluir o pointer. Enrollment MANUAL (POST /api/v1/ai/followups/enrollments) NÃO passa por este gate (escolha explícita de humano). Confirmei por grep que NENHUM código cria enrollment a partir de gatilho automático ainda (silence/stage — só a Task 4.2 tem enrollment manual; o worker de tick AVANÇA enrollments existentes, não os CRIA) — então o gate nasce exportado + testado (4 unit tests,agent-followup-gate.test.ts) SEM CONSUMIDOR. A Task 8.1 (silence/stage → enrollment) DEVE chamar esta função antes de inserir a linha emfollowup_enrollments— não reinventar. Interface estreita (FollowupGateDb) + adapter de produção (createSupabaseFollowupGateDb), mesma doutrina deAdminClient/ReactivityAdminClient. PROVA AO VIVO (Playwright headed,tests/e2e/followup-builder.spec.ts, describe "Task 7.2",E2E_PORT=3010, 2 rodadas seguidas 1/1 verde + suíte completa do arquivo+queue 10/10 verde): setup 100% via API como ADMIN (não manager — achado ao lerpage.tsx: manager VÊ o form masreadOnly=true, só admin salva; login com MFA TOTP real via.e2e-creds.json → admin_totp) — publica um fluxo mínimo trigger→end, cria ummcp_agent+v1 draft viaPOST /api/v1/ai/agentsusando fixtures novas (scripts/seed-e2e-followup-agent.ts— credential+channel_session, únicas coisas que faltavam pra criar um agent via API; não existiam no repo). Abre/app/ai/agents/:id, prova o default aditivo (version.followup === {enabled:false, flow_pointer_ids:[]}na criação), habilita o toggle + marca o checkbox do fluxo publicado (locators, não screenshot-only), screenshote2e-artifacts/followup-7.2-01-flow-selected.png, clica "Salvar rascunho", screenshote2e-artifacts/followup-7.2-02-saved.png, e prova via API (GET .../versions/:vid) quefollowup.enabled===trueeflow_pointer_idscontém o id do fluxo — persistência real, não só estado de UI. Cleanup arquiva o agent + desativa o fluxo. Bug pré-existente encontrado e corrigido (fora do escopo do brief, mas achado rodando a suíte completa): o teste RBAC da Task 6.1 (viewer não vê o botão de criar fluxo) ficou órfão desde o commit6546271(Task 7.1, já commitado antes desta sessão) — a página/app/ai/followupsdeixou de redirecionar viewer pro/403(decisão deliberada: Fila é viewer+), mas a asserção velha (waitForURL(/\/403/)) nunca foi atualizada e falhava 100% das vezes isolado (não era flake de servidor — reproduzido 2x limpo). Corrigido pra refletir o RBAC atual: página carrega, botão "Novo fluxo" ausente. Regra do CLAUDE.md ("CI vermelho é pra ser consertado, não reportado de volta") — consertado, não só relatado. PROVA (unit/typecheck/lint):npm run typecheck0,npm run lint0 erros novos (os 2 pré-existentes degraph-schema.test.tsda Task 2.1 continuam intocados; +2 warningsno-consoledo novo script de seed — mesmo padrão tolerado de TODOS os outrosscripts/seed-e2e-*.ts),npm run test:unit551/551 (+4 sobre a Task 7.1:agent-followup-gate.test.ts). Migration 0061 aplicada no dev DB remoto + reaplicada 2x (idempotência provada) +database.types.tsatualizado (patch cirúrgico). Detalhe completo em.superpowers/sdd/task-7.2-report.md. -
2026-07-22: Task 6.2 ✅ (commits e06bf1e/bb928fd/fbc2415/6fdce2d/3d5d9f4) — builder visual completo em
/app/ai/followups/[id], o centerpiece da feature. 5 incrementos, cada um provado por Playwright antes do próximo:lib/followup/graph-mappers.ts—toReactFlow/fromReactFlowpuros (sem DB), round-trip provado (7 testes: 6 tipos de nó + 3 condições de aresta + posições não-triviais + piso de 2 nós).graphsEqual(sorted-key stringify) alimenta o dirty-state do incremento 5.page.tsx(gate manager+, 404 fora da org) +FlowBuilder.tsx(shellnext/dynamic ssr:false—@xyflow/reactsó carrega nesta rota) +FlowCanvas.tsx+NodePalette.tsx(clique OU HTML5 drag-and-drop viascreenToFlowPosition). GET/api/v1/ai/followup-flows/:idganhouversions_count/previous_version_id(2ª query de linhagem, sem.limit()de propósito — o fake DB detests/api/followup-flows.test.tsnão suporta esse método).- 6 node cards (
Trigger/Wait/Condition/Classify/Action/EndNode.tsx) finos sobreNodeCard.tsxcompartilhado — ícone+cor Sage distintos por tipo (trigger=accent, wait=info, condition=warning, ai_classify=accent sólido, action=success, end=error), subtítulo derivado do config viadescribeNodeConfig.onConnectsempre cria aresta{type:'always'}(editável só por convenção de default — sem UI de edge ainda, decisão de escopo: a spec de aceite não exercitaai_classify/conditioncom múltiplas saídas). NodeConfigPanel.tsx— 1 form por tipo, cada campo valida contra o schema exportado degraph-schema.ts(safeParse) antes de escrever no nó vivo; inválido = erro inline, nunca propaga estado parcial. Achado de design importante: a 1ª versão usavaSheet(Radix Dialog) e o overlay full-screen bloqueava clique em OUTRO nó do canvas (pointer-events da camada, independe demodal={false}) — trocado por<aside>comum, irmão de layout do canvas (padrão "docked inspector" de Figma/n8n), não modal.PublishBar.tsx— badge de status + "Alterações não salvas"; Salvar (PATCH draft_graph); Publicar SALVA primeiro (garante validar o que está no canvas, não um draft_graph desatualizado) depois POST publish — 422 mapeiadetails.errors[].node_idpronode.data.errorsde cada nó (ring vermelho + texto inline, reservado desde o incremento 3); Desativar; Rollback usaprevious_version_id, desabilitado com ≤1 versão; select dehandoff_policyfaz PATCH direto.FlowCanvas.tsxtrocou o snapshot estático do SSR poruseFollowupFlow(react-query, reativo às mutations). PROVA — sequência mandatória completa (Playwright headed, dev server real porta 3022 + DB remoto real,tests/e2e/followup-builder.spec.ts, 6/6 verde em 3 rodadas seguidas): monta trigger+wait+action+end via paleta → conecta só trigger→wait→action (SEM end) → configura wait=10min + action prompt_hint → Publicar com grafo incompleto → 422 com erros ANCORADOS (screenshot mostra 4 cards com ring vermelho + mensagem própria: trigger/wait/action porno_end_path/inalcançável, end porunreachable_node— não um banner genérico) → conecta action→end → Publicar de novo → toast "Fluxo publicado." + badge "Ativo" → RELOAD da página → grafo idêntico (4 nós, 3 arestas, "10 min" e o prompt_hint intactos) → Rollback desabilitado (1 versão, é o 1º publish). Screenshotstest-results/followup-6.2-0{1..8}-*.png. Achado de robustez do PRÓPRIO TESTE (não bug do app, documentado no spec): comparar posição de nó viagetBoundingClientRectsó é estável se ofitViewjá assentou ANTES de medir dos DOIS lados (antes do reload e depois) — sob carga (suíte inteira, não isolado), uma corrida esporádica gerava ~390px de diferença; fix foi uma espera de 400ms após o clique em fit-view em ambos os lados da comparação (não era flake de posição real, era flake de QUANDO medir a posição).npm run build(Turbopack) verde. Bundle: chunk do@xyflow/react(.next/static/chunks/0r6p4oj5ltmpf.js, 192K bruto / ~60K gzip) confirmado AUSENTE derootMainFiles(bundle compartilhado) e dobuild-manifest.jsonde AMBAS as páginas (/app/ai/followupse/app/ai/followups/[id]) — só carrega client-side via onext/dynamicdoFlowBuilder.tsx, e só quando o usuário abre o builder. typecheck 0 / lint 0 novo (2 erros pré-existentes emgraph-schema.test.tsda Task 2.1, intocado) / unit 538/538 (+7 desta onda, sem regressão). Decisão deliberada de escopo: edição de condição de aresta (class_match/cond_result) via UI fica pra uma próxima iteração — a sequência de aceite mandatória só exercita arestasalways(trigger→wait→action→end);ai_classify/conditioncom múltiplas saídas continuam editáveis viaNodeConfigPanel(o NÓ), só a aresta em si nasce semprealways. Detalhe completo em.superpowers/sdd/task-6.2-report.md.
-
2026-07-22: Task 6.1 ✅ (commit
fcd0068) — página + lista de fluxos, 1ª tela user-visible da feature.app/app/ai/followups/page.tsx(server component, mesmo padrão deapp/app/ai/agents/page.tsx:requireAuth/resolveActiveOrg, redirect/403se role<manager, SELECT direto emfollowup_flow_pointersfiltrado pororganization_id) +_components/FlowsList.tsx(client,useFollowupFlowsvia TanStack Query) +NewFlowDialog.tsx(form controlado,useCreateFollowupFlowfaz POST e prepend otimista na lista viasetQueryData— sem navegar pro builder, que ainda não existe) +FlowStatusBadge.tsx(draft→neutral, active→success, disabled→warning— variants NATIVOS doBadgeSage, que já mapeiam pra--color-{success,warning}-{bg,fg}tokens; não usei Tailwind cru).hooks/followup/useFollowupFlows.tsespelhahooks/ai/useAgent.ts+hooks/ai/useAgents.ts(mesmoapiClient, mesmo padrão de query key + mutation). Nav:Sidebar.tsxganhou item "Follow-ups" (íconeFlowArrow, novo no barrellib/ui/icons.ts— ADR-05, import só daqui) reusando a permissãoai.agents.view(rank manager) — mesmo filtro deNAV_ITEMSjá cobre o novo item porque o filtro casa por STRING de permissão, não por href. Contagem de enrollments vivos por fluxo: DEFERIDA. O GET de/api/v1/ai/followup-flowsnão expõe isso hoje; adicionar exigiria mudar o SELECT da rota (subquery correlacionadacount(*) from followup_enrollments where pointer_id=... and status not in (completed,cancelled,dead)) — fora do escopo mínimo desta task (a UI de 6.1 já comunica status/versão sem o número). Fica pra quando 6.2/6.3 precisarem mostrar fila viva. PROVA (Playwright headed,tests/e2e/followup-builder.spec.ts, 2/2 verde): dev server real (next dev --port 3022— 3001 estava ocupado por OUTRO worktree,DeskcommCRM-qa, confirmado vialsof/cwdantes de escolher a porta) + DB remoto real + login real do manager seed (.e2e-creds.json). Caso 1: login manager →/app/ai/followups→ heading "Follow-ups" visível → screenshot lista vazia → clica "Novo fluxo" → dialog abre com foco no input (toBeFocusedvia locator, não visual) → digita nome com timestamp único (E2E Follow-up <Date.now()>, evita colisão entre runs — não há DELETE na API, decisão deliberada de onda anterior) → screenshot → clica "Criar fluxo" → dialog fecha → o card na lista contém o nome E o texto exato "Rascunho" (getByTextescopado ao<li>, não "por perto") → screenshot final. Caso 2 (RBAC): login viewer →/app/ai/followups→ redirect síncrono pro/403(a página nem renderiza o botão — gate é nopage.tsx, mais forte que esconder o botão no client). 4 screenshots emtest-results/followup-6.1-0{1..4}-*.png(visualmente inspecionados: paleta Sage, íconeFlowArrowativo na sidebar, badge "Rascunho" como pill neutro — nada de shadcn-default).npm run typecheck0 /npm run lint0 novo (2 erros pré-existentes emgraph-schema.test.tsda Task 2.1, intocado, seguem os mesmos). Draft de teste (E2E Follow-up <timestamp>) fica no dev DB — sem endpoint DELETE, sweep manual é dívida conhecida, não bloqueia. Sem migration nesta task (só leitura/escrita via a rota 3.1 já existente). -
2026-07-22: Task 5.2 ✅ (commits 863d625/ba22723/ebf4a72/d50fffb) — reatividade: inbound acorda classify, STOP cancela tudo, handoff pausa/retoma (o anti-Tomik).
lib/followup/reactivity.ts(novo) —applyReactivityEvent(db, clock, row)trata 3event_types:message.received(contatois_blocked→ cancela TUDO vivoopted_out; senão,waiting_replydo contato:trigger_config.cancel_on_replydo pointer → cancelareplied, senão acorda via markerinbound_wokestep-scoped +next_eval_at=now),ai.handoff_triggered(aberto — aplicahandoff_policydo pointer: pause/cancel/allow),ai.handoff_resolved(fechado —paused_handoff→activecomnext_eval_at=now+30min). DESVIO DELIBERADO do esboço do brief (cursor próprio emwatchdog_cursorsdentro do tick dofollowup-flow-worker): investiguei o consumidor deevent_logREAL em produção neste repo —lib/event-log/dispatcher.ts+drain.ts(roda a cada minuto viaapp/api/v1/cron/event-log-drain, tanto Vercel cron quanto crontab do kit self-host — README.md confirma) com idempotência viaconsumed_by[]já testada (tests/invariants/event-log-drain.test.ts).watchdog_cursorstem ZERO consumidores TS neste repo (grep confirmou — infra não usada). Reusar o dispatcher genérico (lib/followup/reactivity.handler.ts, keyfollowup-reactivity.v1, registrado emlib/event-log/register-handlers.ts) evita inventar um 2º mecanismo de consumo E dá de graça o requisito "falha de reactivity não aborta o tick": são crons/rotas SEPARADOS — isolamento total, não um try/catch agregando summary.runFollowupTick/engine.ts continuam intocados nesse aspecto (só ganharam owokeEarly— ver acima).ai.handoff_resolvedé evento NOVO — grep confirmou que não existia NENHUM sinal de fechamento de handoff no repo (sóai.handoff_triggeredna abertura, vialib/ai/handoff/orchestrator.ts); a spec §4 assumia "evento de fechamento já emitido" mas isso nunca foi verdade. Adicionado emapp/api/v1/conversations/[id]/reactivate-bot/route.ts(rota home-grown, não código portado do WAHA — mesmo padrãoemit_eventde ~30 rotas do repo, ex.conversations/[id]/claim/route.ts). Sem isso,paused_handoffseria literalmente órfão — violaria o próprio requisito anti-Tomik da task.lib/followup/api-schemas.ts—triggerConfigSchemaganhacancel_on_replyopcional (sibling dekind, não dentro deparams: é política de reação-à-resposta, ortogonal a como o fluxo foi disparado). Aditivo, sem migration (jsonb, Zod só valida na escrita). Idempotência: escritas de cancel/pause/resume usamreactivity:${event_log_row_id}:${enrollment_id}:${tipo}(redrain do MESMO row nunca duplica); o wake marker usa a chave step-scoped que engine.ts consome. Toda leitura/escrita filtraorganization_idderow.organization_id(nunca do payload). PROVA: node-handlers 38/38 (+2 wokeEarly), suíte unit completa 531/531, typecheck 0, lint 0 novo (os 2 erros pré-existentes emgraph-schema.test.tsda Task 2.1 continuam intocados). DB-real:tests/invariants/followup-reactivity.test.tsnovo, 10/10 — STOP cancela tudo (3 pointers distintos — a constraintidx_followup_enrollments_one_livenão permite 2 vivos no mesmo pointer) + idempotência sob re-drain; inbound wake + O TESTE CRÍTICO da corrida classify-lento (sequênciaclassify_enqueued→inbound_woke→ classify_enqueued, nunca sai do nó viano_reply);cancel_on_replytrue/ausente; handoff pause/cancel/allow; O CENTERPIECE anti-Tomik: abre(pause,next_eval_at=null) → fecha(ai.handoff_resolved) → retoma(active,next_eval_at=now+30min) → o tick do engine REAL consegue reclamar essa linha depois (steps_takenincrementa) — nunca preso;- idempotência do fechamento. Suíte completa de invariantes: 38/38 arquivos, 225 passed |
1 skipped (era 215+1 na Task 5.1 — exatos +10 testes, +1 arquivo, zero regressão);
install+update do baseline.sql sem erro novo em
pgvector/pgvector:pg17descartável. Sem migration nesta task (widening de Zod sobre jsonb existente, sem tocar schema). Detalhe completo em.superpowers/sdd/task-5.2-report.md. Pendente pra Onda 6: nenhuma. O motor de reatividade está completo e coberto; a UI (builder React Flow) é trabalho totalmente separado.
- idempotência do fechamento. Suíte completa de invariantes: 38/38 arquivos, 225 passed |
1 skipped (era 215+1 na Task 5.1 — exatos +10 testes, +1 arquivo, zero regressão);
install+update do baseline.sql sem erro novo em
-
2026-07-22: Task 5.2 — fix de review (1 Critical + 1 Important + 2 Minors, todos corrigidos). Critical (
lib/followup/turn-bridge.ts): o guard de obsolescência decompleteTurnForEnrollmentexcluía sócompleted|cancelled|dead— faltavapaused_handoff. Um jobclassify/action/decide_timingem voo quando um handoff pausa (current_node_id não muda no pause — sóstatus) passava o guard incólume e reativava/avançava o enrollment por baixo do humano, ignorandoreactToHandoffCloseinteiramente (mensagens automáticas enquanto um humano segura a conversa). Fix: 1 linha —paused_handoffentra na exclusão, a conclusão tardia vira NO-OP (o resultado stale é descartado, não perdido: é exatamente o comportamento certo — foi calculado antes do humano intervir). Provado emtests/invariants/followup-reactivity.test.ts(describe novo "completeTurnForEnrollment (turn-bridge) — respeita paused_handoff"): classify job em voo → handoff abre (pausa) → o job completa tarde com resultado 'hot' → enrollment CONTINUApaused_handoff/ac1/sem eventoai_classified→ handoff fecha → retoma normalmente praactiveno MESMO nó (o resultado stale nunca foi aplicado). Important (app/api/v1/conversations/[id]/reactivate-bot/route.ts): o emit deai.handoff_resolvedera fire-and-forget (.then()sem await). Nas ~30 outras rotas um evento perdido custa um audit trail; aqui é o ÚNICO produtor do sinal de fechamento, sem retry — um drop órfãpaused_handoffPRA SEMPRE. Fix:awaitnoemit_event; falha agora devolve 500 (a query de update já é idempotente — reclicar reactivate-bot é seguro). Minor 1: comentário emreactToHandoffOpen/reactToHandoffClose(lib/followup/reactivity.ts) documentando o escopo deliberado por CONTATO (não por conversa) —LiveEnrollmentRefnão carregaconversation_idde propósito. Minor 2:task-5.2-report.mdtinha a formulação ERRADA sobre o caminhoforce_human/human-handoff.ts— dizia "pausa sem retomada". Corrigido: esse caminho nunca chamaemit_event, entãoai.handoff_triggerednunca dispara pra ele,reactToHandoffOpennunca roda, e o enrollment nunca é pausado — ele segue tickando normal (active/waiting_reply), só o ENVIO da mensagem é barrado (guard independente deforce_humanembefore-send.ts). É "nenhuma pausa acontece", não "pausa órfã". PROVA: node-handlers 36/36 (inalterado — este fix não mexe em node-handlers/engine), suíte unit completa 531/531, typecheck 0, lint 0 novo. DB-real:tests/invariants/followup-reactivity.test.ts12/12 (+1 sobre a versão anterior — o teste do Critical),followup-turn-bridge.test.ts+followup-engine.test.tssem regressão. 1 de 3 rodadas donpm run test:invariantsoficial teve 1 falha intermitente emfollowup-engine.test.ts(claimed2 em vez de 1) — DIAGNÓSTICO CORRIGIDO no fix seguinte (não é INBOX-001/gov-1b×gov-6, que é uma colisão de slug não-relacionada): era um defeito de ISOLAMENTO no PRÓPRIOfollowup-engine.test.tsdesta sessão (Task 4.1), resolvido no commit seguinte. Detalhe completo (seção "Fix de review") em.superpowers/sdd/task-5.2-report.md. Sem migration. -
2026-07-22: Task 5.2 — fix de review 2 (isolamento do due-queue global em
followup-engine.test.ts). Diagnóstico correto do flake intermitente acima (o coordenador identificou; a formulação anterior deste HANDOFF citando INBOX-001 estava ERRADA — corrigida no bloco acima):fn_claim_due_followup_enrollmentsé deliberadamente GLOBAL/cross-org (design de produção correto, SKIP LOCKED entre workers, provado na Task 1.2) — etests/invariants/**compartilha UM Postgres não-resetado entre arquivos (vitest.db.config.ts,fileParallelism:false).followup-engine.test.ts(autorado nesta sessão, Task 4.1) nunca limpava a fila de enrollments devidos entreits — uma linha residual de um teste-irmão (ex.: o wake defollowup-reactivity.test.ts, que setanext_eval_at=now()) podia ser fisgada pelorunFollowupTick({limit:5})de OUTRO teste que esperavaclaimed/advanced/jobs.lengthexatos, corrompendo as contagens agregadas. Fix:beforeEachemfollowup-engine.test.tsfazendodelete from followup_enrollmentsantes de CADAit(cada um já semeia sua própria org/enrollment DEPOIS do hook — seguro).followup_enrollment_events.enrollment_idtemon delete cascade(migration 0054, conferido no catálogo — nenhuma outra tabela referencia essa FK), então deletar sófollowup_enrollmentsjá limpa os eventos junto. TODAS as asserçõestoBe(1)/toHaveLength(1)(incluindo a de idempotência emjobs.lengthna linha ~378) ficaram EXATAMENTE como estavam — nenhuma foi relaxada, o fix resolve a causa raiz (contaminação cross-test), não o sintoma (afrouxar assert). PROVA (o ponto inteiro deste fix):npm run test:invariants -- followuprodado 3 vezes seguidas, síncrono, saída completa cada vez — 3/3 limpo: 38/38 arquivos, 226 passed | 1 skipped em TODAS as 3 rodadas, zero flake. node-handlers 36/36 (inalterado), unit completo 531/531, typecheck 0, lint 0. Detalhe completo em.superpowers/sdd/task-5.2-report.md. Sem migration. -
2026-07-22: Task 5.1 ✅ (commits ff97ccc/b164560/502f9ab/51cbfb8) — ponte engine ⇄ job_queue.
lib/followup/turn-bridge.ts(novo):completeTurnForEnrollment(db, orgId, enrollmentId, nodeId, result, clock?)traduz o resultado de um turno em progressão de enrollment — 'sent' avança viaselectEdge('always'), 'classified' viaclass_match(fallback 'always' embutido), 'timing' clampa[min_ms,max_ms]do wait smart pinado e reagenda sem sair do nó. Idempotente por${node_id}:${steps_taken}(mesma doutrina deapplyResult).TurnBridgeAdminClientestende oAdminClientdo engine comloadEnrollmentById— extensão ISOLADA (não noAdminClientdo engine.ts) pra não obrigar o adapter pg já aprovado emfollowup-engine.test.tsa ganhar método que não usa.createPgAdminClient(pool)é o adapter de produção real, usado tanto pela wiring (main.ts) quanto pelo teste DB-real (mesmo código, não duplicado).lib/agent-engine/agent/followup-turn.ts— MUDANÇA MÍNIMA guardada porif (payload.followup_enrollment_id !== undefined): 3purposeroteados (send_message rodarunAgentTurnnormal comprompt_hintanexado à abertura; classify/decide_timing NÃO rodam o agente inteiro — 1 chamada estruturada cada,lib/agent-engine/agent/followup-flow-classify.tsnovo, mesmo padrão destage-classifier.ts/guardrails/promise/semantic.ts). CallbackcompleteFollowupTurninjetado viaFollowupTurnDeps(=InboundTurnDeps+ o callback opcional); wiring real emworkers/agent-worker/main.ts(achado como o ÚNICO consumidor decreateFollowupTurnHandlerno repo — não existe rota Next.js pra isso, é o worker 24/7 que falapgpuro, nunca Supabase client).lib/followup/node-handlers.ts— fix de bug real do critério 2 da onda:ai_classifyem re-entrada (grace elapsed, checagemwaitElapsedreusada dewait) SEMPRE reenfileirava outro turno de classificação, mesmo depois dograce_timeout_msvencer sem resposta. Agora rotano_replyviaselectEdgesem chamar LLM. Desvios documentados do esboço do plano:completeTurnForEnrollmentganhouorgId(toda escrita é org-scoped) enodeId(guarda de obsolescência — turno tardio contra enrollment que já saiu do nó vira no-op, não reaplica sobre o nó errado); o payload doFollowupJobRequest(engine.ts) ganhouprompt_hint/classes/hint/guidanceopcionais (o plano não especificava esses campos, mas o turno precisa deles — extensão do lado followup, liberada pelo dispatch). Decisão deliberada de escopo: owait(modesmart) donode-handlers.tsCONTINUA usandomax_msdireto (não wired praenqueue_turn purpose:'decide_timing'nesta task) — investiguei e concluí que reusarresolveWaitPhase/waitElapsedpra essa transição tem uma ambiguidade real (uma recheck-tick de 5min enquanto o turno de decide_timing ainda não terminou colidiria com o sinal "já elapsed"), e o TDD explícito do dispatch não pedia essa mudança — só pedia quecompleteTurnForEnrollmenttrate 'timing' corretamente (o que está feito e testado, unit + DB-real). Fica registrado pra quem for wire isso: vai precisar de um sinal PRÓPRIO (nãoresolveWaitPhase) pra diferenciar "aguardando o turno terminar" de "wait de verdade elapsou". PROVA: node-handlers 34/34 (3 novos), turn-bridge unit 13/13, followup-flow-classify unit 8/8, suíte unit completa 529/529, typecheck 0, lint 0 novo (os 2 erros pré-existentes emgraph-schema.test.tsda Task 2.1 continuam intocados). DB-real: 37/37 arquivos de invariantes, 215 passed | 1 skipped (+1 arquivo, +11 testes sobre a baseline), install+update do baseline.sql sem erro novo empgvector/pgvector:pg17descartável.tests/invariants/followup-turn-bridge.test.tsprova contra Postgres real: ciclo action completo (enqueue→complete→advance) + dupla conclusão idempotente (23505 real) + classe exata→aresta + grace-sem-resposta→no_reply SEM reenfileirar (viarunFollowupTickreal, não só a função pura) + clamp do wait smart contra o grafo pinado. Sem migration nesta task. Detalhe completo em.superpowers/sdd/task-5.1-report.md. Pendente pra Task 5.2: reactivity.ts vai precisar de um sinal PRÓPRIO pra acordarai_classifycedo (inbound chegou) sem ser confundido com "grace expirou" pelo fix desta task (ambos re-entram o MESMO nó via a MESMA checagemwaitElapsedhoje). -
2026-07-22: Task 4.2 ✅ (commit
0571db0) — Onda 4 fechada.app/api/v1/cron/followup-flow-worker/route.ts(GET/POST, clone literal dorouting-worker: auth Bearer fail-closed,runFollowupTickviacreateSupabaseAdminClient— reusa o engine da 4.1,enqueueJobinsere emjob_queuekind=followup_turnjá existente, auditfollowup.worker_runagregada por tick) +app/api/v1/ai/followups/enrollments/route.ts(POST manager+ cria enrollment manual: pointer ativo→422flow_not_active, contato da org→404, resolve nótriggerdo grafo pinado, 23505→409, auditfollowup_enrollment.created; GET member lista com filtro?status=). PROVA AO VIVO (não só unit): 18 testes novos + 505/505 suite completa + typecheck/lint zerados; contranpm run dev(porta 3020) + DB remoto real (0054+0056), com cookie de sessão real (Playwright login) e secret real do.env.local: criei fluxo → PATCH grafotrigger→wait(5min fixo)→condition(steps_taken≥0)→end(exhausted)→ publish → criei contato real → enrollment (RBAC 403 pro agent, 409 em duplicata viva provados) → cron sem/com secret errado→403 (fail-closed provado) → 5 ticks reais (o worker processa 1 passo por chamada, não faz loop até estabilizar — ficou visível: trigger-advance e wait-start são 2 chamadas HTTP distintas) com sleep real de 5min+ em foreground entre o wait começar e elapsar (nada de manipular relógio) → enrollment fechoustatus=completed, outcome=exhausted. Confirmado direto no Postgres (admin client, não o mock do teste): 7 audit rowsfollowup.worker_runbatendo 1:1 com as 7 chamadas cron autenticadas (as 2 fail-closed corretamente não auditam), 3 audit rows das mutações (created/published/enrollment.created), 5followup_enrollment_events(1 por passo real do grafo). Dados de smoke limpos depois (audit ficou, append-only). Detalhe completo + transcript integral em.superpowers/sdd/task-4.2-report.md. Sem migration nesta task. -
2026-07-21: Task 4.1 — fix de review (1 Critical + 1 Important + 2 Minors, todos corrigidos). Critical:
markDeadgravavastatus='dead'ANTES do item de inbox — crash no meio deixava enrollment morto em silêncio. Invertido: inbox primeiro,updateEnrollment(status='dead')por último — pior caso agora é inbox duplicado (visível), nunca morte silenciosa; se a escrita de inbox falhar, o catch por-enrollment já existente segura e o enrollment continua claimable. Important: comentário documentando que falha transitória noenqueueJobpós-evento-commitado nunca reenvia (replay23505no retry) e converge sozinho pro horizonte normal. Minor:applyResultpassou a receberFlowNodede verdade em vez de um shape solto —grace_timeout_mssai da união discriminada, sem cast. Teste novo cobrindo o Critical (pgAdminClient({failInboxTimes:1}): 1ª falha não mata, 2ª tentativa mata + exatamente 1 inbox). PROVA: 31/31 unit node-handlers, 36/36 arquivos de invariantes (210/211, +1 teste novo sem regressão), typecheck 0, 487/487 unit completo. Detalhe em.superpowers/sdd/task-4.1-report.md(seção "Fix de review"). -
2026-07-21: Task 4.1 ✅ (commits
5570cddnode-handlers,53c629amigration 0057,1eb2322engine).lib/followup/node-handlers.ts(processNode/selectEdge/resolveWaitPhase, puro) +lib/followup/engine.ts(runFollowupTick, AdminClient próprio — ver Decisões) + migration 0057 (agent_inbox_items.kindganhafollowup_dead). 2 desvios de contrato documentados no report (outcome nullable no NodeResult 'complete'; waitElapsed como campo opcional extra em processNode) — ambos aditivos, brief tinha texto contraditório entre o fence e a semântica. PROVA: 31/31 unit (node-handlers), 5/5 DB real (tests/invariants/followup-engine.test.ts— tick 1-nó-por-tick, ciclo de wait start→elapse, idempotência sob replay sem duplicar job, backoff 3 rodadas até dead+inbox, max_steps), suíte completa 487/487 unit + 209/210 invariantes (36/36 arquivos) sem regressão, typecheck 0, lint 0 nos arquivos tocados (2 erros pré-existentes emgraph-schema.test.tsda Task 2.1, não tocado). TDD real: 1ª rodada do DB test pegou 2 bugs genuínos (seed reusando$2entre colunacitextetext;markDeadnão persistia oattemptsfinal — backoff morria com contador errado). Detalhe completo em.superpowers/sdd/task-4.1-report.md. Pendente: nenhuma rota de cron consomerunFollowupTickainda;ai_classify/actionsó enfileiram (Task 5 resolve o nó);wait smartclampado emmax_ms(// onda 5). -
2026-07-21: Onda 3 ✅ — Task 3.1 (
dad6a7d+ fix83a9645+ minors09073c8, Approved na re-review). 5 rotas/api/v1/ai/followup-flows(CRUD, publish, disable, rollback), RBAC manager+/member, audit 5 ações, Zod strict. Review 1º round: 2 Importants → fix com migration 0056 (pointer_id + backfill genérico +fn_publish_followup_flow_versionRPC atômica com FOR UPDATE; revoke anon/authenticated — NÃO copiou o furo do templatefn_publish_ai_agent_version, que tem GRANT p/ anon: flag de segurança pré-existente p/ Rafael). Rollback valida linhagem + preserva status. PROVA: 24/24 API tests, 456/456 unit, invariantes 204/204, baseline 0056 fresh+update no pg17 descartável, transcript curl real de 13 passos com cookies de auth reais + 7 linhas de audit confirmadas no banco (detalhe no task-3.1-report.md). 0056 aplicada no dev DB (coluna+função confirmadas). Colisão de numeração 0055 pega pelo hook → renumerada 0056. -
2026-07-21: Onda 2 ✅ — Task 2.1 (
e866497, Approved):lib/followup/graph-schema.ts, 80 testes unit, contrato exato dos 6 nós/arestas/grafo. Task 2.2 (9a27c2b+ fixea5a746+57e914e, Approved na re-review):lib/followup/validate-publish.ts, 17 testes. Review pegou 2 Criticals reais no 1º round (validador aceitava sub-ciclo sem espera dentro de SCC com espera; DFS com cap de 200k truncava silenciosamente em DAG ramificado ≤60 nós) → corrigidos com algoritmos EXATOS: remoção de nós-de-espera + Tarjan (ciclo) e condensação SCC + longest-path topológico O(V+E) (24h/max_steps). Regressões: contra-exemplo A/B/C, boundary 30/31, diamond-DAG 58 nós <1s. PROVA: 17/17 focados, suite unit 432/432, typecheck 0. Nota p/ Onda 6 (UI):labelde nó ≤60 chars; grafo 2..60 nós, 120 arestas. -
2026-07-21: Task 1.2 ✅ (commit
298dd24, review Approved).tests/invariants/followup-schema.test.ts— 12 testes: RLS 2-tenants nas 4 tabelas (via JWT-scoped client real), unique enrollment vivo (23505 + libera após completed), unique idempotency_key (23505), CHECK active sem next_eval_at (23514), claim concorrente disjunto (2 conexões pg reais, união=5 interseção=0). PROVA (1ª mão do controller):npm run test:invariants→ 35 arquivos, 204 passed | 1 skipped, exit 0. RED provado derrubando as 3 constraints (3/12 falham). -
2026-07-21: Bugs operacionais da sessão: (1) Docker daemon caiu no meio de um run → exit 255 mascarado; reiniciado + órfãos limpos; (2) implementer stallou 3x "aguardando watcher" (não acorda sozinho) → doutrina nova: dispatch exige poll síncrono, controller vigia PID e retoma. (3) Flake pré-existente descoberto: gov-1b × gov-6 colidem slug
gov-inv-b(order-dependent).tests/invariants/**congelado → escalado comoINBOX-001emloop/inbox.items.mdcom fix pronto de 1 linha. AVISAR RAFAEL. -
2026-07-21: Task 1.1 ✅ (commit
9363db9, review Approved). Migration 0054 (4 tabelas + RLS +fn_claim_due_followup_enrollmentsSKIP LOCKED) + apêndice baseline + MANIFEST. PROVA: fresh installON_ERROR_STOP=1+ update re-apply verdes empgvector/pgvector:pg17descartável;\dt followup*= 4 tabelas nas duas passadas; suite completa de invariantes 34/34 arquivos, 192 testes verdes. Detalhe no report.superpowers/sdd/task-1.1-report.md. Bug operacional: Docker daemon estava parado → controller iniciou Docker Desktop e retomou o implementer. -
2026-07-21: Onda 0 iniciada. HANDOFF antigo (webhooks) arquivado em
docs/superpowers/handoffs/. Worktree + branch criados. Spec, plano e mineração commitados na base (b1202ca,790546f).
Bugs encontrados / corrigidos
(nenhum ainda)
Provas registradas
(nenhuma ainda — a primeira será o test:invariants da Onda 1)