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:
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user