feat(persistence): P8a — cohérence de clé conversation (id de paire stable)

Corrige l'incohérence P6/P7 : la cellule et la persistance partagent désormais
le MÊME id de paire IdeA, déterministe et stable au redémarrage — la clé sous
laquelle P6b sauve log/handoff == celle sous laquelle P7 les charge.

- domaine : ConversationId::for_pair(a,b) pur/déterministe (User↔Agent = uuid
  agent ; Agent↔Agent = XOR commutatif) ; LeafCell gagne engine_session_id
  (cache resumable moteur, additif serde default)
- infrastructure : InMemoryConversationRegistry::resolve utilise for_pair au
  lieu de new_random() → id recalculable sans état, identique après restart
- application : launch_structured persiste l'id de paire sur la cellule (et l'id
  moteur sur engine_session_id) ; cellule neuve ⇒ dérive for_pair(User,agent) ;
  resolve_conversation repli factorisé sur for_pair ; withers layout clone-and-
  mutate (préservent les champs additifs)
- app-tauri : engine_session_id propagé dans les DTO

Suites domain/infra/application/app-tauri vertes (assertion structured_launch_d3
recodée sur le contrat). Test déterminisme/restart dédié à ajouter au retour de
quota agent (binôme Test interrompu par limite de session).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 18:10:50 +02:00
parent 19ba77824f
commit e583b2b49f
22 changed files with 284 additions and 106 deletions

View File

@ -20,9 +20,9 @@ use domain::ports::{
};
use domain::{
Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, ConversationId,
DomainEvent, Handoff, HandoffStore, ManifestEntry, MarkdownDoc, MemoryIndexEntry, MemoryType,
NodeId, ProfileId, Project, ProjectPath, PtySize, SessionId, SessionKind, SessionStatus, Skill,
TerminalSession,
ConversationParty, DomainEvent, Handoff, HandoffStore, ManifestEntry, MarkdownDoc,
MemoryIndexEntry, MemoryType, NodeId, ProfileId, Project, ProjectPath, PtySize, SessionId,
SessionKind, SessionStatus, Skill, TerminalSession,
};
use crate::error::AppError;
@ -737,13 +737,22 @@ pub struct LaunchAgentOutput {
/// structurée (cf. [`Self::structured`]), conformément à §17.4. Garde la sortie
/// **non cassante** pour A/B et le câblage existant.
pub session: TerminalSession,
/// The conversation id **assigned** by this launch, when the profile supports
/// session assignment and the cell had none yet. The caller persists it on the
/// hosting leaf (via the layout flow, e.g. `set_cell_conversation`) so the next
/// open resumes instead of re-assigning. `None` when nothing new was assigned
/// (resume of an existing id, degraded mode, or a profile without a session
/// block) — the caller has nothing to persist.
/// L'**id de paire IdeA** (pivot **logique** de la conversation, ARCHITECTURE
/// §19.7) assigné par ce lancement, quand la cellule n'en portait pas encore. Le
/// caller le persiste sur la feuille hôte via `set_cell_conversation` : c'est la
/// clé qui retrouve **log + handoff** au (re)lancement (P7) et survit au swap de
/// profil. **Ce n'est plus l'id de session moteur** (qui part désormais dans
/// [`Self::engine_session_id`]). `None` quand rien de neuf n'a été assigné (reprise
/// d'un id existant, mode dégradé, ou profil sans session) — rien à persister.
pub assigned_conversation_id: Option<String>,
/// L'**id de session moteur** (resumable du provider : id Claude `--resume`, ou
/// l'UUID minté pour `--session-id`) exposé/attribué par ce lancement, **distinct**
/// de l'id de paire (ARCHITECTURE §19.7, lot P8a). Le caller le range dans le
/// **cache** `LeafCell::engine_session_id` (via `set_cell_engine_session`) — jamais
/// sur `conversation_id`. La **source de vérité** du resumable reste `providers.json`
/// (lot P8b, hors P8a). `None` pour le chemin PTY/legacy ou quand le moteur n'expose
/// aucun id : rien à cacher.
pub engine_session_id: Option<String>,
/// Descripteur de la session **structurée**, présent **uniquement** quand ce
/// lancement a routé vers un agent IA structuré (`profile.structured_adapter =
/// Some(_)`, ARCHITECTURE §17.4). `None` pour le chemin PTY/terminal brut
@ -1013,6 +1022,7 @@ impl LaunchAgent {
return Ok(LaunchAgentOutput {
session,
assigned_conversation_id: None,
engine_session_id: None,
structured: None,
});
}
@ -1031,6 +1041,7 @@ impl LaunchAgent {
return Ok(LaunchAgentOutput {
session,
assigned_conversation_id: None,
engine_session_id: None,
structured: None,
});
}
@ -1062,6 +1073,8 @@ impl LaunchAgent {
return Ok(LaunchAgentOutput {
session: structured_snapshot(&existing, input.agent_id, node_id, size),
assigned_conversation_id: None,
// Rebind de vue : aucune (ré)assignation ⇒ rien de neuf à cacher.
engine_session_id: None,
structured: Some(StructuredSessionDescriptor {
session_id: existing.id(),
agent_id: input.agent_id,
@ -1183,6 +1196,24 @@ impl LaunchAgent {
self.structured.as_ref(),
profile.structured_adapter.is_some(),
) {
// ── Clé **logique** de la cellule = id de paire IdeA (ARCHITECTURE §19.7,
// lot P8a) ──
// - cellule porteuse d'un `conversation_id` (resume, ou lancement délégué
// qui a déjà injecté l'id de paire A↔B) ⇒ c'est **déjà** l'id de paire,
// on le conserve tel quel ;
// - cellule structurée **neuve** (lancement direct utilisateur, aucun
// requester) ⇒ on **dérive** `pair(User, agent)` via la fonction domaine
// pure partagée avec `resolve_conversation` (aucun couplage à
// l'orchestrateur). C'est cet id — et **non** l'id de session moteur —
// qui retrouve log + handoff au (re)lancement (P7) et survit au swap.
let pair_conversation_id =
input.conversation_id.clone().unwrap_or_else(|| {
ConversationId::for_pair(
ConversationParty::User,
ConversationParty::agent(agent.id),
)
.to_string()
});
return self
.launch_structured(
factory.as_ref(),
@ -1192,7 +1223,7 @@ impl LaunchAgent {
&prepared,
&run_dir,
&session_plan,
assigned_conversation_id,
pair_conversation_id,
input.node_id,
size,
)
@ -1227,6 +1258,8 @@ impl LaunchAgent {
Ok(LaunchAgentOutput {
session,
assigned_conversation_id,
// Chemin PTY/terminal brut : pas de session moteur structurée à cacher.
engine_session_id: None,
structured: None,
})
}
@ -1250,7 +1283,7 @@ impl LaunchAgent {
prepared: &PreparedContext,
run_dir: &ProjectPath,
session_plan: &SessionPlan,
assigned_conversation_id: Option<String>,
pair_conversation_id: String,
node_id: Option<NodeId>,
size: PtySize,
) -> Result<LaunchAgentOutput, AppError> {
@ -1266,10 +1299,15 @@ impl LaunchAgent {
// amont sur les deux registres).
structured.insert(Arc::clone(&session), agent.id, node_id);
// L'id de conversation à persister : celui que le moteur expose s'il en a un,
// sinon l'id pré-attribué par le plan de session (§15).
let engine_conversation_id = session.conversation_id();
let conversation_id = engine_conversation_id.clone().or(assigned_conversation_id);
// ── SÉPARATION DES DEUX CLÉS (ARCHITECTURE §19.7, lot P8a) ──
// - **id de paire** (`pair_conversation_id`) : clé **logique** persistée sur
// `LeafCell.conversation_id`. C'est lui qui retrouve log + handoff (P7) et
// survit au swap. Stable, indépendant du provider.
// - **id de session moteur** (`session.conversation_id()`) : resumable du
// provider (Claude/Codex). Rangé **séparément** dans le cache
// `LeafCell.engine_session_id` (via `set_cell_engine_session`) — **jamais**
// sur `conversation_id`. `None` si le moteur n'expose encore aucun id.
let engine_session_id = session.conversation_id();
let snapshot = structured_snapshot(&session, agent.id, node_id, size);
@ -1280,13 +1318,15 @@ impl LaunchAgent {
Ok(LaunchAgentOutput {
session: snapshot,
// Le caller persiste cet id sur la cellule (pivot de reprise §15.2).
assigned_conversation_id: conversation_id.clone(),
// Cellule ⇐ id de **paire** (pivot logique de reprise §15.2 / §19.7).
assigned_conversation_id: Some(pair_conversation_id),
// Cache moteur ⇐ id resumable du provider (distinct de la paire).
engine_session_id: engine_session_id.clone(),
structured: Some(StructuredSessionDescriptor {
session_id,
agent_id: agent.id,
node_id,
conversation_id: engine_conversation_id,
conversation_id: engine_session_id,
}),
})
}

View File

@ -99,13 +99,7 @@ impl LayoutsDoc {
/// The default single-cell layout tree (one empty leaf).
#[must_use]
pub fn default_tree() -> LayoutTree {
LayoutTree::single(LeafCell {
id: NodeId::new_random(),
session: None,
agent: None,
conversation_id: None,
agent_was_running: false,
})
LayoutTree::single(LeafCell::new(NodeId::new_random()))
}
/// Builds a fresh doc holding one layout (`tree`) made active.

View File

@ -112,13 +112,7 @@ impl LayoutOperation {
} => tree.split(
*target,
*direction,
LeafCell {
id: *new_leaf,
session: None,
agent: None,
conversation_id: None,
agent_was_running: false,
},
LeafCell::new(*new_leaf),
*container,
),
Self::Merge {

View File

@ -937,7 +937,10 @@ impl OrchestratorService {
let right = ConversationParty::agent(target);
match &self.conversations {
Some(reg) => reg.resolve(left, right).id,
None => domain::conversation::ConversationId::from_uuid(target.as_uuid()),
// Repli pur déterministe partagé avec `LaunchAgent` (ARCHITECTURE §19.7,
// lot P8a) : la même paire dérive la même clé de conversation des deux
// côtés (sauvegarde du handoff ici, dérivation côté cellule là-bas).
None => domain::conversation::ConversationId::for_pair(left, right),
}
}