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

241 lines
13 KiB
Markdown

# 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`](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`](mail-o365-folder-path-disambiguation.md), et
l'anti-toggle du 02/08 dans
[`mail-o365-toggle-expand-non-idempotent-fix.md`](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é :
```python
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_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`.