feat(persistence): P8c — routage --resume moteur via providers.json (structuré)

Referme la régression latente de P8a : pour un profil structuré, le resumable
passé au moteur vient de providers.json[provider] (vrai id Claude/Codex), jamais
de l'id de paire désormais porté par la cellule.

- resolve_session_plan devient async + prend root ; branche structurée câblée :
  get(pair, provider_key) ⇒ Resume{engine_id}, sinon None ; repli gracieux sur
  l'ancien comportement si le store n'est pas câblé (zéro régression tests) ;
  branche non structurée strictement inchangée
- tests : 6 cas (claude/codex Resume{engine} avec assert_ne! vs id de paire,
  discrimination de clé provider, store vide/cellule neuve/non-uuid ⇒ None) ;
  structured_launch_d3 22 verts, domain+application+app-tauri sans régression

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 18:42:35 +02:00
parent 9b053216e3
commit d87b8f6ed2
2 changed files with 268 additions and 13 deletions

View File

@ -1175,8 +1175,9 @@ impl LaunchAgent {
// of the *cell*, not the PTY: the caller (which owns the layout) passes
// the cell's current `conversation_id`. Any id this launch *assigns* is
// returned in the output so the caller persists it on the leaf.
let (session_plan, assigned_conversation_id) =
self.resolve_session_plan(&profile, input.conversation_id.clone());
let (session_plan, assigned_conversation_id) = self
.resolve_session_plan(&profile, input.conversation_id.clone(), &input.project.root)
.await;
let mut spec =
self.runtime
.prepare_invocation(&profile, &prepared, &run_dir, &session_plan)?;
@ -1444,22 +1445,63 @@ impl LaunchAgent {
/// [`SessionPlan::None`] (nothing to resume on a first launch; the adapter
/// uses the bare resume flag only on later reopens);
/// - profile without a `session` block ⇒ [`SessionPlan::None`] (legacy).
fn resolve_session_plan(
async fn resolve_session_plan(
&self,
profile: &AgentProfile,
cell_conversation_id: Option<String>,
root: &ProjectPath,
) -> (SessionPlan, Option<String>) {
// No session strategy at all: behave exactly as before — **sauf** pour un
// profil **structuré** (§17), dont la reprise ne dépend pas d'un bloc
// `session` mais de l'adapter (Claude `--resume` / Codex `exec resume`). Pour
// un tel profil, une conversation présente sur la cellule ⇒ `Resume`, l'id
// étant capté/attribué par le moteur au premier tour (rien à pré-attribuer).
let Some(session) = &profile.session else {
if profile.structured_adapter.is_some() {
if let Some(conversation_id) = cell_conversation_id {
return (SessionPlan::Resume { conversation_id }, None);
}
// Profil **structuré** (§17, lot P8c) : le `conversation_id` de la cellule est
// un **id de paire IdeA**, jamais le resumable du moteur. Le passer en `--resume`
// serait invalide (Claude/Codex attendent *leur* id). On route donc le resume
// moteur via `providers.json`, keyé par `(id de paire, provider_key)` (écrit en
// P8b). Rien à pré-attribuer ici : l'id de paire est géré en aval (P8a), le
// resumable moteur est capté/rapporté par P8b au fil de l'exécution.
if let Some(adapter) = profile.structured_adapter {
// Store câblé : on lit le resumable moteur rangé pour cette paire+provider.
if let Some(provider) = self.provider_sessions.as_ref() {
let engine = match &cell_conversation_id {
// L'id de paire (UUID) → resumable moteur via providers.json.
Some(raw) => match uuid::Uuid::parse_str(raw) {
Ok(uuid) => match provider.provider_session_store_for(root) {
Some(store) => store
.get(ConversationId::from_uuid(uuid), adapter.provider_key())
.await
.ok()
.flatten(),
None => None,
},
Err(_) => None,
},
None => None,
};
return match engine {
// Vrai resumable moteur connu ⇒ resume propre du moteur.
Some(engine_id) => (
SessionPlan::Resume {
conversation_id: engine_id,
},
None,
),
// Aucun resumable (None / parse KO / store absent / pas d'id de
// cellule) ⇒ premier lancement propre. Le moteur attribuera/rapportera
// son id (capté par P8b) ; la continuité du travail est assurée par le
// handoff (P7). On ne passe **jamais** l'id de paire en `--resume`.
None => (SessionPlan::None, None),
};
}
// Store **non câblé** (tests/legacy) : repli gracieux sur l'ancien
// comportement — une conversation sur la cellule ⇒ `Resume`, sinon `None`.
// Garantit zéro régression des tests qui ne câblent pas le store.
return match cell_conversation_id {
Some(conversation_id) => (SessionPlan::Resume { conversation_id }, None),
None => (SessionPlan::None, None),
};
}
// Profils **non structurés** (PTY/TUI) : la cellule porte toujours l'id moteur.
// Comportement **inchangé** depuis T4.
let Some(session) = &profile.session else {
return (SessionPlan::None, None);
};