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

@ -51,6 +51,36 @@ impl ConversationId {
pub const fn as_uuid(&self) -> uuid::Uuid {
self.0
}
/// **Dérivation déterministe et model-agnostic** de l'id de paire pour le couple
/// `{a, b}` (ARCHITECTURE §19.7). **Pure** (aucune I/O, aucun registre) et
/// **insensible à l'ordre** : `for_pair(a, b) == for_pair(b, a)`.
///
/// Sert de **repli stable** quand aucun [`ConversationRegistry`] (stateful) n'est
/// disponible pour matérialiser le fil — typiquement le lancement **direct** d'une
/// cellule structurée neuve par l'utilisateur (`LaunchAgent`, lot P8a) : la cellule
/// doit porter la **même** clé que celle sous laquelle le handoff a été (ou sera)
/// rangé. Pour la paire canonique `User↔Agent`, le résultat est **exactement**
/// l'id du repli de `OrchestratorService::resolve_conversation(None, agent)`
/// (`ConversationId::from_uuid(agent.as_uuid())`), garantissant la cohérence de
/// clé end-to-end sans coupler `LaunchAgent` à l'orchestrateur ni au registre.
///
/// Pour une paire `Agent↔Agent`, l'id est dérivé des deux UUID agent de façon
/// commutative (XOR), stable et déterministe.
#[must_use]
pub fn for_pair(a: ConversationParty, b: ConversationParty) -> Self {
match (a.as_agent(), b.as_agent()) {
// User↔Agent (un seul agent) : aligné sur le repli de `resolve_conversation`.
(Some(agent), None) | (None, Some(agent)) => Self::from_uuid(agent.as_uuid()),
// Agent↔Agent : combinaison commutative des deux ids (insensible à l'ordre).
(Some(x), Some(y)) => {
let xor = x.as_uuid().as_u128() ^ y.as_uuid().as_u128();
Self::from_uuid(uuid::Uuid::from_u128(xor))
}
// User↔User n'existe pas (invariant `Conversation`) : repli sûr non-panic.
(None, None) => Self::from_uuid(uuid::Uuid::nil()),
}
}
}
impl std::fmt::Display for ConversationId {

View File

@ -42,17 +42,70 @@ pub struct LeafCell {
/// The agent to launch automatically in this cell, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<AgentId>,
/// Opaque CLI conversation id, **persistent** across the cell's lifetime
/// (distinct from the ephemeral PTY [`SessionId`]). Enables resuming a CLI
/// conversation after the terminal/PTY has been closed and reopened.
/// **Id de paire IdeA** (`ConversationId`, UUID) de la conversation hébergée par
/// la cellule — pivot **logique** et **model-agnostic** de la reprise (ARCHITECTURE
/// §19.7). C'est sous cette clé que le log canonique et le `handoff.md` sont
/// rangés (`P6b`) puis retrouvés au (re)lancement (`P7`) ; elle **survit** au swap
/// de profil. **Distinct** de l'id de session moteur (resumable Claude/Codex), qui
/// vit désormais dans [`Self::engine_session_id`] / `providers.json`, plus jamais
/// ici. Reste nommé `conversation_id` (compat sérialisation/DTO).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
/// **Cache** de l'id de session **moteur** (resumable du provider courant : id
/// Claude `--resume`, ou l'UUID minté pour `--session-id`) — distinct de l'id de
/// paire ([`Self::conversation_id`]). Additif (`None` par défaut) ; la **source de
/// vérité** du resumable est `providers.json` (ARCHITECTURE §19.7, lot P8b). Sert
/// au plan de session `--resume` (P8c) et à l'inspection/popup, sans jamais
/// polluer la clé logique de la conversation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub engine_session_id: Option<String>,
/// Whether the cell's agent process was running at the moment the cell was
/// last closed. Used to decide whether to auto-resume the agent on reopen.
#[serde(default, skip_serializing_if = "is_false")]
pub agent_was_running: bool,
}
impl LeafCell {
/// Crée une feuille **vide** (ni session, ni agent, ni conversation), porteuse du
/// seul `id`. Constructeur de commodité **additif** : il n'altère aucune
/// construction par littéral existante, mais offre un point d'entrée stable face
/// aux champs additifs (comme [`Self::engine_session_id`]) — les appelants
/// composent ensuite avec les withers `with_*`.
#[must_use]
pub fn new(id: NodeId) -> Self {
Self {
id,
session: None,
agent: None,
conversation_id: None,
engine_session_id: None,
agent_was_running: false,
}
}
/// Wither additif : pose l'agent auto-lancé de la cellule.
#[must_use]
pub fn with_agent(mut self, agent: Option<AgentId>) -> Self {
self.agent = agent;
self
}
/// Wither additif : pose l'**id de paire IdeA** ([`Self::conversation_id`]).
#[must_use]
pub fn with_conversation(mut self, conversation_id: Option<String>) -> Self {
self.conversation_id = conversation_id;
self
}
/// Wither additif : pose le **cache de l'id de session moteur**
/// ([`Self::engine_session_id`]).
#[must_use]
pub fn with_engine_session(mut self, engine_session_id: Option<String>) -> Self {
self.engine_session_id = engine_session_id;
self
}
}
/// A weighted child within a [`SplitContainer`]. The `weight` is a *relative*
/// resizable share; the UI normalises it for rendering.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@ -363,22 +416,14 @@ impl LayoutTree {
let root = map_node(&self.root, &mut |node| {
if let LayoutNode::Leaf(leaf) = node {
if leaf.id == from {
return LayoutNode::Leaf(LeafCell {
id: leaf.id,
session: None,
agent: leaf.agent,
conversation_id: leaf.conversation_id.clone(),
agent_was_running: leaf.agent_was_running,
});
let mut leaf = leaf.clone();
leaf.session = None;
return LayoutNode::Leaf(leaf);
}
if leaf.id == to {
return LayoutNode::Leaf(LeafCell {
id: leaf.id,
session: Some(session),
agent: leaf.agent,
conversation_id: leaf.conversation_id.clone(),
agent_was_running: leaf.agent_was_running,
});
let mut leaf = leaf.clone();
leaf.session = Some(session);
return LayoutNode::Leaf(leaf);
}
}
node.clone()
@ -410,13 +455,9 @@ impl LayoutTree {
if let LayoutNode::Leaf(leaf) = node {
if leaf.id == target {
found = true;
return LayoutNode::Leaf(LeafCell {
id: leaf.id,
session,
agent: leaf.agent,
conversation_id: leaf.conversation_id.clone(),
agent_was_running: leaf.agent_was_running,
});
let mut leaf = leaf.clone();
leaf.session = session;
return LayoutNode::Leaf(leaf);
}
}
node.clone()
@ -455,22 +496,14 @@ impl LayoutTree {
let root = map_node(&self.root, &mut |node| {
if let LayoutNode::Leaf(leaf) = node {
if leaf.session == Some(session) && leaf.id != target {
return LayoutNode::Leaf(LeafCell {
id: leaf.id,
session: None,
agent: leaf.agent,
conversation_id: leaf.conversation_id.clone(),
agent_was_running: leaf.agent_was_running,
});
let mut leaf = leaf.clone();
leaf.session = None;
return LayoutNode::Leaf(leaf);
}
if leaf.id == target {
return LayoutNode::Leaf(LeafCell {
id: leaf.id,
session: Some(session),
agent: leaf.agent,
conversation_id: leaf.conversation_id.clone(),
agent_was_running: leaf.agent_was_running,
});
let mut leaf = leaf.clone();
leaf.session = Some(session);
return LayoutNode::Leaf(leaf);
}
}
node.clone()
@ -498,13 +531,9 @@ impl LayoutTree {
if let LayoutNode::Leaf(leaf) = node {
if leaf.id == target {
found = true;
return LayoutNode::Leaf(LeafCell {
id: leaf.id,
session: leaf.session,
agent,
conversation_id: leaf.conversation_id.clone(),
agent_was_running: leaf.agent_was_running,
});
let mut leaf = leaf.clone();
leaf.agent = agent;
return LayoutNode::Leaf(leaf);
}
}
node.clone()
@ -538,13 +567,48 @@ impl LayoutTree {
if let LayoutNode::Leaf(leaf) = node {
if leaf.id == target {
found = true;
return LayoutNode::Leaf(LeafCell {
id: leaf.id,
session: leaf.session,
agent: leaf.agent,
conversation_id: conversation_id.clone(),
agent_was_running: leaf.agent_was_running,
});
let mut leaf = leaf.clone();
leaf.conversation_id = conversation_id.clone();
return LayoutNode::Leaf(leaf);
}
}
node.clone()
});
if !found {
return Err(LayoutError::NodeNotFound(target));
}
let tree = Self { root };
tree.validate()?;
Ok(tree)
}
/// Sets (or, with `None`, clears) the **engine session id** cache
/// ([`LeafCell::engine_session_id`]) on the leaf `target` — the resumable id of
/// the current provider (id Claude `--resume`, UUID minté pour `--session-id`).
///
/// Jumeau additif de [`Self::set_cell_conversation`] (ARCHITECTURE §19.7) :
/// l'id de paire **logique** reste porté par `conversation_id`, l'id **moteur**
/// est rangé séparément ici (cache ; source de vérité `providers.json`). Ne touche
/// **que** ce champ, laissant `conversation_id` intact — c'est la séparation des
/// deux clés qui corrige l'incohérence P6/P7.
///
/// Pure: returns a new validated tree.
///
/// # Errors
/// - [`LayoutError::NodeNotFound`] if `target` is not a leaf in the tree.
pub fn set_cell_engine_session(
&self,
target: NodeId,
engine_session_id: Option<String>,
) -> Result<Self, LayoutError> {
let mut found = false;
let root = map_node(&self.root, &mut |node| {
if let LayoutNode::Leaf(leaf) = node {
if leaf.id == target {
found = true;
let mut leaf = leaf.clone();
leaf.engine_session_id = engine_session_id.clone();
return LayoutNode::Leaf(leaf);
}
}
node.clone()
@ -570,13 +634,9 @@ impl LayoutTree {
if let LayoutNode::Leaf(leaf) = node {
if leaf.id == target {
found = true;
return LayoutNode::Leaf(LeafCell {
id: leaf.id,
session: leaf.session,
agent: leaf.agent,
conversation_id: leaf.conversation_id.clone(),
agent_was_running: running,
});
let mut leaf = leaf.clone();
leaf.agent_was_running = running;
return LayoutNode::Leaf(leaf);
}
}
node.clone()
@ -686,13 +746,11 @@ impl LayoutTree {
if let Some(agent) = leaf.agent {
let is_host = host_of.get(&agent) == Some(&leaf.id);
if !is_host && (leaf.agent_was_running || leaf.conversation_id.is_some()) {
return LayoutNode::Leaf(LeafCell {
id: leaf.id,
session: leaf.session,
agent: leaf.agent,
conversation_id: None,
agent_was_running: false,
});
let mut leaf = leaf.clone();
leaf.conversation_id = None;
leaf.engine_session_id = None;
leaf.agent_was_running = false;
return LayoutNode::Leaf(leaf);
}
}
}

View File

@ -122,6 +122,7 @@ fn leaf_cell(id: u128, sess: Option<u128>) -> LeafCell {
session: sess.map(session),
agent: None,
conversation_id: None,
engine_session_id: None,
agent_was_running: false,
}
}

View File

@ -20,6 +20,7 @@ fn leaf(id: u128, sess: Option<u128>) -> LeafCell {
session: sess.map(session),
agent: None,
conversation_id: None,
engine_session_id: None,
agent_was_running: false,
}
}
@ -32,6 +33,7 @@ fn leaf_with_resume(id: u128, sess: Option<u128>) -> LeafCell {
session: sess.map(session),
agent: None,
conversation_id: Some("conv-1".to_string()),
engine_session_id: None,
agent_was_running: true,
}
}
@ -674,6 +676,7 @@ fn agent_leaves_collects_only_agent_bearing_leaves() {
session: None,
agent: Some(agent_id(100)),
conversation_id: None,
engine_session_id: None,
agent_was_running: false,
}),
weight: 1.0,
@ -688,6 +691,7 @@ fn agent_leaves_collects_only_agent_bearing_leaves() {
session: None,
agent: Some(agent_id(101)),
conversation_id: None,
engine_session_id: None,
agent_was_running: false,
}),
weight: 1.0,
@ -783,6 +787,7 @@ fn move_session_preserves_resume_fields_on_both_leaves() {
session: Some(session(100)),
agent: None,
conversation_id: Some("conv-from".to_string()),
engine_session_id: None,
agent_was_running: true,
}),
weight: 1.0,
@ -793,6 +798,7 @@ fn move_session_preserves_resume_fields_on_both_leaves() {
session: None,
agent: None,
conversation_id: Some("conv-to".to_string()),
engine_session_id: None,
agent_was_running: true,
}),
weight: 1.0,
@ -842,6 +848,7 @@ fn agent_leaf_full(
session: None,
agent: Some(agent_id(agent)),
conversation_id: conv.map(str::to_string),
engine_session_id: None,
agent_was_running: running,
}
}
@ -1047,6 +1054,7 @@ fn leaf_serde_all_four_combinations_roundtrip() {
session: None,
agent: None,
conversation_id: conv.clone(),
engine_session_id: None,
agent_was_running: running,
};
let json = serde_json::to_string(&cell).unwrap();
@ -1062,6 +1070,7 @@ fn leaf_serde_omits_defaults() {
session: None,
agent: None,
conversation_id: None,
engine_session_id: None,
agent_was_running: false,
};
let json = serde_json::to_string(&cell).unwrap();
@ -1082,6 +1091,7 @@ fn leaf_serde_field_names_are_camel_case_when_present() {
session: None,
agent: None,
conversation_id: Some("c".to_string()),
engine_session_id: None,
agent_was_running: true,
};
let json = serde_json::to_string(&cell).unwrap();
@ -1108,6 +1118,7 @@ fn leaf_can_carry_conversation_without_session_and_inversely() {
session: None,
agent: None,
conversation_id: Some("c".to_string()),
engine_session_id: None,
agent_was_running: false,
};
let a_back: LeafCell = serde_json::from_str(&serde_json::to_string(&a).unwrap()).unwrap();
@ -1119,6 +1130,7 @@ fn leaf_can_carry_conversation_without_session_and_inversely() {
session: Some(session(7)),
agent: None,
conversation_id: None,
engine_session_id: None,
agent_was_running: false,
};
let b_back: LeafCell = serde_json::from_str(&serde_json::to_string(&b).unwrap()).unwrap();

View File

@ -406,6 +406,7 @@ fn layout_roundtrip() {
session: Some(session(100)),
agent: None,
conversation_id: None,
engine_session_id: None,
agent_was_running: false,
}),
weight: 1.5,
@ -416,6 +417,7 @@ fn layout_roundtrip() {
session: None,
agent: None,
conversation_id: None,
engine_session_id: None,
agent_was_running: false,
}),
weight: 2.5,
@ -441,6 +443,7 @@ fn leaf_with_agent_roundtrip_and_omits_null() {
session: None,
agent: Some(AgentId::from_uuid(agent_uuid)),
conversation_id: None,
engine_session_id: None,
agent_was_running: false,
}));
let rt = roundtrip(&tree);
@ -460,6 +463,7 @@ fn leaf_with_agent_roundtrip_and_omits_null() {
session: None,
agent: None,
conversation_id: None,
engine_session_id: None,
agent_was_running: false,
}));
let json2 = serde_json::to_string(&tree_no_agent).unwrap();

View File

@ -20,6 +20,7 @@ fn leaf_tree() -> LayoutTree {
session: None,
agent: None,
conversation_id: None,
engine_session_id: None,
agent_was_running: false,
}))
}