Files
nas-runbooks/hermes-tt/mail-o365-bug-D-deplier-nest-pas-selectionner.md

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.pygo_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, notesrebuild 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: vec0notes_vec est une table virtuelle sqlite-vec : passer par setup_db.get_db_connection(), pas par sqlite3.connect() brut.
  • database is locked — le container garde une connexion ouverte. Faire docker stop nyora-notes-tt, exécuter dans un container éphémère (docker run --rm sur la même image, mêmes montages), puis docker start. Le code étant cuit dans l'image, le script se dépose dans backups/ (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 :

  1. 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 gardant minutesInterval: 10 et 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 »).
  2. Connexion zombie : le verrou survit à la fin des exécutions et n'a cédé qu'à un docker restart — des fonctions de mcp_server ne 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.