« Chat out of date » : quand le bureau refusait chaque envoi

Vous travaillez dans une même session de bureau depuis des heures, appel d’outil après appel d’outil. Vous appuyez sur Entrée et vous obtenez « Chat out of date » — la fenêtre était derrière une autre vue du même chat, les derniers messages sont chargés, renvoyez si vous le souhaitez toujours. Vous renvoyez. Même message. Redémarrer l’application ne change rien. Aucune de vos données ne manque, mais cette conversation ne peut plus accepter de nouveau message.
La PR #125523, fusionnée le 2026-09-27, corrige cette boucle. L’issue #123909 contient la chronologie complète de la reproduction.
Le symptôme : une boucle de refus qui ne peut pas converger
- Un déclencheur d’apparence parfaitement anodine : déplacer une session dans un projet. Après le déplacement, le store en mémoire de la fenêtre se fige sur la frontière d’avant le déplacement, et à partir de là chaque envoi est bloqué côté client.
- Ce n’était pas un cas isolé : les issues #123033 et #123856 signalaient le même garde-fou déclenché sans aucun déclencheur évident.
- Il y a une seconde victime, plus discrète : forker un chat touché initialise le fork depuis ce store figé, en abandonnant silencieusement tout ce qui suit le déplacement. Aucune erreur — la nouvelle session ne contient simplement pas cet historique.
Cause racine : deux consommateurs en désaccord sur un même store
Deux composants lisent le même store de session et n’avaient pas la même idée de ce que « current » signifie :
- La rétention (
transcript-retention.ts, introduite pour l’issue #77311) libère délibérément la tête du store, en gardant la fenêtre vivante plus une page de poids de marge. ChaqueChatMessage.partstransporte de la sortie d’outil rendue, des aperçus de fichiers, des diffs et des images, et c’est ainsi qu’une fenêtre de longue durée grossit. Un transcript de coordinateur bourré d’appels d’outils brûle ce budget très vite, si bien que le store élagué contient bien moins de messages rendus que la session n’en a réellement. - L’ancien garde-fou (
stale-transcript-guard.ts) comparait des totaux : la dernière page de 120 lignes (includeCompacted: true) se réduisait à, disons, 85 messages de chat — plus que ce que contenait le store élagué — donc la vue était déclarée en retard, l’envoi était refusé et la page rafraîchie installée. Le mouvement d’ancre suivant élaguait de nouveau, et l’envoi suivant était refusé encore. Non convergent par construction, et cela survivait au rechargement comme au redémarrage, parce que l’hydratation ré-élague dès que la coupe de fenêtre est établie.
Les chats courts ne franchissent jamais ce budget. C’est pourquoi le problème ne touchait que les conversations longues et lourdes — dont le Bot Chat canonique, qui est un chat permanent par conception.
Le correctif : comparer la fin, pas le total
La nouvelle logique change ce qui est comparé :
- Un nouveau champ
lastDurableRowIdremonte depuis la fin de la vue jusqu’au premier message portant unrowIdpersisté — la fin de la vue. La rétention ne peut jamais libérer que la tête, donc la dernière ligne durable est toujours la fin vivante ; les lignes non persistées (prompts optimistes, réponses en streaming) se trouvent au-delà. - Quand le dernier
rowIddurable de la vue locale est égal au dernierrowIddurable de la page que le backend vient de renvoyer, la vue est à jour, peu importe ce que la rétention a évincé en tête. Une fenêtre voisine avec des lignes réellement plus récentes change toujours la pointe, donc une vraie péremption est toujours refusée. - Une source de faux positifs disparaît au passage : les lignes
systemNoticeécrites par le backend s’affichent dans la timeline mais n’appartiennent à aucune vue, si bien que les compter signalait une seconde fenêtre qui n’existait pas — un simple changement de modèle sur place suffisait à faire paraître chaque envoi en retard.
Ce que vous pouvez faire
- La mise à jour est la réponse directe. Une fois le correctif installé, les longs chats ne sont plus refusés par cette boucle (l’application de bureau suit
hermes update). - Sur une machine pas encore mise à jour, le contournement pratique quand vous voyez l’invite est de continuer dans une nouvelle session, ou de poursuivre depuis le CLI (
hermes sessions browsepermet de choisir une session passée et de la reprendre). N’appuyez pas sans fin sur Envoyer. - Si vous avez déjà forké une session à cause de cela, traitez ce fork comme une copie peu fiable : la session d’origine existe toujours avec tout son historique, donc n’utilisez pas le fork comme sauvegarde.
- Un autre correctif sur les conversations longues est arrivé le même jour et mérite d’être lu ensuite : pourquoi la compaction en milieu de tour ne doit pas réécrire l’historique. Pour l’application de bureau elle-même, voir les modes d’interface et Hermes Desktop dans notre section écosystème.
État de la publication
Le correctif a été fusionné le 2026-09-27, après le tag de publication v0.21.5 (v2026.9.24), il n’est donc pour l’instant que sur main.
À retenir
La leçon ici n’est dans aucune ligne de code en particulier : les deux modules étaient « corrects ». L’un libère l’historique au poids pour borner la mémoire ; l’autre jugeait la fraîcheur en comptant les lignes — mis ensemble, la comparaison ne pouvait jamais réussir. Le correctif ne règle pas le budget. Il choisit un fait sur lequel les deux côtés sont d’accord : la dernière ligne durable. Si les pointes correspondent, la vue est à jour ; sinon, elle est réellement en retard. La mémoire reste bornée, et l’envoi fonctionne de nouveau.