diff --git a/docs/handoff-lot6.md b/docs/handoff-lot6.md deleted file mode 100644 index 328263e..0000000 --- a/docs/handoff-lot6.md +++ /dev/null @@ -1,213 +0,0 @@ -# Passation — lot 6 (chargements paresseux sous concurrence) - -Tu travailles sur `mcp-wms-api` : un serveur MCP (Node.js, CommonJS, stdio) qui -donne à Claude un accès en lecture à un WMS EasyWMS (Mecalux) via ses API REST. -Lis [../CLAUDE.md](../CLAUDE.md) et [../DECISIONS.md](../DECISIONS.md) — -en particulier **D8** (invalidation par abonnement), **D10** et **D26** -(caches par application, chargement paresseux) — avant de toucher au code. - -**Mission** : rendre les chargements paresseux corrects sous appels -concurrents — lot 6 de [../ROADMAP.md](../ROADMAP.md) : déduplication des -fetchs en vol (L6.1), garde de génération contre les écritures post-invalidation -(L6.2), refus d'encaisser un vide anormal (L6.3). Trois services concernés : -`src/services/workflow-service.js`, `src/services/ad-service.js`, -`src/services/entity-resolver.js`. - -**Hors périmètre** : -- La **sérialisation globale des `tools/call`** et le figeage du profil par - appel : c'est l'autre manifestation de la concurrence (bascule de profil - redirigeant les requêtes en vol), point ouvert distinct de la ROADMAP — n'y - touche pas. -- Le reste de la roadmap (L4.3, L4.4, L4.5, Metrics). Aucun nouvel outil, le - compte reste à 23. Aucun changement des schémas d'outils. -- Ne pousse rien (`git push` interdit), ne touche pas au `.env`, n'appelle - jamais `execute_command` (il écrit dans le WMS). - ---- - -## Contexte matériel - -- Profil de travail : `LIMAGRAIN` (par défaut), host `10.255.255.2`, tenant - `LIMAGRAI2512`. `EUROTRAFIC` fonctionne (utile pour tester la bascule). -- **Le profil `AD` est cassé et c'est diagnostiqué — ne le réinvestigue pas** - (tenant introuvable côté STS, point ouvert). La baseline se mesure avec - `npm test` (profil par défaut), attendu **4/4, code de sortie 0**. -- Baseline protocolaire : **23 outils**, **6 resources**, rien sur stdout hors - JSON-RPC. -- **Fait établi, ne le redécouvre pas** : le serveur traite les `tools/call` - en **concurrence** — une rafale de requêtes dans une même session s'exécute - en parallèle. C'est précisément ce qui déclenche les bugs de ce lot, et - c'est l'outil de reproduction. - -Handshake + comptages : - -```bash -printf '%s\n%s\n%s\n%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}' '{"jsonrpc":"2.0","method":"notifications/initialized"}' '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' '{"jsonrpc":"2.0","id":3,"method":"resources/list"}' | node src/index.js 2>/dev/null | node -e "let b='';process.stdin.on('data',d=>b+=d).on('end',()=>{for(const l of b.split('\n').filter(Boolean)){const m=JSON.parse(l);if(m.id===2)console.log('tools:',m.result.tools.length);if(m.id===3)console.log('resources:',m.result.resources.length);}});" -``` - -**Rafale concurrente** (l'outil de reproduction de ce lot) : envoyer plusieurs -`tools/call` d'un bloc sur stdin — ils partent en parallèle. Exemple, 6 appels -identiques : - -```bash -node -e " -const lines=[JSON.stringify({jsonrpc:'2.0',id:1,method:'initialize',params:{protocolVersion:'2024-11-05',capabilities:{},clientInfo:{name:'t',version:'1'}}}),JSON.stringify({jsonrpc:'2.0',method:'notifications/initialized'})]; -for(let i=0;i<6;i++)lines.push(JSON.stringify({jsonrpc:'2.0',id:10+i,method:'tools/call',params:{name:'search_workflows',arguments:{query:'CST_',application:'CustomApp'}}})); -process.stdout.write(lines.join('\n')+'\n');" | node src/index.js 2>stderr.log | node -e "let b='';process.stdin.on('data',d=>b+=d).on('end',()=>{for(const l of b.split('\n').filter(Boolean)){const m=JSON.parse(l);if(m.id>=10){const o=JSON.parse(m.result.content[0].text);console.log('id',m.id,'count',o.count);}}});"; grep -c "fetching from API" stderr.log -``` - -Pour les vérifications **séquentielles** (une requête après la réponse de la -précédente), écris un petit driver qui n'envoie la ligne suivante qu'à -réception de la réponse — ne réutilise pas la rafale pour ça. - -## Contraintes non négociables - -1. `console.error()` uniquement (D6) — et les logs de fetch existants - (`fetching from API`, `Cache expired or empty`…) sont l'**observable** des - vérifications de ce lot : garde-les exacts, un par fetch réel. -2. Contrat d'erreur `{ success: false, error, tool }`, `isError: true`. -3. Aucun accès base de données (D1). -4. **D8** : l'invalidation reste pilotée par `onSwitch()` — ce lot la renforce - (L6.2), il ne la remplace pas. N'invalide toujours rien à la main depuis un - autre module. -5. **D26** : chargement paresseux strict — la déduplication ne doit introduire - aucun préchargement. -6. Pas de dépendance externe pour le single-flight : c'est une Map de - promesses, pas une lib. - -**Numéro de décision réservé** : **D27** = single-flight + génération de cache -(le motif commun aux trois services). Vérifie que D26 est bien la dernière -décision avant d'écrire. - ---- - -## Phase 0 — Reproduire (time-box : 45 min) - -Preuves déjà mesurées (25/08/2026, révisions des lots 2 et 5) à **reproduire -au moins partiellement** — la course est non déterministe, un échec de -reproduction ponctuel n'invalide pas les mesures : - -1. **Duplication** (déterministe, reproduis-la telle quelle) : une rafale de - 6 appels identiques nécessitant le resolver a produit 6 × - `[EntityResolver] Cache expired or empty, fetching Metadata...` — soit 6 - chargements complets (6 × 6 GET) pour un seul cache. La rafale ci-dessus - doit montrer aujourd'hui plusieurs `fetching from API` pour la même clé. -2. **Résultat faux** (non déterministe) : dans une rafale mixte de 14 appels, - `search_workflows("CST_", application: "CustomApp")` a répondu - `count: 0` (contre 44 en séquentiel) et `get_workflow_details` une réponse - anormale (~982 caractères au lieu de ~23 000). **Cause supposée, à - confirmer ou infirmer pendant l'implémentation de L6.3** : une réponse - transitoirement anormale de l'API sous charge concurrente, encaissée comme - cache vide (voir L6.3). « Non résolu » est une réponse acceptable sur le - déclencheur exact ; le durcissement L6.3 se fait dans tous les cas. - ---- - -## L6.1 — Dédupliquer les fetchs en vol (single-flight) - -**Problème.** Aucun des trois services ne mémorise la promesse du chargement -en cours : chaque appelant concurrent qui trouve le cache invalide lance sa -propre chaîne de fetch complète. - -**À faire.** Dans `workflow-service` (cache par application **et** liste -allégée d'`Application/GetAll`), `ad-service` (cache par -`(application, type)`), `entity-resolver` (table unique) : mémoriser la -promesse de fetch en vol **par clé de cache** ; tout appelant concurrent sur -la même clé attend cette promesse au lieu de refetcher ; la promesse est -retirée de la Map au règlement (succès **ou** échec — un échec ne doit pas -rester coincé et bloquer les appels suivants : le prochain appel refetche). - -**Attention** : deux clés différentes (ex. `EasyWMS` et `CustomApp`) doivent -pouvoir se charger **en parallèle** — le single-flight est par clé, pas global. - -**Vérification attendue** (protocole, LIMAGRAIN) : -- Rafale de 6 `search_workflows {"query":"CST_","application":"CustomApp"}` → - les 6 réponses portent `count: 44`, et stderr contient **exactement une** - ligne `fetching from API` pour CustomApp. -- Rafale de 6 `query_wms_entities {"entity_type":"Container","limit":1}` → - 6 succès, **une seule** ligne `[EntityResolver] Cache expired or empty`. -- Rafale mixte EasyWMS + CustomApp → un fetch **par application** (deux au - total), réponses toutes correctes. - -## L6.2 — Garde de génération contre les écritures post-invalidation - -**Problème.** Un fetch parti avant une invalidation (`clearCache()` / -`invalidateCache()` sur bascule de profil, D8) termine **après** elle et écrit -son résultat : le cache est repeuplé avec les données de l'ancien tenant, avec -un timestamp neuf. Défaut latent, aggravé par le single-flight si la promesse -en vol survit à l'invalidation. - -**À faire.** Compteur de génération par service (ou par clé) : incrémenté à -chaque invalidation ; un fetch capture la génération au départ et, au moment -d'écrire, jette son résultat si la génération a changé. L'invalidation vide -aussi la Map des promesses en vol (un appelant en attente sur une promesse de -l'ancienne génération reçoit son résultat mais celui-ci n'est **pas** mis en -cache). - -**Vérification attendue** : rafale `[search_workflows (EasyWMS), -switch_wms_profile (EUROTRAFIC)]` envoyée d'un bloc, puis — séquentiellement, -après les deux réponses — `get_application_summary` : **aucun cache** peuplé -(le fetch EasyWMS parti avant la bascule n'a pas repeuplé le cache après -l'invalidation). Rejouer 3 fois (course non déterministe) : zéro occurrence de -cache repeuplé. - -## L6.3 — Ne pas encaisser un vide anormal - -**Problème.** `fetchAllWorkflows` (`workflow-service.js`, boucle de -pagination) fait `response?.entities || []` : une réponse sans champ -`entities` (forme anormale, quelle qu'en soit la cause) devient un tableau -vide, mis en cache avec un timestamp valide — **cache vide empoisonné pour -tout le TTL**. Cause probable du `count: 0` mesuré. Vérifie si `ad-service` -et la liste d'applications ont le même motif. - -**À faire.** Distinguer les deux cas : une réponse **sans** champ `entities` -(undefined/null, forme inattendue) → **lever** une erreur (l'appel échoue, -rien n'est mis en cache, le prochain appel refetche) ; une réponse avec -`entities: []` **réel** → comportement actuel conservé (fin de pagination, et -des applications légitimement vides existent — `SmartUI` a 0 workflow, D26). - -**Pente naturelle interdite** : ne transforme pas ça en retry automatique ou -en logique de résilience élaborée — ce lot rend l'anomalie **visible et non -persistante**, c'est tout. - -**Vérification attendue** : `search_workflows` sur `SmartUI` (application -légitimement vide) → `count: 0` **sans erreur**, cache posé, et le hint L5.4 -présent. Le cas « forme sans `entities` » n'est pas déclenchable à la demande -contre le vrai WMS : couvre-le par un test direct du service en Node (appelle -la fonction avec un `apiService.post` substitué qui renvoie `{}`) et colle la -sortie — l'erreur doit être levée, rien en cache. - ---- - -## Méthode - -1. Phase 0 (reproduction, time-boxée). -2. L6.1, puis L6.2, puis L6.3 — dans cet ordre : la garde de génération (L6.2) - s'appuie sur la structure posée en L6.1. -3. Chaque bloc vérifié **en exécution via le protocole** (rafales pour la - concurrence, driver séquentiel pour les états) avant de passer au suivant. -4. Les courses sont non déterministes : rejoue chaque vérification de - concurrence **3 fois** et colle les trois sorties. -5. Aucun schéma d'outil ne change ; si tu touches quand même à `src/index.js` - ou aux schémas, reboucle sur les 23 noms de `tools/list`. -6. Baseline avant/après : handshake (23/6) + `npm test` (4/4, exit 0). Et - vérifie qu'un appel **séquentiel simple** (`search_workflows` stacker → 50, - cache hit au second appel) est strictement inchangé — la déduplication ne - doit rien changer au chemin nominal. - -## Livraison - -- Un commit par bloc (L6.1, L6.2, L6.3), messages expliquant le pourquoi, - **sorties des vérifications (× 3 pour les rafales) dans le corps du - message**. -- Documentation dans les mêmes commits : **D27** dans DECISIONS.md (motif - single-flight + génération, et le contrat « vide anormal = erreur, vide réel - = cachable ») ; CLAUDE.md — une phrase dans la section Caches ; ROADMAP.md — - retirer le lot 6 (le point ouvert « bascule de profil concurrente » reste, - il n'est **pas** couvert par ce lot). -- Ne pousse pas. `.env` intact. Toute anomalie hors périmètre découverte en - route : dans ROADMAP.md, pas dans le code. -- Compte-rendu final : pour chaque bloc, la vérification attendue rejouée et - ses résultats **mesurés** (colle les sorties, y compris les compteurs de - lignes stderr), plus la baseline finale. Laisse `handoff-lot6.md` en place - pour la révision.