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:
@ -237,6 +237,14 @@ pub struct TerminalSessionDto {
|
||||
/// resumes. `None` for a plain terminal, a resume, or a degraded launch.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub assigned_conversation_id: Option<String>,
|
||||
/// **Id de session moteur** (resumable du provider courant) exposé par ce
|
||||
/// lancement, **distinct** de l'id de paire `assignedConversationId` (ARCHITECTURE
|
||||
/// §19.7, lot P8a). Le front le range dans le **cache** `engineSessionId` de la
|
||||
/// cellule (jamais sur `conversationId`). Absent pour un terminal nu, une reprise,
|
||||
/// ou quand le moteur n'expose encore aucun id. La source de vérité du resumable
|
||||
/// est `providers.json` (lot P8b).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub engine_session_id: Option<String>,
|
||||
/// How the frontend should render the cell hosting this session (§17.6):
|
||||
/// [`CellKind::Chat`] for a structured AI session (an `AgentChatView` driven by
|
||||
/// `agent_send`/`reattach_agent_chat`), [`CellKind::Pty`] for a raw terminal
|
||||
@ -269,6 +277,7 @@ impl From<OpenTerminalOutput> for TerminalSessionDto {
|
||||
rows: s.pty_size.rows,
|
||||
cols: s.pty_size.cols,
|
||||
assigned_conversation_id: None,
|
||||
engine_session_id: None,
|
||||
cell_kind: CellKind::Pty,
|
||||
}
|
||||
}
|
||||
@ -1165,6 +1174,7 @@ pub struct LaunchAgentRequestDto {
|
||||
impl From<LaunchAgentOutput> for TerminalSessionDto {
|
||||
fn from(out: LaunchAgentOutput) -> Self {
|
||||
let assigned_conversation_id = out.assigned_conversation_id;
|
||||
let engine_session_id = out.engine_session_id;
|
||||
// §17.6: the cell kind is *derived* from the launch routing. A structured
|
||||
// descriptor (`structured: Some(..)`) means LaunchAgent routed to an
|
||||
// AgentSession ⇒ chat cell; its absence means the PTY/terminal path ⇒
|
||||
@ -1181,6 +1191,7 @@ impl From<LaunchAgentOutput> for TerminalSessionDto {
|
||||
rows: s.pty_size.rows,
|
||||
cols: s.pty_size.cols,
|
||||
assigned_conversation_id,
|
||||
engine_session_id,
|
||||
cell_kind,
|
||||
}
|
||||
}
|
||||
@ -1257,6 +1268,7 @@ impl From<TerminalSession> for TerminalSessionDto {
|
||||
rows: s.pty_size.rows,
|
||||
cols: s.pty_size.cols,
|
||||
assigned_conversation_id: None,
|
||||
engine_session_id: None,
|
||||
cell_kind: CellKind::Pty,
|
||||
}
|
||||
}
|
||||
|
||||
@ -308,6 +308,7 @@ fn layout_dto_serialises_camelcase_tagged_tree() {
|
||||
session: None,
|
||||
agent: None,
|
||||
conversation_id: None,
|
||||
engine_session_id: None,
|
||||
agent_was_running: false,
|
||||
});
|
||||
let dto = LayoutDto::from(LoadLayoutOutput {
|
||||
@ -409,6 +410,7 @@ fn layout_dto_round_trips_a_split_tree_shape() {
|
||||
session: None,
|
||||
agent: None,
|
||||
conversation_id: None,
|
||||
engine_session_id: None,
|
||||
agent_was_running: false,
|
||||
})
|
||||
.split(
|
||||
@ -419,6 +421,7 @@ fn layout_dto_round_trips_a_split_tree_shape() {
|
||||
session: None,
|
||||
agent: None,
|
||||
conversation_id: None,
|
||||
engine_session_id: None,
|
||||
agent_was_running: false,
|
||||
},
|
||||
nid(9),
|
||||
|
||||
@ -307,6 +307,7 @@ fn launch_agent_output_maps_to_terminal_session_dto() {
|
||||
let out = LaunchAgentOutput {
|
||||
session,
|
||||
assigned_conversation_id: None,
|
||||
engine_session_id: None,
|
||||
structured: None,
|
||||
};
|
||||
let dto = TerminalSessionDto::from(out);
|
||||
@ -351,6 +352,7 @@ fn launch_agent_output_propagates_assigned_conversation_id() {
|
||||
let out = LaunchAgentOutput {
|
||||
session,
|
||||
assigned_conversation_id: Some("conv-xyz".to_owned()),
|
||||
engine_session_id: None,
|
||||
structured: None,
|
||||
};
|
||||
let dto = TerminalSessionDto::from(out);
|
||||
|
||||
@ -149,6 +149,7 @@ fn launch_output_with_structured_descriptor_derives_chat_cell_kind() {
|
||||
let out = LaunchAgentOutput {
|
||||
session,
|
||||
assigned_conversation_id: None,
|
||||
engine_session_id: None,
|
||||
structured: Some(descriptor),
|
||||
};
|
||||
let dto = TerminalSessionDto::from(out);
|
||||
@ -164,6 +165,7 @@ fn launch_output_without_structured_descriptor_derives_pty_cell_kind() {
|
||||
let out = LaunchAgentOutput {
|
||||
session,
|
||||
assigned_conversation_id: None,
|
||||
engine_session_id: None,
|
||||
structured: None,
|
||||
};
|
||||
let dto = TerminalSessionDto::from(out);
|
||||
@ -197,6 +199,7 @@ fn pty_launch_output_serialises_cellkind_pty_without_breaking_existing_keys() {
|
||||
let out = LaunchAgentOutput {
|
||||
session,
|
||||
assigned_conversation_id: None,
|
||||
engine_session_id: None,
|
||||
structured: None,
|
||||
};
|
||||
let v = serde_json::to_value(TerminalSessionDto::from(out)).unwrap();
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -528,6 +528,7 @@ fn agent_leaf(
|
||||
session: None,
|
||||
agent,
|
||||
conversation_id: conversation_id.map(str::to_owned),
|
||||
engine_session_id: None,
|
||||
agent_was_running,
|
||||
}
|
||||
}
|
||||
|
||||
@ -198,6 +198,7 @@ fn single_leaf(node_id: NodeId) -> LayoutTree {
|
||||
session: None,
|
||||
agent: None,
|
||||
conversation_id: None,
|
||||
engine_session_id: None,
|
||||
agent_was_running: false,
|
||||
})
|
||||
}
|
||||
|
||||
@ -328,6 +328,7 @@ fn agent_leaf(
|
||||
session: None,
|
||||
agent,
|
||||
conversation_id: conversation_id.map(str::to_owned),
|
||||
engine_session_id: None,
|
||||
agent_was_running,
|
||||
}
|
||||
}
|
||||
|
||||
@ -161,6 +161,7 @@ fn agent_leaf(node: NodeId, agent: Option<AgentId>, conv: Option<&str>, running:
|
||||
session: None,
|
||||
agent,
|
||||
conversation_id: conv.map(str::to_string),
|
||||
engine_session_id: None,
|
||||
agent_was_running: running,
|
||||
}
|
||||
}
|
||||
|
||||
@ -181,6 +181,7 @@ fn agent_leaf(node: NodeId, agent: Option<AgentId>) -> LeafCell {
|
||||
session: None,
|
||||
agent,
|
||||
conversation_id: None,
|
||||
engine_session_id: None,
|
||||
agent_was_running: false,
|
||||
}
|
||||
}
|
||||
|
||||
@ -741,8 +741,20 @@ async fn structured_launch_starts_session_registers_no_pty_spawn() {
|
||||
out.session.kind,
|
||||
SessionKind::Agent { agent_id } if agent_id == f.agent.id
|
||||
));
|
||||
// L'id de conversation moteur est exposé pour persistance sur la cellule.
|
||||
assert_eq!(out.assigned_conversation_id.as_deref(), Some("engine-conv"));
|
||||
// P8a (ARCHITECTURE §19.7) : la CELLULE porte l'**id de paire IdeA**, pas l'id
|
||||
// moteur. Cellule neuve, lancement direct (aucun requester) ⇒ `pair(User, agent)`
|
||||
// dérivé via `ConversationId::for_pair` = l'UUID de l'agent (`aid(1)`).
|
||||
assert_eq!(
|
||||
out.assigned_conversation_id.as_deref(),
|
||||
Some("00000000-0000-0000-0000-000000000001"),
|
||||
"cell carries the IdeA pair id (pivot logique), not the engine resumable"
|
||||
);
|
||||
// L'id de session MOTEUR (resumable provider) part dans le cache séparé.
|
||||
assert_eq!(
|
||||
out.engine_session_id.as_deref(),
|
||||
Some("engine-conv"),
|
||||
"engine resumable id is cached separately from the pair key"
|
||||
);
|
||||
}
|
||||
|
||||
/// Profil **non structuré** (même câblage structuré présent) ⇒ chemin PTY inchangé :
|
||||
|
||||
@ -62,6 +62,7 @@ fn tab(n: u128) -> Tab {
|
||||
session: None,
|
||||
agent: None,
|
||||
conversation_id: None,
|
||||
engine_session_id: None,
|
||||
agent_was_running: false,
|
||||
})),
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -20,6 +20,7 @@ fn leaf_tree() -> LayoutTree {
|
||||
session: None,
|
||||
agent: None,
|
||||
conversation_id: None,
|
||||
engine_session_id: None,
|
||||
agent_was_running: false,
|
||||
}))
|
||||
}
|
||||
|
||||
@ -90,11 +90,14 @@ impl ConversationRegistry for InMemoryConversationRegistry {
|
||||
.cloned()
|
||||
.expect("by_pair id always present in by_id");
|
||||
}
|
||||
// Lazy create: mint a fresh Dormant conversation for the pair. The pair is
|
||||
// valid by construction at call sites (resolve is only asked for real pairs);
|
||||
// `try_new` still guards the invariants, and on the (impossible) error we fall
|
||||
// back to a normalised pair to never panic in production.
|
||||
let id = ConversationId::new_random();
|
||||
// Lazy create: mint a Dormant conversation for the pair. The id is **derived
|
||||
// deterministically** from the pair (`ConversationId::for_pair`, ARCHITECTURE
|
||||
// §19.7) so the **same pair always yields the same id across registry instances**
|
||||
// (i.e. it survives an IDE restart with an empty `by_pair`/`by_id`). This keeps
|
||||
// the persistence key stable and aligned with `LaunchAgent` (P8a) / the
|
||||
// `resolve_conversation` fallback. The pair is valid by construction at call
|
||||
// sites; `try_new` still guards the invariants.
|
||||
let id = ConversationId::for_pair(key.0, key.1);
|
||||
let conv = Conversation::try_new(id, key.0, key.1)
|
||||
.expect("pair_key yields a valid distinct/≤1-user pair");
|
||||
inner.by_pair.insert(key, id);
|
||||
|
||||
Reference in New Issue
Block a user