13 KiB
Bug D — « déplier » n'est pas « sélectionner » : /search?folder_path= renvoyait la Boîte de réception en HTTP 200
Date : 10/08/2026 (soir)
Instance : hermes-tt
Statut : CAUSE TROUVÉE, CORRIGÉE ET VALIDÉE · bug C corrigé dans le même passage · 46 notes
polluées purgées · bug E découvert par les tests et bloqué (recherche élargie par OWA)
Fichiers : /volume1/docker/hermes-mail-browser/app/main.py — go_to_folder_path (D, C) ·
/volume1/docker/nyora-notes-tt/hermes_mail_client.py — _verifier_portee (E)
Sauvegardes : main.py.bak-20260810-212013-pre-fix-DC ·
hermes_mail_client.py.bak-20260810-*-pre-fix-E
Suite directe de nyora-notes-tt-parsing-owa-pua-10-08-2026.md
(bugs A/B corrigés, D mitigé par HermesFolderScopeError, C diagnostiqué). Ce runbook ne traite
que la cause de fond de D et le correctif de C. La désambiguïsation des chemins reste décrite
dans mail-o365-folder-path-disambiguation.md, et
l'anti-toggle du 02/08 dans
mail-o365-toggle-expand-non-idempotent-fix.md.
Symptôme
/search?folder_path=X renvoyait le contenu de la Boîte de réception pour certains chemins,
en HTTP 200, sans erreur. Déterministe, lié au chemin, indépendant des emojis :
| Chemin | Résultat |
|---|---|
00 2026|⏱️ suivi |
81 items correctement scopés ✅ |
00 2026|Relance marchés RLA |
76 items tous libellés « Boîte de réception » ❌ |
| re-test du premier | à nouveau correct |
Cause
Sur cette UI, déplier un dossier et le sélectionner sont deux gestes distincts — et le code les confondait. La portée d'une recherche OWA est le dossier courant ; le dernier segment du chemin doit donc être sélectionné, pas seulement déplié.
L'ancienne boucle de go_to_folder_path ne cliquait la ligne que par défaut, quand aucun
bouton d'expansion n'était trouvé :
if match["expanded"] != "true": # (1) si déjà déplié : aucun clic du tout
expand_btn = row.locator("button").first
if await expand_btn.count() > 0:
await expand_btn.evaluate("el => el.click()") # (2) chevron : déplie, ne sélectionne pas
else:
await row.evaluate("el => el.click()") # seul chemin qui sélectionne
D'où le comportement, entièrement déterminé par la présence d'enfants :
- dossier feuille → pas de chevron → repli sur le clic de la ligne → sélection réelle → OK ;
- dossier avec enfants → soit seul le chevron est cliqué (2), soit — s'il était déjà
aria-expanded="true", l'état d'expansion persistant d'un appel à l'autre — aucun clic (1). Le dossier n'est jamais sélectionné.goto_owa_root()ayant rechargé la racine, le dossier courant reste la Boîte de réception, et la recherche s'y exécute.
Le branchement (1) est le fix anti-toggle du 02/08 : correct pour l'expansion, mais il a laissé la
sélection sans aucun chemin de code. Les deux cas discriminants s'expliquent exactement :
⏱️ suivi est une feuille, 🔁 relance marchés rla un conteneur déplié (2 enfants).
Portée mesurée sur l'arbre réel : 63 dossiers sur 336 ont des enfants — tous inatteignables.
Correctif
Séparer les deux responsabilités dans la boucle :
- segments intermédiaires → seule l'expansion compte (comportement du 02/08 inchangé, pour que les enfants soient montés dans le DOM) ;
- dernier segment → clic toujours sur la ligne, jamais sur le chevron, puis confirmation
de la sélection via
aria-selected, avec 3 tentatives et attente croissante. Sans confirmation →HTTPException(502)nommant le dossier demandé et le dossier réellement sélectionné.
snapshot_folders() lisait déjà aria-selected et l'exposait en selected : la machinerie de
vérification existait, elle n'était simplement pas utilisée.
Bug C — dossiers hors Réception (même passage)
cur = inbox_candidates[0] ancrait tout chemin sous la Boîte de réception, rendant ses frères
de niveau 2 (éléments envoyés, archive, brouillons…) inatteignables. Le premier segment
est désormais cherché d'abord sous la Boîte de réception, puis — à défaut seulement — parmi les
dossiers de niveau 2 du compte. Cet ordre garantit que les chemins existants (00 2026,
01 2025, 02 2024…) gardent exactement leur résolution : aucune régression possible.
Pollution induite, et sa purge
La dédup message_id est globale (SELECT id FROM notes WHERE message_id = ?, sans filtre de
dossier). Les messages de la Réception ingérés sous un folder_path arbitraire y occupaient donc
l'identifiant du vrai message, ce qui aurait empêché leur ré-ingestion correcte au backfill.
La purge était un prérequis, pas un nettoyage cosmétique.
Détection : dernière ligne de raw_excerpt (= libellé du dossier réel, signal déjà utilisé par
HermesFolderScopeError) contredisant le folder_path assigné.
46 notes sur 399 (11,5 %), réparties sur 10 dossiers, tous des conteneurs — signature exacte de la cause :
| Notes | Dossier |
|---|---|
| 18 | 00 2026|Consultations |
| 6 | 02 2024|RLA |
| 4 | 02 2024|RLA|E. Partielles |
| 3 | 00 2026|DR Zone Sud, 01 2025|Consommables 2025, 02 2024|Inventaire 2023, 02 2024|RLA|AO 76/2024 E FO |
| 2 | 00 2026|Automated, 02 2024|DR Zone Sud, 02 2024|RLA|AO 66/2024 E Curatif |
Contenus manifestement étrangers à leur dossier : notifications de release logicielle sous
Inventaire 2023, veille infra sous RLA, newsletter mode sous Consultations.
Procédure (script backups/purge_bugD.py, dry-run par défaut, --apply pour exécuter) :
sauvegarde VACUUM INTO → re-mesure de la liste (jamais figée) → suppression notes_vec
(lié par rowid), note_links, notes → rebuild de notes_fts → checkpoints des 10 dossiers
remis à pending avec last_processed_* à NULL.
Résultat : 399 → 353 notes, 353 vecteurs, 0 incohérence résiduelle.
Deux pièges rencontrés pendant la purge
no such module: vec0—notes_vecest une table virtuelle sqlite-vec : passer parsetup_db.get_db_connection(), pas parsqlite3.connect()brut.database is locked— le container garde une connexion ouverte. Fairedocker stop nyora-notes-tt, exécuter dans un container éphémère (docker run --rmsur la même image, mêmes montages), puisdocker start. Le code étant cuit dans l'image, le script se dépose dansbackups/(monté rw).
Validation
Batterie de 9 appels après rebuild (--no-cache + --force-recreate), garde-fou actif :
| Cas | Avant | Après |
|---|---|---|
00 2026|suivi (feuille, référence) |
81 items scopés | 81/81 — aucune régression |
00 2026|Relance marchés RLA (conteneur déplié) |
76 items « Boîte de réception » | 19/19 correctement scopés |
02 2024|RLA (conteneur profond) |
— | 27/27 |
…|AO 66/2024 E Curatif|Notification (homonyme) |
— | 8/8 |
Éléments envoyés (bug C, frère L2) |
inatteignable | 40/40 |
| 3 appels consécutifs sur le même conteneur | alternance succès/échec (02/08) | stable, aucune alternance |
Piège de méthode : une première série de tests a tourné en double (un job nohup chaîné
derrière le build avait survécu à sa session SSH). Les deux séries tapant le même navigateur et
@serialize_owa sérialisant les appels côté serveur, leurs résultats s'entrelaçaient — un test
renvoyait la portée du test précédent. Toujours lancer ces batteries sous flock, et se méfier
d'un résultat qui « pointe vers le dossier d'à côté ».
Bug E — OWA élargit la recherche sur un dossier sans message direct
Découvert par ces mêmes tests. 00 2026|Consultations renvoyait 40 items dont le libellé
majoritaire était Archive à 10/40 — un mélange, stable sur 3 appels. Même profil sur
01 2025|Cons° animation commerciale.
Ce n'est pas un reliquat du bug D, et les deux effets ont été isolés : /inbox sur ces mêmes
dossiers répond « liste des messages introuvable » — c'est-à-dire dossier vide — au lieu du
contenu de la Boîte de réception. La navigation est donc correcte ; c'est la recherche qui
s'élargit. Quand un dossier ne contient aucun message direct (ses messages sont dans ses
sous-dossiers), OWA étend de lui-même la requête à toute la boîte aux lettres, en HTTP 200.
Correctif côté client (_verifier_portee) : politique inversée. L'hétérogénéité était traitée
comme une indétermination et laissait passer l'ingestion ; elle est désormais un motif de blocage
(HermesSearchScopeWidenedError). Justification empirique : une recherche réellement scopée est
uniforme même sur un dossier à enfants (02 2024|RLA → 27/27, Relance marchés RLA → 19/19).
L'uniformité est la norme d'une portée correcte ; son absence est une preuve suffisante.
Sans ce durcissement, le backfill aurait classé des messages de toute la boîte sous ces
folder_path — le mode de corruption exact que la purge ci-dessus venait de réparer.
Leçon généralisable
Sur une UI React/Fluent, le succès d'un clic ne prouve pas son effet. Un chevron d'expansion et la ligne qui le porte sont deux cibles distinctes aux effets distincts. Après toute navigation dont dépend la suite, confirmer l'état atteint par un attribut structuré (
aria-selected) et échouer explicitement sinon — un 200 qui répond sur le mauvais périmètre coûte infiniment plus cher qu'une erreur franche.
Corollaire tiré du bug E : quand un garde-fou doit trancher sur des données bruitées, le cas « je ne peux pas conclure » mérite d'être mesuré avant d'être traité comme inoffensif. Ici il recouvrait un second mode de corruption, et la politique permissive choisie par prudence était précisément ce qui l'aurait laissé passer.
Suite du 10/08 au soir — pilote, et le blocage qui reste
Ce que le pilote a validé
Ingestion réelle sur la Boîte de réception : 25 notes, portée 25/25 correcte. Le fix D/C est donc validé au-delà de la lecture — le pipeline classe bien au bon endroit.
BLOQUANT — message_id n'est PAS stable dans le temps
Le prérequis considéré comme « levé par le fix A » ne l'est pas. Lot pilote de 10 dossiers, avec re-scan volontaire d'un dossier déjà ingéré : 4 notes créées au lieu de 0 (~5 %).
Cause : message_id est dérivé des champs normalisés dont received_date, et la date n'est
pas extraite de façon déterministe d'un passage à l'autre :
| Message | 1er passage | 2e passage |
|---|---|---|
| « Veille marché du 10 Août 2026 » | 2026-08-10 |
date_inconnue |
| « Veille marché du 07 Août 2026 » | date_inconnue |
2026-08-07 |
| « Veille marché du 09 Juin 2026 » | 2026-06-10 |
2026-06-09 (30 s d'écart, même passage) |
Le fix A a bien supprimé le décalage positionnel dû au glyphe PUA, mais la reconnaissance de motif de date reste non déterministe sur certains rendus OWA (le troisième cas, à un jour d'écart, sent le fuseau ou la date relative).
Piste de correctif retenue : sortir received_date du calcul du message_id. Fiabiliser le
parsing de date ne ferait que déplacer le problème — un identifiant de déduplication ne doit pas
dépendre du champ le plus fragile de la chaîne. Tant que ce n'est pas fait, le workflow n8n
récurrent ferait gonfler la base à chaque passage.
Concurrence SQLite — deux pipelines, un seul fichier
Le pilote s'est heurté à des database is locked. Deux causes cumulées :
- Régression n8n :
Q6ZAXgd8HYoQbk9K(auto-sync OneDrive) tournait toutes les 10 min au lieu de 2 h depuis le 07/08 — le correctif avait été enregistré mais jamais publié, la version active gardantminutesInterval: 10et le paramètre imbriqué parasite. Corrigé en republiant. Piège déjà listé dans PROTOCOL-INFRA (« une modif écrite n'est pas une modif live »). - Connexion zombie : le verrou survit à la fin des exécutions et n'a cédé qu'à un
docker restart— des fonctions demcp_serverne ferment pas leur connexion sur exception.
Le commentaire du code (« verrou dédié onedrive_sync_lock […] aucune raison qu'ils se bloquent
mutuellement ») est faux au niveau SQLite : des verrous applicatifs distincts ne changent rien
au fait que les deux pipelines écrivent dans le même fichier, donc se disputent le même verrou.
Débit mesuré
~49 s par message (79 messages en 64 min), dominé par Mimo + embedding. Après ce gros dossier,
hermes-mail-browser a renvoyé deux HTTP 503 et 7 dossiers ont échoué en timeout à 300 s. Un
backfill de ~3 500 messages se dimensionne donc en dizaines d'heures, et doit être découpé et
espacé plutôt que lancé d'un bloc.
Divers
pipeline_lock contenait 4 lignes violant sa contrainte CHECK (id = 1), ce qui faisait échouer
PRAGMA integrity_check. Supprimées → integrity_check: ok. Aucun code du dépôt ne fait
d'INSERT sur cette table (uniquement UPDATE … WHERE id = 1) : origine non élucidée, à
surveiller.
État de la base à la clôture : 468 notes / 468 vecteurs, integrity_check: ok, checkpoints
54 done / 128 error_404 / 7 error / 5 error_409 / 10 pending / 1 in_progress.