docs(architecture): §19.7 cohérence des ids + découpage P8a→P8d

Cadrage du swap cross-profile et correction de l'incohérence de clé P6/P7 :
LeafCell.conversation_id = id de paire IdeA (clé log/handoff, stable au swap),
resumable moteur isolé par provider dans providers.json (+ cache
engine_session_id). §19.6 : P8 éclaté en P8a (prioritaire/bloquant) → P8d.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 13:47:57 +02:00
parent c1411d3b69
commit 19ba77824f

View File

@ -1785,7 +1785,7 @@ LaunchAgent::execute(input):
```
- **Invariant « 1 session vivante/agent »** : la garde existante (`session_for_agent` au début de `execute`) est **généralisée** pour interroger **les deux** registres (PTY + structuré) — un agent est vivant s'il a une session vivante dans l'un OU l'autre. Le `rebind`/idempotence se duplique trivialement côté structuré (même sémantique : une cellule est une **vue**).
- **Reprise (B, §15.2)** : `ListResumableAgents` est **inchangé** (lecture pure du layout + manifeste + `resume_supported` via le profil). La reprise effective appelle `LaunchAgent` ⇒ pour un profil structuré, la factory démarre la session avec `SessionPlan::Resume { conversation_id }` (l'adapter passe le flag de reprise du moteur). Le `conversation_id` reste **persisté sur la cellule** (`LeafCell.conversation_id`) : pivot model-agnostic déjà en place.
- **Reprise (B, §15.2)** : `ListResumableAgents` est **inchangé** (lecture pure du layout + manifeste + `resume_supported` via le profil). La reprise effective appelle `LaunchAgent` ⇒ pour un profil structuré, la factory démarre la session avec `SessionPlan::Resume { conversation_id }` (l'adapter passe le flag de reprise du moteur). Le `conversation_id` reste **persisté sur la cellule** (`LeafCell.conversation_id`) : pivot model-agnostic déjà en place. **Précision §19.7** : ce pivot est l'**id de paire IdeA** (stable, indépendant du provider) ; le `--resume` consomme le **resumable moteur** rangé séparément dans `providers.json` (`ProviderSessionStore`), pas l'id de paire — voir §19.7 (corrige la clé P6/P7).
- **Hot-swap (A, §15.1)** : `ChangeAgentProfile` **inchangé dans sa structure** ; le « kill PTY » devient « **shutdown de la session** » polymorphe : on résout la session vivante (PTY *ou* structurée), on l'arrête, puis on **rappelle `LaunchAgent`** dans la même cellule avec le nouveau profil ⇒ le bon adapter/type de cellule est re-sélectionné. *Détail d'implémentation* : `relaunch_if_live` interroge les deux registres ; une petite abstraction `LiveSession::shutdown()` (énum interne PTY/structuré) évite de dupliquer la branche.
#### Messagerie inter-agents via le port — `OrchestratorService`
@ -1995,11 +1995,35 @@ Aujourd'hui la continuité d'une conversation repose sur le **`resumable_id` CLI
| **P5** | domaine+infra | `ProviderSessionStore` + `FsProviderSessionStore` | `ProviderSessionStore` | `conversation_log.rs`, `infrastructure/src/conversation_log/providers.rs` | get/set par provider ; providers multiples coexistent ; absent ⇒ `None` ; round-trip disque | P1 |
| **P6** | application | Câblage **checkpoint** : append + fold+save aux fins de tour | (réutilise P1P4) | `crates/application/src/agent/lifecycle.rs`, `orchestrator/service.rs`, `input/` | un tour terminé ⇒ 1 append + handoff réécrit ; debounce (pas N writes/delta) ; profil sans persistance ⇒ no-op (zéro régression) | P2, P3, P4 |
| **P7** | application | Câblage **reprise** : injecter `handoff.md` au (re)lancement + `--resume` si `providers.json` présent | (réutilise P3, P5) ; `ListResumableAgents`, `LaunchAgent` | `application/src/agent/{resume,lifecycle}.rs` | resumable présent ⇒ `--resume` + handoff injecté ; resumable absent ⇒ handoff seul injecté ; aucun handoff ⇒ chemin actuel inchangé | P3, P5, P6 |
| **P8** | application | Câblage **swap cross-profile** : réutiliser `handoff.md`, ignorer l'ancien `resumable_id` | (réutilise P3, P5) ; `ChangeAgentProfile` (§15.1) | `application/src/agent/lifecycle.rs` | swap Claude→Codex ⇒ handoff injecté au nouveau profil, ancien resumable **non** passé ; nouveau provider écrit son **propre** `providers.json[codex]` | P7 |
| **P8a** | domaine+app | **Corrige la clé P6/P7** : la cellule porte l'**id de paire IdeA** (pivot logique), distinct de l'id moteur. `LeafCell` gagne un 2e champ `engine_session_id` (resumable provider courant, cache) ; `conversation_id` redevient/reste l'id de **paire**. `launch_structured` persiste l'id de paire sur la cellule (plus l'id moteur) ; l'id moteur part dans `providers.json` (P8b). | `LeafCell` (+`engine_session_id`, wither additif), `LaunchAgentOutput`, `launch_structured`, `resolve_handoff` (déjà OK une fois la clé corrigée) | `domain/src/layout.rs`, `application/src/agent/lifecycle.rs`, `app-tauri/src/dto.rs` | handoff sauvé sous (paire) **retrouvé** au relancement sous la même clé ; `assigned_conversation_id` = id de paire ; round-trip layout du nouveau champ ; defaults `None` ⇒ zéro régression A/B | P7 |
| **P8b** | application | **Écriture `providers.json`** : quand une session structurée expose/assigne son id moteur (`session.conversation_id()`), appeler `ProviderSessionStore::set(paire, provider_id, resumable)`. Câblage **provider-pattern** (root par appel) comme P6b/P7. | `ProviderSessionStore` (P5) ; provider `ProviderSessionStore` sur `LaunchAgent` (wither additif) | `application/src/agent/lifecycle.rs`, `app-tauri` (câblage) | id moteur exposé ⇒ `providers.json[provider]` écrit sous la paire ; absent ⇒ no-op ; multi-providers coexistent | P8a, P5 |
| **P8c** | application | **Routage `--resume` via `providers.json`** : `resolve_session_plan` consulte `ProviderSessionStore::get(paire, provider_courant)` pour le resumable — **plus** l'id de paire. Présent ⇒ `Resume{resumable}` ; absent ⇒ `None`/`Assign` (le handoff P7 porte la fidélité). | `resolve_session_plan` (devient `async` ou pré-résout le resumable en amont) ; `ProviderSessionStore` | `application/src/agent/lifecycle.rs` | resumable présent pour le provider courant ⇒ `--resume` avec **son** id ; provider sans resumable (post-swap) ⇒ pas de `--resume`, handoff seul ; id de paire **jamais** passé en `--resume` | P8a, P8b |
| **P8d** | application | **Swap cross-profile (P8 proprement dit)** : `ChangeAgentProfile` **préserve** l'id de paire de la cellule (ne plus le `clear`), efface **seulement** le lien provider (id moteur), ne passe **pas** l'ancien resumable au nouveau moteur ; la fidélité vient du handoff (déjà injecté par P7). | `ChangeAgentProfile::execute`/`clean_conversation`, `relaunch_if_live` | `application/src/agent/lifecycle.rs` | swap Claude→Codex ⇒ id de paire **conservé** sur la cellule, handoff injecté au nouveau profil, ancien resumable **non** passé ; nouveau provider écrit son **propre** `providers.json[codex]` (P8b) | P8a, P8c |
| **P9** *(opt.)* | infra/app | Router le log/handoff sous FileGuard si concurrence d'écriture réelle | `GuardedResource` étendu (ou wrapper) | `domain/src/fileguard.rs`, `infrastructure/src/conversation_log/` | écritures concurrentes même conversation sérialisées ; pas de corruption ; conversations différentes parallèles | P2, P3 |
| **P10** *(opt., ultérieur)* | infra | `LlmHandoffSummarizer` (profil déclaratif) | impl `HandoffSummarizer` | `infrastructure/src/conversation_log/summarizer_llm.rs` | substituable à P4 sans toucher l'app (OCP) ; défaut reste l'heuristique | P4 |
**Ordre** : **P1→P2→P3→P4→P5** (briques, parallélisables après P1) **→ P6 → P7 → P8**, puis **P9/P10** optionnels. P6 est le **pivot** (relie le checkpoint existant aux briques) ; P7/P8 délivrent la valeur produit (reprise + handoff cross-profile). P9/P10 durcissent/enrichissent sans bloquer.
**Ordre** : **P1→P2→P3→P4→P5** (briques, parallélisables après P1) **→ P6 → P7 → P8a → P8b → P8c → P8d**, puis **P9/P10** optionnels. P6 est le **pivot** (relie le checkpoint existant aux briques) ; **P8a est prioritaire et bloquant** : il corrige l'incohérence de clé P6/P7 (sans lui, la reprise ne retrouve jamais le handoff — cf. §19.7). P8b/P8c branchent le resumable provider ; P8d livre le swap cross-profile. P9/P10 durcissent/enrichissent sans bloquer.
### 19.7 Cohérence des ids — id de paire IdeA vs resumable provider (corrige P6/P7)
**Problème.** Deux notions de « conversation id » coexistaient et étaient confondues sur la cellule :
1. **Id de paire IdeA** — déterministe via `OrchestratorService::resolve_conversation(requester, target)` (depuis les `ConversationParty`). C'est la clé sous laquelle **P6b** range le **log canonique** et le **handoff** (`.ideai/conversations/<conversationId>/`). Stable, **indépendante du provider**.
2. **Id de session moteur** (resumable Claude/Codex) — exposé par `AgentSession::conversation_id()`, utilisé pour le `--resume` du provider. Propre au moteur, **change** à chaque provider.
Avant correction, `launch_structured` persistait l'**id moteur (2)** sur `LeafCell.conversation_id`, alors que P6 sauvait sous l'**id de paire (1)** ⇒ `resolve_handoff` (P7) cherchait le handoff sous (2) et ne le retrouvait **jamais**. Et `ChangeAgentProfile` **effaçait** ce `conversation_id` au swap (id moteur étranger au nouveau moteur), perdant aussi le lien handoff.
**Décision (conforme D19-3).** Séparer franchement les deux ids :
- **La cellule (`LeafCell`) porte l'id de paire IdeA** comme `conversation_id` — clé **logique** unique de la conversation. C'est lui qui retrouve **log + handoff** au (re)lancement (P7) et **survit** au swap de profil. Le resumable moteur **ne s'écrit plus** sur la cellule.
- **L'id de session moteur (resumable) vit séparément**, par provider, dans `providers.json` via `ProviderSessionStore` (P5), clé `(paire, provider_id)`. C'est **lui** — et **jamais** l'id de paire — que `resolve_session_plan` consulte pour le `--resume` du **provider courant**.
- **Impact `LeafCell`** : un **2e champ optionnel** `engine_session_id: Option<String>` (cache du resumable du provider courant, additif, default `None`) peut être porté pour l'inspection/popup ; la **source de vérité** du resumable reste `providers.json`. `conversation_id` reste/redevient l'**id de paire**. Les withers sont additifs (`set_cell_conversation` inchangé, nouveau `set_cell_engine_session`), donc la persistance des layouts, `SnapshotRunningAgents` et `ListResumableAgents`/popup restent compatibles (lecture du nouveau champ optionnelle, default `None`).
**Acheminement de l'id de paire jusqu'au lancement.** Mécanisme le moins invasif retenu : **la cellule stocke l'id de paire** et le lancement « normal » le lit depuis `LeafCell.conversation_id` (chemin déjà en place : `launch_agent` reçoit `conversation_id` depuis la feuille). Première matérialisation = `resolve_session_plan` branche `Assign{paire}` (UUID minté côté IdeA) sur cellule vierge, **ou** l'orchestrateur fournit l'id de paire calculé par `resolve_conversation` lors d'un `ask`. On **ne** résout **pas** l'id de paire à l'intérieur de `LaunchAgent` à partir de l'agent+interlocuteur (couplage évité) : l'id voyage **comme donnée** sur la cellule / `LaunchAgentInput`, exactement comme aujourd'hui — seul son **contenu** (paire, plus moteur) est corrigé.
**Écriture `providers.json` (P8b).** Au moment où `launch_structured` capte `session.conversation_id()` (id moteur assigné/exposé), il appelle `ProviderSessionStore::set(paire, provider_id, resumable)`. Câblage **provider-pattern** (root résolu par appel) comme P6b/P7 ; absence d'id moteur ⇒ no-op.
**Swap cross-profile (P8d).** `ChangeAgentProfile` : **préserver** l'id de paire de la cellule (ne plus le `clear` dans `clean_conversation`) ; n'effacer que le **lien provider** (le cache `engine_session_id` de la cellule, l'ancien resumable n'étant pas passé au nouveau moteur). La fidélité vient du **handoff** (injecté par P7 une fois la clé corrigée), pas de la session CLI. Ce qui était « cleared » (l'id de conversation entier) devient « seul le resumable provider est invalidé ».
> **Renvoi §15/§17** : le `LeafCell.conversation_id` mentionné en §15.2/§17.4 comme « pivot de reprise » désigne désormais explicitement l'**id de paire IdeA** (pas l'id moteur). Le resumable provider est rangé dans `providers.json` (§19.7), consulté par `resolve_session_plan` pour le `--resume`.
---