"Chat out of date": quando o desktop recusava cada envio


Você está há horas na mesma sessão do desktop, uma chamada de ferramenta atrás da outra. Aperta Enter e recebe “Chat out of date” — a janela estava atrás de outra visão do mesmo chat, as mensagens mais recentes foram carregadas, envie de novo se você ainda quiser. Você envia de novo. A mesma mensagem. Reiniciar o app não muda nada. Nenhum dado seu sumiu, mas aquela conversa não aceita mais nenhuma mensagem nova.

O PR #125523, mesclado em 2026-09-27, corrige o loop. A issue #123909 guarda a linha do tempo completa da reprodução.

O sintoma: um loop de recusa que não converge

  • Um gatilho parece totalmente inocente: mover uma sessão para dentro de um projeto. Depois da mudança, o store em memória da janela congela na fronteira anterior à mudança e, a partir dali, todo envio é bloqueado no lado do cliente.
  • Não era um caso isolado: as issues #123033 e #123856 relataram a mesma guarda disparando sem nenhum gatilho óbvio.
  • Existe uma segunda vítima mais silenciosa: fazer fork de um chat afetado semeia o fork a partir desse store congelado, descartando em silêncio tudo o que veio depois da mudança. Nenhum erro — a nova sessão simplesmente não contém esse histórico.

Causa raiz: dois consumidores em desacordo sobre um mesmo store

Dois componentes leem o mesmo store da sessão e tinham ideias diferentes sobre o que significa “atual”:

  • Retention (transcript-retention.ts, introduzido para a #77311) libera deliberadamente a cabeça do store, mantendo a janela viva mais uma página de peso de folga. Cada ChatMessage.parts carrega saída renderizada de ferramenta, prévias de arquivos, diffs e imagens — é assim que uma janela de vida longa cresce. Um transcript de coordenador cheio de chamadas de ferramenta queima esse orçamento rápido, então o store podado guarda bem menos mensagens renderizadas do que a sessão realmente tem.
  • A guarda antiga (stale-transcript-guard.ts) comparava contagens: a página mais recente de 120 linhas (includeCompacted: true) se reduzia a, digamos, 85 mensagens de chat — mais do que o store podado continha —, então a visão era declarada atrasada, o envio era recusado e a página atualizada era instalada. O deslocamento seguinte da âncora de janela podava de novo, e o envio seguinte era recusado outra vez. Não convergente por construção, e sobrevivia a recarregar e a reiniciar porque a hidratação volta a podar assim que o corte da janela se estabelece.

Chats curtos nunca cruzam esse orçamento. É por isso que isso só mordia conversas longas e pesadas — incluindo o Bot Chat canônico, que é um forever-chat por design.

A correção: comparar a cauda, não o total

A nova lógica muda o que é comparado:

  • Um novo lastDurableRowId caminha do fim da visão para trás até a primeira mensagem que carrega um rowId persistido — a cauda da visão. A retention só consegue liberar a cabeça, então a última linha durável é sempre a cauda viva; linhas não persistidas (prompts otimistas, respostas em streaming) ficam além dela.
  • Quando o último rowId durável da visão local é igual ao último rowId durável da página que o backend acabou de devolver, a visão está atual, não importa quanto da cabeça a retention tenha paginado para fora. Uma janela par com linhas genuinamente mais novas ainda muda a ponta, então desatualização real continua sendo recusada.
  • Uma fonte de falsos positivos foi embora junto: linhas systemNotice escritas pelo backend são renderizadas na timeline, mas não pertencem a nenhuma visão, então contá-las reportava uma segunda janela que não existia — trocar o modelo no lugar já bastava para fazer todo envio parecer atrasado.

O que você pode fazer

  • Atualizar é a resposta direta. Assim que a correção estiver na sua instalação, os chats longos param de ser recusados por esse loop (o desktop acompanha o hermes update).
  • Numa máquina ainda não atualizada, o contorno prático ao ver o aviso é continuar numa nova sessão, ou seguir pela CLI (hermes sessions browse permite escolher uma sessão antiga e retomá-la). Não fique apertando enviar.
  • Se você já fez fork de uma sessão por causa disso, trate esse fork como uma cópia não confiável: a sessão original continua existindo com todo o histórico, então não use o fork como backup.
  • Outra correção para conversas longas chegou no mesmo dia e vale a leitura em seguida: por que a compactação no meio do turno não pode reescrever o histórico. Sobre o app desktop em si, veja modos de interface e o Hermes Desktop na nossa seção de ecossistema.

Status de release

O patch foi mesclado em 2026-09-27, depois da tag de release v0.21.5 (v2026.9.24), então por enquanto ele está só na main.

A lição

A lição aqui não está em nenhuma linha de código específica: os dois módulos estavam “corretos”. Um libera histórico por peso para limitar a memória; o outro julgava a atualidade contando linhas — juntos, a comparação nunca poderia dar certo. A correção não ajusta o orçamento. Ela escolhe um fato em que os dois lados concordam: a última linha durável. Se as pontas batem, a visão está atual; se não batem, ela está mesmo atrasada. A memória continua limitada, e o envio volta a funcionar.