Web — parte 1 (desde RN-039)
Painel de projetos
As RNs de orçamento, metering e teto de custo moram em Regras de negócio — Custo.
Mesmo motivo da seção de Autenticação abaixo: tamanho, não assunto.
Âncoras #rn-NNN inalteradas.
Cost
RN-039 — Dot de status do projeto: risco sempre vence inatividade
A sidebar do dashboard mostra um dot de saúde por projeto, derivado de três
sinais independentes: orçamento (mesmos limiares 70%/90% do RN-018), task
bloqueada (tasks.blocked) e atividade recente (7 dias). Cores: verde =
saudável e ativo; âmbar = orçamento ≥70%; vermelho = orçamento ≥90% ou
task bloqueada; cinza = sem atividade nos últimos 7 dias. Quando um sinal
de risco (âmbar/vermelho) e o de inatividade (cinza) se aplicam ao mesmo
tempo, o de risco vence — um projeto estourado e parado ainda é algo a
olhar, não algo a esconder atrás de "sem atividade".
- Onde:
apps/web/src/lib/project-status.ts(deriveProjectStatus) - Teste:
apps/web/src/lib/project-status.test.ts - Origem: fidelidade do dashboard de projetos ao design aprovado
RN-064 — Heartbeat não encerra sessão com trabalho pendente
Estendida pela RN-581 (AT-072): um agente conversacional esperando o usuário passou a ser o QUINTO sinal, e o único com teto (8h, causa
conversation_idle_timeout); a resposta ganhouaguardandoUsuarioDesde. O resto desta regra vale como está.
O timeout de heartbeat mede inatividade da aba, não do trabalho. Antes
de encerrar, o SessionServer pergunta à api se sobrou trabalho
(GET /internal/sessions/:id/pending-work); havendo, reagenda o timeout e
registra o motivo no log em vez de matar a sessão.
O default são 30 segundos. Sair da sessão para a aba de Backlog já bastava
para matá-la — e numa execução real isso prendeu um handoff offered para o
Arquiteto dentro de uma sessão fechada: épico e quatro histórias prontos, e a
cadeia sem como seguir, porque não existe onde aceitar handoff de sessão morta.
Fechar sessão é sobre o trabalho ter acabado, não sobre quem está olhando.
A api fora do ar NÃO impede o encerramento: {:error, _} encerra assim
mesmo, com aviso no log. Trocar sessão órfã por sessão imortal seria trocar um
defeito por outro.
"Trabalho pendente" são três sinais — o segundo entrou pelo achado V, o terceiro pelo bug real do Criativo→PO→Arquiteto:
- handoff
offered— o caso original acima; proposed_actioncom statuspending— alguém está esperando a SUA decisão, e um agente pode estar suspenso esperando o desfecho (RN-073);- agente ATIVADO ainda em turno — o último
agent.statusde cada ator que já falou na sessão éworkingsem umidleposterior.
O segundo é o mesmo defeito do primeiro um nível abaixo, e a execução do
hello-limpo mostrou o custo: a sessão nasceu 23:34:12, uma ação ficou
pending às 23:34:13, e o heartbeat a fechou às 23:34:42 — exatamente os 30s
do timeout. O dev agent seguiu trabalhando por mais de uma hora numa sessão
que o banco dava por encerrada, e isso envenena toda métrica por sessão:
duração, custo e "quantas terminaram bem" passam a ler um estado que não
descreve o que houve.
O terceiro é a mesma janela, um passo antes de qualquer um dos dois primeiros
existir: AcceptHandoffUseCase marca o handoff antigo como accepted e ativa
o próximo agente na hora, mas a ativação no engine é GenServer.cast
fire-and-forget — responde 201 antes de o agente sequer começar. O PO ativado
pelo handoff do Criativo roda um kickoff de até 12 iterações de LLM usando só
ferramentas category: :direct (create_epic/create_story/create_task),
que nunca geram proposed_action, e só oferece o handoff seguinte (o sinal 1)
no FIM do turno inteiro. Entre a ativação e esse fim, nem o sinal 1 nem o sinal
2 existiam — só o ping do canal Phoenix a cada 10s segurava a sessão, e
qualquer atraso maior que os 30s do timeout fechava a sessão com o PO ainda
gerando o backlog, quebrando a cadeia de handoff pela raiz: o handoff seguinte
acabava sendo oferecido numa sessão já closed, que não aceita mais nada.
agent.status (working/idle) é o que todo agente conversacional
(Criativo/PO/Arquiteto/Dev Lead/Infra) já narra nos limites de turno, e é
PERSISTIDO no event log, não só broadcastado no canal
(Engine.Sessions.LiveBroadcast.agent_status/4, ADR 0021)
— o mesmo sinal que o painel do time já lê para derivar o roster
(conversationalStatus em apps/web/src/lib/agent-status.ts). Reaproveitá-lo
aqui não exigiu evento novo nenhum: o terceiro sinal é o último agent.status
de CADA ator que já falou na sessão, e é genérico por tipo de evento — cobre
qualquer agente ativado por handoff, não só o PO.
A versão anterior desta regra dizia, por escrito, que incluir trabalho de agente "sem um teste que prove a interação seria adivinhar". A execução produziu a prova, e o teste agora existe.
O que continua fora: task in_progress sem ação pendente nem handoff nem
turno em aberto. O dev agent tem máquina de estados própria e retém o worktree
por conta dele; o sinal que a api possui e que a execução comprovou é a ação
pendente. Incluir a task exigiria a api ler dev_agent_states, que é do
engine — decisão de fronteira, não conserto de passagem. Os dev agents também
não emitem agent.status (rodam com máquina de estados própria, não com o
loop conversacional de turno) — o terceiro sinal não os cobre, e não precisa:
a ação pendente já cobre o caminho deles.
- Onde:
apps/api/src/application/use-cases/sessions/get-session-pending-work.use-case.ts,apps/api/src/application/ports/session-event-repository.port.ts(listByTypeInSession),apps/engine/lib/engine/sessions/session_server.ex(handle_info(:heartbeat_timeout, …)) - Teste:
apps/engine/test/engine/sessions/session_lifecycle_test.exs(heartbeat NÃO encerra sessão com trabalho pendentee o caso oposto) eapps/api/test/application/use-cases/sessions/get-session-pending-work.use-case.spec.ts(os três sinais, a ação já decidida que NÃO segura, oidleque libera, o isolamento por ator, a genericidade por tipo de agente e o escopo por sessão) - Origem: execução real da FASE 13b; achado V, Fase H do backlog; bug real do encadeamento Criativo→PO→Arquiteto
RN-088 — Falha de carregamento é dita na tela; 429 não se retenta
Toda tela que carrega dado da api distingue três estados, e nunca dois:
carregando (esqueleto), erro (a mensagem que a api mandou, com o
trace_id e um botão de tentar de novo) e vazio (o texto de lista vazia).
if (!dado) return null colapsa os três, e o desfecho observado foi o pior
possível: com a api limitando por 429, a área principal de /projects/:id
ficava completamente em branco — sem mensagem, sem erro, sem esqueleto — e
o motivo existia só no console. No dashboard era pior que branco: !projects
também era verdadeiro no erro, então a tela convidava a criar o primeiro
projeto de um workspace que podia ter vinte.
É a RN-059 do outro lado do fio: falha nunca vira resposta vazia. Quem falha, diz.
A frase é a da api, extraída por mensagemDaApi — a mesma função que o
caminho de mutação já usava nos toasts. Um texto genérico nosso não sabe a
diferença entre "limite de requisições excedido, tente em instantes" (espere) e
"acesso negado" (não adianta esperar).
O outro lado da mesma regra é não responder ao limite com mais tráfego. 4xx
não se retenta: 429 é literalmente o servidor mandando parar, e 401 já
foi renovado uma vez dentro de request(). E poll para quando a query erra
— volta no foco da janela, na remontagem ou no botão. Antes eram três
retentativas por falha somadas a ~25 polls de 3 a 5 segundos que não sabiam
parar: uma sessão real acumulou 1128 erros 429 contra um limite de 300
requisições por minuto por usuário (RateLimitGuard), e o laço impedia a
janela deslizante de se refazer. 5xx e falha de rede continuam com as três
tentativas, onde repetir é a reação certa.
- Onde:
apps/web/src/lib/query-policy.ts(deveRetentar,pollQueParaNoErro),apps/web/src/components/ErroDeCarregamento.tsx,apps/web/src/routes/ProjectPage.tsx,apps/web/src/routes/Dashboard.tsx,apps/web/src/routes/Shell.tsx,apps/web/src/main.tsx - Teste:
apps/web/src/lib/query-policy.test.ts;apps/web/src/routes/ProjectPage.test.tsx(429 vira mensagem com a frase da api; 403 mostra o motivo do 403; carregando não é erro);apps/web/src/routes/Dashboard.test.tsx(erro na lista não vira "nenhum projeto ainda");apps/web/src/routes/Shell.test.tsx(a sidebar diz em vez de ficar vazia) - Origem: navegação real em
/projects/:idcom 1128 erros 429 no console
RN-089 — Projeto de nome repetido se desempata na barra lateral
Nome de projeto não é único — nada no domínio impede. Quando dois ou mais
projetos da lista têm o mesmo nome, cada um deles mostra na sidebar o id
abreviado e a data de criação (#a1b2c3d4 · 07/08 14:32), os dois já presentes
no payload do projeto. Quem tem nome único não mostra nada: a legenda em toda
linha seria ruído no lugar com menos espaço da tela.
Origem concreta: uma execução de validação criou vinte projetos chamados
validacao-real, e as vinte linhas da sidebar eram visualmente idênticas.
- Onde:
apps/web/src/lib/project-label.ts(nomesRepetidos,desempateDoProjeto),apps/web/src/routes/Shell.tsx - Teste:
apps/web/src/routes/Shell.test.tsx(nome repetido ganha desempate, nome único não) - Origem: navegação real com a sidebar cheia de projetos de validação
Session
RN-098 — O nome da sessão soma à hashtag, nunca a substitui
A sessão pode receber um nome amigável (sessions.name, opcional), na
criação ou depois, por PATCH /projects/:projectId/sessions/:sessionId. O
rótulo mostrado na tela é composto: <nome> · #<8 primeiros caracteres do id>. Sem nome, ele degrada para a hashtag sozinha.
A hashtag nunca some, e o motivo é operacional: é ela que se cola numa URL,
num comando ou numa conversa, e um nome escolhido por uma pessoa não é
único — duas sessões chamadas "Checkout" são normais. Nome em branco (ou só
espaços) conta como ausência, tanto na criação quanto ao renomear:
gravar '' transformaria o rótulo em " · #a1b2c3d4", pior que a hashtag
sozinha. null no corpo do PATCH é o jeito de desfazer.
Renomear não é evento de sessão. O log de eventos é o que a sessão viveu, e o nome é um rótulo de navegação, trocado quantas vezes se quiser — N eventos de renomeação empurrariam justamente o que importa para fora da cauda de 200 eventos.
- Onde:
apps/web/src/lib/session-label.ts:61(rotuloDaSessao),apps/api/src/application/ports/session-repository.port.ts:52(rename). Alcançável tanto de dentro da sessão (apps/web/src/routes/SessionPage.tsx:439,handleRename) quanto da lista do projeto, sem precisar abrir a sessão antes (apps/web/src/routes/ProjectSessionsTab.tsx:101,handleRenomear) — as duas telas chamam o mesmorenameSessione o mesmorotuloDaSessao. - Teste:
apps/web/src/lib/session-label.test.ts,apps/api/test/application/use-cases/sessions/session-kind-e-nome.spec.ts,apps/web/src/routes/ProjectSessionsTab.test.tsx - Origem: ADR 0061
Painel de projetos
RN-099 — Atividades pagina o passado sem repolar o que não muda
A coluna de Atividade da Visão geral mostra uma janela de 100 eventos
ancorada na cauda da sessão, e desce no passado de página em página. Cada
página usa o afterSeq/nextCursor que
GET /projects/:p/sessions/:s/events já devolve: nenhuma rota nova,
nenhum parâmetro novo.
Duas perguntas, duas queries. useSessionEvents responde "como está
agora" — os últimos 200 em poll — e alimenta o painel do time, a linha do
tempo em árvore, a seção de execução e a aba de Aprovações. Nenhuma delas
pagina. A de Atividades responde "o que aconteceu", e era a única que fazia
essa pergunta com a resposta da outra: 200 itens de uma vez, sem começo nem
fim, e ainda assim sem alcançar o início de uma sessão longa.
A âncora é a cauda, não o começo da sessão. O endpoint pagina para
FRENTE, e não existe beforeSeq — inventar um seria contrato novo. Mas abrir
o feed no evento nº 1 de uma sessão de milhares entrega a tela errada: quem
abre Atividades quer o que acabou de acontecer. Então a primeira página é a
mesma leitura latest que a tela já faz, com a mesma queryKey (o React
Query serve as duas com UMA busca), e cada clique desce uma janela fixa com
afterSeq.
O que a paginação não pode custar. Uma requisição por ciclo de poll — a mesma de antes, e a economia da RN-090/RN-091 não regride. Três peças garantem isso:
- a cauda é a query que a tela já tinha, compartilhada por
queryKey; - o primeiro clique não vira requisição: a leitura
latesttraz 200 e a janela mostra 100, então revelar o que já se pagou vem antes de pedir de novo; - página antiga é uma janela fechada de
seqsobre eventos imutáveis (nunca há UPDATE em tabela de evento):staleTime: Infinitye nenhumrefetchInterval. Um poll ali seria pagar N requisições para receber N respostas idênticas.
A contagem é honesta sobre o que ela sabe. Os filtros por agente e por
tipo rodam sobre a página carregada, não sobre a sessão — então a UI diz
N de M carregados, e não "N resultados". Levar o filtro ao servidor daria o
total verdadeiro e mexeria no repositório de eventos; não é desta fase, e
dizer o total errado seria pior que dizer menos.
Os três estados da RN-088 valem aqui: erro antes de vazio — a coluna dizia "nenhuma atividade" quando a api tinha recusado.
- Onde:
apps/web/src/lib/hooks.ts(useSessionEventHistory,EVENTOS_POR_PAGINA),apps/web/src/components/ActivityFeed.tsx,apps/web/src/routes/ProjectOverviewTab.tsx,apps/api/src/infrastructure/persistence/drizzle/session-event.repository.ts(listPaginated, já existente) - Teste:
apps/web/src/lib/session-history.test.tsx(abre na cauda; o primeiro clique não custa requisição; o seguinte pagina comafterSeqcontíguo; o laço termina no começo da sessão; montado junto com o estado atual a cauda é UMA busca; página antiga não repolla);apps/web/src/components/ActivityFeed.test.tsx(o "N de M carregados" acompanha o filtro; sem as props de paginação nada muda) - Origem: pedido do usuário na primeira navegação real — "Atividades na aba Visão Geral deve ser paginada, para não ficar infinita"
Session
RN-104 — A aba deriva do tipo gravado, e cria nesse tipo
O projeto tem duas abas de sessão — Criativo e Chat — e cada
uma é um kind: lista as sessões cujo sessions.kind é o seu, e o
CTA dela cria nesse kind, sem perguntar de novo. Não há terceira
aba listando as duas juntas.
O tipo já era imutável (RN-097), e é isso que o torna
uma coordenada de navegação legítima em vez de um filtro salvo: uma sessão nunca
troca de aba. A aba lê o campo gravado — não deriva o
tipo de evento nenhum, pelo mesmo motivo da RN-097: execution.activated
classifica ESTADO de execução, e uma aba que olhasse para ele mudaria
sozinha.
Duas consequências que a regra fixa, e que não são detalhe de tela:
- A escolha do tipo sai do formulário. Ela morava num
fieldsetde radio buttons, e agora acontece no momento em que a pessoa clica na aba. Manter os dois ofereceria a chance de criar uma sessão que contradiz o lugar onde se está. O que sobra no formulário é o nome, que segue opcional (RN-098). - Uma ação, um lugar de cada vez. "Iniciar ideação" continua existindo — é o que traz o agente Criativo para a sessão, e dali em diante a chave do dono começa a ser gasta (RN-058); ninguém entra sozinho. O que mudou é onde ela mora: dentro do convite enquanto o convite está na tela, e na topbar quando não está. O convite APONTAVA para a topbar ("use Iniciar ideação, no topo da tela"), que é a versão literal do problema que originou a RN-097 — a ação num lugar e a explicação em outro. A topbar não pode simplesmente perder o botão: dá para digitar numa sessão criativa sem nunca ter chamado o agente Criativo, e aí o convite sai de cena.
As três telas honram os três estados da RN-088, com erro antes de vazio: a lista filtrada fica vazia nos dois casos, e dizer "nenhuma ideação ainda" depois de um 429 seria mentir sobre o que aconteceu.
A chave de deep link do Chat continua sessions, não chat. É isso que faz um
link antigo salvo com ?tab=sessions abrir o Chat com a aba marcada na
barra de abas — resolver a chave antiga como alias só no painel deixaria
a barra de abas sem seleção nenhuma, porque Tabs compara active
com key, e quem escreve active recebe a chave crua do
validateSearch.
- Onde:
apps/web/src/routes/project-tabs.ts:95(as duas entradas),apps/web/src/routes/ProjectSessionsTab.tsx:114(o filtro pelokindgravado) e:98(o CTA criando nokindda aba),apps/web/src/routes/SessionPage.tsx:715(conviteVisivel, a única pergunta que a topbar e o convite compartilham) - Teste:
apps/web/src/routes/ProjectSessionsTab.test.tsx,apps/web/src/routes/project-tabs.test.tsx,apps/web/src/routes/SessionPage.sessao.test.tsx - Caso de borda: projeto sem sessão daquele tipo mostra o estado vazio DAQUELE TIPO, mesmo quando tem sessões do outro tipo — é informação, não erro.
- Origem: ADR 0061
RN-107 — A aba Code tem um QUARTO estado: bloqueada por decisão pendente
Os três estados da RN-088 (carregando/erro/vazio) não descrevem
sem_decisao (RN-105): não é carregando (a api já respondeu), não é
erro (ela respondeu CERTO) e não é vazio (não falta dado — falta uma DECISÃO,
que é outra coisa). Tratar sem_decisao como "vazio" mostraria um editor sem
arquivos, convidando a pensar que o repositório está vazio; tratar como "erro"
faria a tela sugerir "tentar de novo" para algo que só o Arquiteto resolve.
A aba pergunta o estado do container ANTES de tentar ler código
(GET /projects/:id/container), em vez de esperar a primeira árvore ou
arquivo devolver 409 — a mesma checagem que a api já faz no funil de
ReadProjectCodeUseCase (RN-105), só que perguntada primeiro, para o quarto
estado nascer como mensagem própria e não como o rodapé de um erro genérico.
Enquanto bloqueada, a tela reconsulta sozinha a cada 15s — depois de decidida
a imagem não muda sem ação humana nova, e ficar reconsultando um estado
estável seria a mesma família de tráfego desnecessário da PÓS-FASE 15.
A apresentação do quarto estado foi EXTRAÍDA para ContainerImageGateNotice
(apps/web/src/components/ContainerImageGate.tsx) — achado de uso: a aba PRs
(apps/web/src/routes/code/PrListAndDiff.tsx, consumida por
ProjectPrsTab.tsx e por CodeDiffPanel.tsx) chama getCodePullRequests/
getCodeDiff, que passam pelo MESMO funil (RN-105) e podem devolver o MESMO
409 — mas, ao contrário desta aba, sem perguntar antes. Ela mostrava esse 409
no banner de erro genérico com "Tentar de novo", a afordância errada para um
estado que só o Arquiteto resolve. isContainerImageGateError
(apps/web/src/lib/api-client.ts) identifica a causa pelo status === 409
— única causa de ConflictException em ReadProjectCodeUseCase.alvo — e
PrListAndDiff troca o banner por ContainerImageGateNotice quando ela bate,
sem pré-checagem própria (reage ao 409 da query que já ia rodar).
- Onde:
apps/web/src/routes/ProjectCodeTab.tsx,apps/web/src/routes/ProjectCodeTab.module.css,apps/web/src/components/ContainerImageGate.tsx(apresentação compartilhada),apps/web/src/routes/code/PrListAndDiff.tsx(consumidor reativo ao 409),apps/web/src/lib/api-client.ts(isContainerImageGateError) - Teste:
apps/web/src/routes/ProjectCodeTab.test.tsx("o gate"),apps/web/src/routes/code/CodeDiffPanel.test.tsx,apps/web/src/routes/ProjectPrsTab.test.tsx("o gate do container não é erro genérico") - Borda: a checagem no front NÃO substitui a da api — é conveniência de UX. Se a api mudar de estado entre a consulta do gate e a leitura de verdade, a rota de leitura ainda recusa com 409 (RN-105); o front só evita o caso comum de mostrar o editor vazio por um instante. A aba PRs não faz pré-checagem: ela descobre o bloqueio quando a query já falhou, porque não há árvore/arquivo nenhum ali para o instante "vazio" que a pré-checagem da aba Code evita.
- Origem: ADR 0065
RN-108 — O socket da sessão exige um ticket opaco de uso único, não o JWT reaproveitado
EngineWeb.SessionSocket.connect/3 recusava a conexão inteira só com o
session_id (UUID) precisando existir no Registry — quem descobrisse o UUID
entrava no canal session:<id> e recebia todos os broadcasts ao vivo da
sessão. Fechar isso era limitação deliberada documentada no próprio módulo
desde a Fase 3.
POST /projects/:projectId/sessions/:sessionId/socket-ticket (scope: "heartbeat"|"terminal") emite um ticket opaco (32 bytes de CSPRNG,
TokenFactory), TTL de 30 segundos, uso único. scope: "heartbeat" exige
papel viewer; scope: "terminal" exige developer — o mesmo papel mínimo
de MIN_ROLE_FOR_ACTION_TYPE.terminal em domain/actions/decide.ts (hoje
nenhum caminho pede terminal de verdade; o valor nasce certo para a FASE 25,
o terminal interativo). A api persiste só o HASH (SHA-256 puro, não
hashDeToken/HMAC — o engine não tem o pepper da api, e um token de 256 bits
de CSPRNG não precisa de pepper contra dicionário, mesmo raciocínio que o
próprio hashDeToken já registra), nunca o token bruto.
O consumo é do ENGINE, que lê session_socket_tickets direto (mesmo padrão de
Engine.Outbox.Event sobre outbox_events — nunca changeset/insert, só a
escrita estreita que o uso único exige) em DUAS etapas:
SocketTicket.validar/1 (peek, sem marcar nada — chamado por connect/3,
que ainda não sabe qual session_id vai ser pedido) e
SocketTicket.consumir/2 (UPDATE condicional exigindo o session_id do
tópico bater com o da linha — chamado por SessionChannel.join/3, que
também confere o project_id do ticket contra o da sessão, defesa em
profundidade contra ticket de um projeto abrindo canal de outro). Sem ticket,
ou com um inválido: a conexão inteira é recusada ({:error, %{reason: "unauthorized"}}), não só o join do canal.
O web (session-channel.ts) busca um ticket NOVO antes de TODA
socket.connect() — inclusive em reconexão automática, que existe. O
reconnect nativo do Phoenix.Socket reusaria o mesmo params da construção
(o ticket velho, já expirado ou consumido), então ele é neutralizado
(reconnectAfterMs que praticamente nunca dispara) e a reconexão passa a ser
inteiramente manual, com busca de ticket fresco a cada tentativa.
Essa neutralização vale para TODO cliente do socket, e não só para o do web —
inclusive o socket /runner, cujo ticket (POST .../runner-ticket) é do
mesmo calibre. Ela falhou em DOIS dos quatro construtores de Socket do
repositório, e o custo foi medido em execução real: apps/runner/src/ channel.ts construía o socket sem a opção — enquanto o docblock do módulo
AFIRMAVA que o reconnect era "por isso neutralizado" — e o auto-reconnect
repetia o mesmo ticket já consumido a cada ~5,13s (o teto do backoff
interno do phoenix.js), rendendo 61 REFUSED CONNECTION TO EngineWeb.RunnerSocket em poucas horas. Somado à política PRÓPRIA do
runner (index.ts, essa correta), o resultado foram 530 requisições num
minuto contra o teto de 300 do RATE_LIMIT_USER — e como o limite é por
USUÁRIO, o 429 aparecia no NAVEGADOR do dono da conta. Duas consequências, e
a segunda é pior que o 429: enquanto está nesse laço o runner NÃO está
conectado, então RunnerReadiness (RN-507) recusa e o projeto parece "sem
agente local". apps/web/src/lib/fs-browser-channel.ts tinha a MESMA linha
de defeito (mesmo ticket de uso único, e sem política própria de reconexão —
o onClose dele manda "feche e reabra") e foi corrigido junto.
O que impede a terceira instância é MECANISMO, não comentário: no runner as
opções do Socket viraram um tipo com reconnectAfterMs OBRIGATÓRIO
(OpcoesDoSocket em channel.ts), e os dois lados ganharam teste que assere
a OPÇÃO PASSADA ao construtor — um teste que só verifique "conecta" passava
com o defeito de pé, e passou. Junto, conectarERodar (index.ts) passou a
DESCARTAR explicitamente a conexão caída (conexao.desconectar()): sem isso
o Socket da rodada anterior sobrevivia à criação do próximo, e cada queda
deixava mais um objeto vivo tentando com o ticket morto.
- Onde:
apps/api/src/db/schema/sessions.ts(sessionSocketTickets),apps/api/src/domain/sessions/socket-ticket-scope.ts,apps/api/src/application/use-cases/sessions/create-socket-ticket.use-case.ts,apps/api/src/interfaces/http/sessions/sessions.controller.ts(rotasocket-ticket),apps/engine/lib/engine/sessions/socket_ticket.ex,apps/engine/lib/engine_web/channels/session_socket.ex,apps/engine/lib/engine_web/channels/session_channel.ex,apps/web/src/lib/session-channel.ts,apps/web/src/lib/fs-browser-channel.ts,apps/runner/src/channel.ts(OpcoesDoSocket,NUNCA_RECONECTAR_SOZINHO_MS),apps/runner/src/index.ts(conectarERodar, o descarte da conexão caída) - Teste:
apps/api/test/application/use-cases/sessions/create-socket-ticket.use-case.spec.ts,apps/api/test/domain/sessions/socket-ticket-scope.spec.ts,apps/api/test/infrastructure/persistence/session-socket-ticket.repository.spec.ts,apps/engine/test/engine/sessions/socket_ticket_test.exs(reuso falha, session_id errado falha, corrida concorrente só um vence),apps/engine/test/engine_web/channels/session_socket_test.exs(sem ticket a conexão é recusada),apps/engine/test/engine_web/channels/session_channel_test.exs(ticket de outro projeto: join falha),apps/web/src/lib/session-channel.test.ts,apps/runner/src/channel.spec.ts("neutraliza o auto-reconnect do Phoenix.Socket — ticket de uso único nunca é repetido"; assere a OPÇÃO passada ao construtor, e reprova sem ela),apps/web/src/lib/fs-browser-channel.test.ts(o mesmo, no transporte via runner do navegador de pastas) - Borda: o ticket NÃO é o JWT reaproveitado — TTL curto, uso único, escopo fechado, e nasce de uma rota própria que já checa papel efetivo, não de decodificar o access token existente.
- Origem: sem ADR — extração/hardening pontual, não mudança estrutural.
RN-113 — Blame no editor é anotação SOB DEMANDA — um toggle, nunca embutida na leitura do arquivo
A UI que consome a fundação da RN-110 entra aqui: o editor da aba
Code (CodeEditor.tsx) só chama getCodeBlame quando o usuário liga o toggle
"Blame" — nunca junto da leitura de arquivo, que já dispara sozinha ao abrir
uma aba. O motivo é o mesmo dos orçamentos de leitura composta (ADR 0060):
blame é uma SEGUNDA chamada ao provider por arquivo aberto, e um arquivo perto
do teto (GIT_BLAME_LINE_LIMIT, 2000 linhas) já é caro o bastante para não
pagá-lo de graça em toda navegação. truncated (que a RN-110 já expõe) vira
aviso visível, no mesmo padrão do aviso de fileQuery.data.truncated.
Linhas consecutivas do MESMO commit mostram autor e sha curto só na PRIMEIRA
linha do bloco — repetir o mesmo texto em cada linha de um bloco de dezenas
de linhas seria ruído, não anotação; a linha só some do texto, nunca some da
anotação (o title do elemento continua com data completa e resumo do
commit em qualquer linha do bloco).
- Onde:
apps/web/src/routes/code/CodeEditor.tsx,apps/web/src/routes/code/CodeEditor.module.css - Teste:
apps/web/src/routes/code/CodeEditor.test.tsx - Origem: onda de UI da FASE 26b (blame — dropdown rico de branches e lista de PRs são UI de outros dois agentes, sem risco de colisão)
Painel de projetos
RN-118 — A árvore do time expõe detalhe de execução por marco, e abre os "5 últimos"
AgentTimelineTree/timeline-tree.ts (FASE 14b) já agrupava o event log por
agente, mas com três lacunas: o critério de abrir um ramo era só
ativo/parado (sem relação com quantos agentes existiam), o contador do
cabeçalho era o TOTAL de marcos (nunca "o que é novo"), e tool.call/
tool.result/agent.response viravam marco mostrando só o nome da
ferramenta — args, result e iteration, que o event log já grava,
nunca chegavam na tela.
Abertura padrão passa a ser "os 5 agentes de atividade mais recente
(maior seq do último marco)", com ativo mantendo prioridade sobre
recência — não se soma: como montarArvore já ordena ativos primeiro e,
dentro de cada grupo, do mais recente pro mais antigo, os dois critérios
colapsam numa fatia só (ramosAbertosPorPadrao, os primeiros
max(nº de ativos, 5) ramos). Mais de 5 ativos: todos abrem, sem corte.
Contador de novidade por agente usa o MESMO mecanismo do sino
(read-state.ts, localStorage), granular por projectId:agentId em vez
de só projectId — não existia "último visto" nessa granularidade antes.
Ramo colapsado com marco de seq maior que o último visto daquele agente
mostra a contagem de NOVIDADE no lugar do total; abrir o ramo (automático
ou manual) marca como visto até o seq mais recente.
Detalhe expansível é por MARCO, não por ramo — só tool.call,
tool.result e agent.response (EVENTOS_EXPANSIVEIS) viram botão
individual, porque só eles carregam payload que vale expandir. O
agrupamento visual por ITERAÇÃO usa iteration do payload de
agent.response quando existe (ToolLoop — tool.call/tool.result
herdam a iteração da resposta que os despachou, porque no ToolLoop eles
são emitidos DEPOIS dela); agentes fora do ToolLoop (PO, Criativo, sem
iteration no payload) ganham um contador PRÓPRIO por agente, incrementado
a cada resposta — inferência por proximidade de seq, não um campo novo no
event log.
- Onde:
apps/web/src/lib/timeline-tree.ts(Marco.iteracao,Marco.eventType,Marco.payload,ramosAbertosPorPadrao,marcoExpansivel),apps/web/src/components/AgentTimelineTree.tsx,apps/web/src/lib/read-state.ts(getAgentLastSeenSeq/setAgentLastSeenSeq) - Teste:
apps/web/src/lib/timeline-tree.test.ts(agrupamento por iteração real e inferida,ramosAbertosPorPadraocom ativo/recência/sem corte),apps/web/src/components/AgentTimelineTree.test.tsx(abertura padrão, contador de novidade aparecendo/sumindo, marco expandindo com args/resultado/iteração) - Origem: pedido do usuário — lacunas confirmadas por investigação no componente existente da FASE 14b; sem ADR, mesma semântica de "não visto" do sino, só granularidade nova
Session
RN-119 — O agente ATIVO da sessão é o do agent.activated mais recente — nunca uma cadeia fixa
SessionPage decide duas coisas a partir do MESMO agente: para quem vai a
mensagem do composer, e de qual agente a topbar resolve o modelo mostrado
(a cascata completa sessão→agente→área→projeto→workspace, a mesma que o
RunLlmTurnUseCase usa para de fato rodar o turno). As duas perguntas
tinham respostas DIFERENTES antes desta regra: o roteamento usava uma cadeia
fixa de precedência (architect > po > creative, baseada em EXISTÊNCIA
histórica — "já ativou uma vez", que nunca "desligava"), e o modelo mostrado
nem sabia quem estava ativo — a rota de vínculo de modelo não recebia agente
nenhum e sempre caía no fallback fixo do agente Criativo
(herdarModeloDeStart), mesmo depois de um handoff para o PO, o Arquiteto ou o Dev
Lead.
A definição única: o agente ATIVO é o do agent.activated mais
RECENTE (por seq), entre os agentes conversacionais do fluxo do chat
(Criativo, PO, Arquiteto, Dev Lead). O Infra Lead fica de fora:
agent_command_controller.ex (engine) só tem rota message para
po/dev-lead/arquiteto (mais o Criativo, como fallback implícito da última
cláusula) — o Infra nunca teve message ligado, só start. Mandar mensagem
para "infra" hoje cairia em silêncio no agente Criativo; tratá-lo
como agente ativo do composer reabriria essa armadilha em vez de fechar
uma.
Superada em parte pela RN-631 (AT-251): "vence o
agent.activatedmais recente" era ele mesmo um destinatário padrão implícito, e invisível. O destinatário do composer agora é ESCOLHIDO pela pessoa (um seletor "Para" listando os agentes que já estão na sessão) e o modelo da topbar segue esse destinatário. O que sobrevive desta regra é a metade do modelo: a rota de vínculo recebe o agente, nunca cai em fallback calado.
- Onde:
apps/web/src/lib/session-destinatario.ts:290(useDestinatarioDoChat, desde a RN-631),apps/web/src/lib/api-client.ts:1192(getSessionModelBinding, oagentId),apps/api/src/interfaces/http/llm/model-bindings.controller.ts:166(getSessionBinding,@Query('agentId')) - Teste:
apps/web/src/routes/SessionPage.destinatario-do-chat.test.tsx(antesSessionPage.agente-mais-recente.test.tsx),apps/web/src/routes/SessionPage.modelo-do-agente-ativo.test.tsx,apps/api/test/application/use-cases/llm/resolve-model-binding.use-case.spec.ts - Caso de borda: o Infra Lead não participa do roteamento do composer nem da definição de "agente ativo" — ele é proativo (Fase 4), não conversacional pelo composer.
RN-120 — O poll de eventos da sessão pausa durante um turno em andamento
useSessionEvents buscava eventos a cada poucos segundos SEM saber de um
turno em andamento. Se o poll caía DURANTE um turno (comum — turnos costumam
durar mais que o intervalo), ele trazia chat.message/agent.response já
persistidos, que renderizavam JUNTO com o estado
otimista/de streaming ainda na tela: a mesma mensagem, duas vezes.
A correção pausa só o TIMER (refetchInterval) enquanto streaming é
true — a query continua enabled, então a invalidação explícita que
já dispara no fim do turno (finalizarTurnoDoAgente) segue
buscando dados frescos na hora certa. pausarPoll é parâmetro
OPCIONAL, padrão false: os outros consumidores do hook (Overview, Code,
Provisioning, AdoptionPlan) não têm turno conversacional em andamento e
ficam como estavam.
- Onde:
apps/web/src/lib/hooks.ts:221(useSessionEvents),apps/web/src/routes/SessionPage.tsx:269(eventsQuery) - Teste:
apps/web/src/lib/hooks.pausar-poll.test.tsx - Caso de borda: pausar o timer não é desabilitar a query — a invalidação explícita segue funcionando, e a correção depende dela para nunca perder dado.
Painel de projetos
RN-121 — Dev agent e QA são "executores": aba própria, fora do "Time de agentes"
O grid "Time de agentes" e a "Linha do tempo do time" da Visão geral
misturavam Criativo/PO/Arquiteto/Infra com dev-<módulo> e QA (lead +
qa-automacao/qa-performance-seguranca) — sete, oito agentes na mesma
grade, sem distinguir quem CRIA/DECIDE de quem IMPLEMENTA/VERIFICA. A aba
Executores isola os dois últimos, com a MESMA renderização da Visão
geral (AgentCard, o grid de área — extraído para AgentTeamGrid.tsx — e
AgentTimelineTree), nunca um card reinventado.
A regra de separação é uma função só, isExecutorGroup/
isExecutorAgentId (lib/agent-status.ts): dev-lead/dev-<qualquer módulo> e a área qa inteira (lead + subespecialidades) são executor;
o resto não é. As DUAS telas filtram o MESMO groupRosterByArea com essa
função — a Visão geral com !isExecutorGroup, Executores com
isExecutorGroup — então um agente não pode aparecer nos dois nem sumir
dos dois: é sempre exatamente um lado.
A árvore não duplica: cada aba filtra os EVENTOS antes de passar para
AgentTimelineTree (por actor.id pertencer ou não ao conjunto de
executores), então o ramo de um dev agent aparece só em Executores, nunca
também na Visão geral. A coluna de Atividade (ActivityFeed, RN-099/100)
NÃO foi filtrada — ela responde "o que aconteceu" na sessão inteira,
dev/QA inclusive, e o filtro por agente do próprio componente já lista
todo mundo que falou; só o grid e a árvore de "quem está fazendo o quê"
mudam de lugar.
- Onde:
apps/web/src/lib/agent-status.ts(isExecutorGroup,isExecutorAgentId,autonomyActionTypeFor— movido deProjectOverviewTab.tsx),apps/web/src/components/AgentTeamGrid.tsx(extraído do grid que já existia),apps/web/src/routes/ ProjectExecutorsTab.tsx,apps/web/src/routes/ProjectOverviewTab.tsx,apps/web/src/routes/project-tabs.ts(key: 'executores',ordem: 12— logo depois da Visão geral, antes de Criativo/Chat/Code) - Teste:
apps/web/src/lib/agent-status.test.ts(isExecutorAgentId/isExecutorGroup— dev-<módulo>,dev-lead, áreaqainteira são executor; criativo/po/arquiteto/infra/secops não são);apps/web/src/routes/ProjectExecutorsTab.test.tsx(mostra só dev/QA, estado vazio sem os dois, contagem do cabeçalho);apps/web/src/routes/ProjectOverviewTab.test.tsx(dev-backend/QA somem do grid, o resto do time continua);apps/web/src/routes/ project-tabs.test.tsx(a aba nova entra na varredura genérica do registro — régua, painel, deep-link, ordem única) - Borda: SecOps NÃO é executor — ele entra na roster pelo MESMO
pr.gate_changedque traz QA (Fase 4a,rosterFactsFromEvents), mas fica na Visão geral.dev-leadestá na lista de executores por completude (RN-087 o descreve como conversacional), embora hoje nenhum caminho o instancie na roster da sessão (a delegação Dev Lead →dev-<módulo>segue fora de escopo, ADR 0053 item 5). - Origem: pedido do usuário — "Executores" própria pra dev agent e QA, fora do grid misturado da Visão geral.
Session
RN-123 — Sessão criativa sem agente Criativo ativo: a primeira mensagem TAMBÉM o ativa
Numa sessão kind: 'criativa' (RN-097) ainda sem agent.activated,
mandar a primeira mensagem pelo composer ativa antes o agente
Criativo (esperando a ativação terminar) e só então entrega aquela
mensagem a ele pelo caminho real (sendAgentMessage — histórico, prompt de
sistema, ferramenta emit_artifact). Antes desta regra, quem digitava sem
clicar em "Iniciar ideação" caía num chat SSE genérico, sem histórico e sem
regras de negócio: NÃO era o agente Criativo de verdade, e a conversa
não registrava nada no domínio.
O clique em "Iniciar ideação" continua existindo (RN-104) — ainda é o que dispara a ativação quando a pessoa prefere o gesto explícito, e é a partir dele que a chave do dono (RN-058) começa a ser gasta em qualquer dos dois caminhos. O que mudou é que a PRIMEIRA MENSAGEM agora também conta como esse gesto: ninguém deveria precisar de um clique à parte antes de falar com quem a tela já o convidou a falar.
- Onde:
apps/web/src/routes/SessionPage.tsx:690(handleSend) - Teste:
apps/web/src/routes/SessionPage.ideacao-automatica.test.tsx - Caso de borda: sessão
consultivanão tem agente Criativo — a regra não se aplica, e o caminho SSE genérico segue sendo o certo para ela.