From e583b2b49fc85aca7daa582ef8ca1e6cc635cb72 Mon Sep 17 00:00:00 2001 From: Blomios Date: Fri, 12 Jun 2026 18:10:50 +0200 Subject: [PATCH] =?UTF-8?q?feat(persistence):=20P8a=20=E2=80=94=20coh?= =?UTF-8?q?=C3=A9rence=20de=20cl=C3=A9=20conversation=20(id=20de=20paire?= =?UTF-8?q?=20stable)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/app-tauri/src/dto.rs | 12 ++ crates/app-tauri/tests/dto.rs | 3 + crates/app-tauri/tests/dto_agents.rs | 2 + crates/app-tauri/tests/dto_chat.rs | 3 + crates/application/src/agent/lifecycle.rs | 76 +++++-- crates/application/src/layout/store.rs | 8 +- crates/application/src/layout/usecases.rs | 8 +- .../application/src/orchestrator/service.rs | 5 +- .../application/tests/change_agent_profile.rs | 1 + crates/application/tests/layout_usecases.rs | 1 + .../tests/list_resumable_agents.rs | 1 + crates/application/tests/reconcile_layouts.rs | 1 + .../tests/snapshot_running_agents.rs | 1 + .../application/tests/structured_launch_d3.rs | 16 +- crates/application/tests/window_usecases.rs | 1 + crates/domain/src/conversation.rs | 30 +++ crates/domain/src/layout.rs | 190 ++++++++++++------ crates/domain/tests/agent_profile_a0.rs | 1 + crates/domain/tests/layout.rs | 12 ++ crates/domain/tests/serde_roundtrip.rs | 4 + crates/domain/tests/window.rs | 1 + crates/infrastructure/src/conversation/mod.rs | 13 +- 22 files changed, 284 insertions(+), 106 deletions(-) diff --git a/crates/app-tauri/src/dto.rs b/crates/app-tauri/src/dto.rs index 6734dda..de74a2b 100644 --- a/crates/app-tauri/src/dto.rs +++ b/crates/app-tauri/src/dto.rs @@ -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, + /// **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, /// 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 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 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 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 for TerminalSessionDto { rows: s.pty_size.rows, cols: s.pty_size.cols, assigned_conversation_id: None, + engine_session_id: None, cell_kind: CellKind::Pty, } } diff --git a/crates/app-tauri/tests/dto.rs b/crates/app-tauri/tests/dto.rs index 9c4bfb2..c1cb705 100644 --- a/crates/app-tauri/tests/dto.rs +++ b/crates/app-tauri/tests/dto.rs @@ -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), diff --git a/crates/app-tauri/tests/dto_agents.rs b/crates/app-tauri/tests/dto_agents.rs index b22ff3b..74bdb5d 100644 --- a/crates/app-tauri/tests/dto_agents.rs +++ b/crates/app-tauri/tests/dto_agents.rs @@ -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); diff --git a/crates/app-tauri/tests/dto_chat.rs b/crates/app-tauri/tests/dto_chat.rs index 016a0e5..6650238 100644 --- a/crates/app-tauri/tests/dto_chat.rs +++ b/crates/app-tauri/tests/dto_chat.rs @@ -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(); diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index a2799a9..6a5bab6 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -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, + /// 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, /// 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, + pair_conversation_id: String, node_id: Option, size: PtySize, ) -> Result { @@ -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, }), }) } diff --git a/crates/application/src/layout/store.rs b/crates/application/src/layout/store.rs index 65be50f..e0527bc 100644 --- a/crates/application/src/layout/store.rs +++ b/crates/application/src/layout/store.rs @@ -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. diff --git a/crates/application/src/layout/usecases.rs b/crates/application/src/layout/usecases.rs index 7f5cc14..773e63e 100644 --- a/crates/application/src/layout/usecases.rs +++ b/crates/application/src/layout/usecases.rs @@ -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 { diff --git a/crates/application/src/orchestrator/service.rs b/crates/application/src/orchestrator/service.rs index 6eb13f8..82d55c2 100644 --- a/crates/application/src/orchestrator/service.rs +++ b/crates/application/src/orchestrator/service.rs @@ -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), } } diff --git a/crates/application/tests/change_agent_profile.rs b/crates/application/tests/change_agent_profile.rs index 30edfeb..0d4ccba 100644 --- a/crates/application/tests/change_agent_profile.rs +++ b/crates/application/tests/change_agent_profile.rs @@ -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, } } diff --git a/crates/application/tests/layout_usecases.rs b/crates/application/tests/layout_usecases.rs index b1a17da..e3668bd 100644 --- a/crates/application/tests/layout_usecases.rs +++ b/crates/application/tests/layout_usecases.rs @@ -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, }) } diff --git a/crates/application/tests/list_resumable_agents.rs b/crates/application/tests/list_resumable_agents.rs index f8b3bb4..685b703 100644 --- a/crates/application/tests/list_resumable_agents.rs +++ b/crates/application/tests/list_resumable_agents.rs @@ -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, } } diff --git a/crates/application/tests/reconcile_layouts.rs b/crates/application/tests/reconcile_layouts.rs index 0c8dd5c..f2bc89e 100644 --- a/crates/application/tests/reconcile_layouts.rs +++ b/crates/application/tests/reconcile_layouts.rs @@ -161,6 +161,7 @@ fn agent_leaf(node: NodeId, agent: Option, conv: Option<&str>, running: session: None, agent, conversation_id: conv.map(str::to_string), + engine_session_id: None, agent_was_running: running, } } diff --git a/crates/application/tests/snapshot_running_agents.rs b/crates/application/tests/snapshot_running_agents.rs index 32eec13..a768ac5 100644 --- a/crates/application/tests/snapshot_running_agents.rs +++ b/crates/application/tests/snapshot_running_agents.rs @@ -181,6 +181,7 @@ fn agent_leaf(node: NodeId, agent: Option) -> LeafCell { session: None, agent, conversation_id: None, + engine_session_id: None, agent_was_running: false, } } diff --git a/crates/application/tests/structured_launch_d3.rs b/crates/application/tests/structured_launch_d3.rs index 6bc938a..3416680 100644 --- a/crates/application/tests/structured_launch_d3.rs +++ b/crates/application/tests/structured_launch_d3.rs @@ -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é : diff --git a/crates/application/tests/window_usecases.rs b/crates/application/tests/window_usecases.rs index 15d6a45..300a2a2 100644 --- a/crates/application/tests/window_usecases.rs +++ b/crates/application/tests/window_usecases.rs @@ -62,6 +62,7 @@ fn tab(n: u128) -> Tab { session: None, agent: None, conversation_id: None, + engine_session_id: None, agent_was_running: false, })), } diff --git a/crates/domain/src/conversation.rs b/crates/domain/src/conversation.rs index 8966cb1..47efb5e 100644 --- a/crates/domain/src/conversation.rs +++ b/crates/domain/src/conversation.rs @@ -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 { diff --git a/crates/domain/src/layout.rs b/crates/domain/src/layout.rs index f2fc325..f5dbec5 100644 --- a/crates/domain/src/layout.rs +++ b/crates/domain/src/layout.rs @@ -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, - /// 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, + /// **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, /// 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) -> 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) -> 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) -> 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, + ) -> Result { + 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); } } } diff --git a/crates/domain/tests/agent_profile_a0.rs b/crates/domain/tests/agent_profile_a0.rs index d92caff..83e50de 100644 --- a/crates/domain/tests/agent_profile_a0.rs +++ b/crates/domain/tests/agent_profile_a0.rs @@ -122,6 +122,7 @@ fn leaf_cell(id: u128, sess: Option) -> LeafCell { session: sess.map(session), agent: None, conversation_id: None, + engine_session_id: None, agent_was_running: false, } } diff --git a/crates/domain/tests/layout.rs b/crates/domain/tests/layout.rs index 155cc7a..875c24a 100644 --- a/crates/domain/tests/layout.rs +++ b/crates/domain/tests/layout.rs @@ -20,6 +20,7 @@ fn leaf(id: u128, sess: Option) -> 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) -> 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(); diff --git a/crates/domain/tests/serde_roundtrip.rs b/crates/domain/tests/serde_roundtrip.rs index 8593a90..0cabddb 100644 --- a/crates/domain/tests/serde_roundtrip.rs +++ b/crates/domain/tests/serde_roundtrip.rs @@ -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(); diff --git a/crates/domain/tests/window.rs b/crates/domain/tests/window.rs index b88ade9..c299c93 100644 --- a/crates/domain/tests/window.rs +++ b/crates/domain/tests/window.rs @@ -20,6 +20,7 @@ fn leaf_tree() -> LayoutTree { session: None, agent: None, conversation_id: None, + engine_session_id: None, agent_was_running: false, })) } diff --git a/crates/infrastructure/src/conversation/mod.rs b/crates/infrastructure/src/conversation/mod.rs index 74f4e3f..fc2981b 100644 --- a/crates/infrastructure/src/conversation/mod.rs +++ b/crates/infrastructure/src/conversation/mod.rs @@ -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);