feat(persistence): P5 — ProviderSessionStore (resumable_id par provider)

Range le resumable_id du moteur par (conversation, provider) dans
providers.json, support de reprise non exclusif (le handoff reste la source de
fidélité au swap cross-profile) — §19.2/§19.3.

- domaine : port ProviderSessionStore (get/set par provider)
- infra : FsProviderSessionStore — providers.json map plate, set en
  read-modify-write atomique (tmp+rename) sérialisé par conversation
  (coexistence multi-providers garantie), get absent ⇒ None, corrompu ⇒
  StoreError::Serialization

Tests : 6 cas P5 (coexistence claude+codex, 12 set concurrents sans perte,
corruption, isolation) ; cible conversation_log 30 verts, suites complètes
sans régression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 13:14:34 +02:00
parent 75e4f57a71
commit f3046f3dd8
6 changed files with 467 additions and 4 deletions

View File

@ -278,6 +278,54 @@ pub trait HandoffSummarizer: Send + Sync {
async fn fold(&self, prev: Option<Handoff>, new_turns: &[ConversationTurn]) -> Handoff;
}
/// Le store des `resumable_id` CLI, rangés **par (conversation, provider)** (port
/// driven, lot P5).
///
/// Le `resumable_id` est l'identifiant **propre au moteur** (le `--resume`/`--continue`
/// d'une CLI : `"claude"`, `"codex"`…) qui permet de **réattacher** la session native du
/// provider à la reprise (ARCHITECTURE §19.2/§19.3). Contrairement au [`Handoff`]
/// (résumé **portable**, indépendant du provider, source de vérité de la continuité),
/// ce store garde l'optimisation **non portable** : chaque provider a **son** id, et
/// plusieurs providers peuvent coexister pour une même conversation (après un swap
/// Claude→Codex, on garde l'id de chacun). La clé est donc bien le couple
/// `(conversation, provider_id)`.
///
/// `#[async_trait]` et erreur [`StoreError`] comme les ports voisins
/// ([`ConversationLog`], [`HandoffStore`]) : injecté en `Arc<dyn ProviderSessionStore>`
/// au composition root, gardé object-safe. La persistance réelle (`providers.json`) est
/// une affaire d'infrastructure (l'adapter `FsProviderSessionStore`, lot P5).
#[async_trait::async_trait]
pub trait ProviderSessionStore: Send + Sync {
/// Charge le `resumable_id` du provider `provider_id` pour `conversation`.
///
/// Renvoie `Ok(Some(id))` si un id a été rangé pour ce couple, `Ok(None)` si le
/// provider (ou la conversation) n'a encore rien rangé. L'absence n'est **jamais**
/// une erreur.
///
/// # Errors
/// [`StoreError`] en cas d'échec de lecture ou de désérialisation.
async fn get(
&self,
conversation: ConversationId,
provider_id: &str,
) -> Result<Option<String>, StoreError>;
/// Range (en écrasant) le `resumable_id` du provider `provider_id` pour
/// `conversation`.
///
/// Les autres providers déjà rangés pour la même conversation **coexistent** : seul
/// l'enregistrement de `provider_id` est créé/écrasé.
///
/// # Errors
/// [`StoreError`] en cas d'échec d'écriture ou de sérialisation.
async fn set(
&self,
conversation: ConversationId,
provider_id: &str,
resumable_id: &str,
) -> Result<(), StoreError>;
}
#[cfg(test)]
mod tests {
use super::*;