Sauvegarde de l'arbre de travail en cours (persistance P8, conversations C-series, write-portal frontend, médiation d'entrée) avant d'attaquer le support de la délégation inter-agents pour les profils Codex. Le round-trip inter-agent question/réponse est couvert sans tokens par les tests loopback existants (state::mcp_e2e_loopback_tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
455 lines
16 KiB
Rust
455 lines
16 KiB
Rust
//! Conversation-by-pair domain model (cadrage « conversation par paire », lot C1/C2).
|
|
//!
|
|
//! A [`Conversation`] is a **thread between two distinct parties** with its own
|
|
//! I/O session. Its identity is the **unordered pair** `{left, right}`: the same
|
|
//! two parties always denote the same conversation (this is the key to the
|
|
//! *lazy materialisation* the [`ConversationRegistry`] performs — `resolve(a, b)`
|
|
//! and `resolve(b, a)` yield the same [`ConversationId`]).
|
|
//!
|
|
//! This module is **pure** (ARCHITECTURE dependency rule): no `tokio`, no
|
|
//! `std::fs`, no `std::process`. It owns the value objects and entities plus the
|
|
//! [`ConversationRegistry`] port (a trait the application depends on; the infra
|
|
//! provides `InMemoryConversationRegistry`). The session handle is held only as a
|
|
//! [`SessionRef`] (a reference to a [`crate::terminal::TerminalSession`]); the
|
|
//! domain never holds the PTY itself.
|
|
//!
|
|
//! ## Why this exists
|
|
//!
|
|
//! The previous coupling «1 live session / agent» was ambiguous once an agent can
|
|
//! take part in several threads (`session-registry-agent-ambiguity`). Here the live
|
|
//! session is keyed **by conversation**, so a delegation `A↔B` never borrows the
|
|
//! `User↔B` thread — the context leak is closed by construction.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::ids::{AgentId, SessionId};
|
|
|
|
/// Identifies one [`Conversation`].
|
|
///
|
|
/// Newtype around [`uuid::Uuid`], calqué sur [`crate::ids::AgentId`] /
|
|
/// [`crate::mailbox::TicketId`]. Minted by the [`ConversationRegistry`] when a pair
|
|
/// is first resolved; stable for the lifetime of that pair's thread.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
|
#[serde(transparent)]
|
|
pub struct ConversationId(pub uuid::Uuid);
|
|
|
|
impl ConversationId {
|
|
/// Wraps an existing [`uuid::Uuid`].
|
|
#[must_use]
|
|
pub const fn from_uuid(id: uuid::Uuid) -> Self {
|
|
Self(id)
|
|
}
|
|
|
|
/// Mints a fresh random conversation id.
|
|
#[must_use]
|
|
pub fn new_random() -> Self {
|
|
Self(uuid::Uuid::new_v4())
|
|
}
|
|
|
|
/// Returns the inner [`uuid::Uuid`].
|
|
#[must_use]
|
|
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 {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "{}", self.0)
|
|
}
|
|
}
|
|
|
|
/// One end of a [`Conversation`].
|
|
///
|
|
/// Invariant (enforced by [`Conversation::try_new`]): a conversation relates **two
|
|
/// distinct** parties and carries **at most one** [`ConversationParty::User`]
|
|
/// (never `User↔User`, never `Agent(x)↔Agent(x)`).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase", tag = "kind")]
|
|
pub enum ConversationParty {
|
|
/// The human operator — a single logical instance on IdeA's side.
|
|
User,
|
|
/// A project agent.
|
|
#[serde(rename_all = "camelCase")]
|
|
Agent {
|
|
/// The agent on this end.
|
|
agent_id: AgentId,
|
|
},
|
|
}
|
|
|
|
impl ConversationParty {
|
|
/// Convenience constructor for an agent party.
|
|
#[must_use]
|
|
pub const fn agent(agent_id: AgentId) -> Self {
|
|
Self::Agent { agent_id }
|
|
}
|
|
|
|
/// Returns the [`AgentId`] when this party is an agent.
|
|
#[must_use]
|
|
pub const fn as_agent(&self) -> Option<AgentId> {
|
|
match self {
|
|
Self::Agent { agent_id } => Some(*agent_id),
|
|
Self::User => None,
|
|
}
|
|
}
|
|
|
|
/// Whether this party is the human user.
|
|
#[must_use]
|
|
pub const fn is_user(&self) -> bool {
|
|
matches!(self, Self::User)
|
|
}
|
|
}
|
|
|
|
/// Errors raised when constructing a [`Conversation`].
|
|
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
|
pub enum ConversationError {
|
|
/// The two ends are the **same** party (`left == right`).
|
|
#[error("a conversation must relate two distinct parties")]
|
|
SameParty,
|
|
/// **Both** ends are [`ConversationParty::User`] — at most one is allowed.
|
|
#[error("a conversation may carry at most one User party")]
|
|
TwoUsers,
|
|
}
|
|
|
|
/// The I/O state of a conversation thread.
|
|
///
|
|
/// The domain holds only a [`SessionRef`]; the live PTY/structured stream is an
|
|
/// infrastructure concern (`session-registry`).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase", tag = "state")]
|
|
pub enum ConversationSession {
|
|
/// Never launched, or suspended (only `resumable_id` is retained on the
|
|
/// [`Conversation`]).
|
|
Dormant,
|
|
/// A live I/O stream backs this thread.
|
|
#[serde(rename_all = "camelCase")]
|
|
Live {
|
|
/// Reference to the backing [`crate::terminal::TerminalSession`].
|
|
handle_ref: SessionRef,
|
|
},
|
|
}
|
|
|
|
impl ConversationSession {
|
|
/// Whether the thread currently has a live session.
|
|
#[must_use]
|
|
pub const fn is_live(&self) -> bool {
|
|
matches!(self, Self::Live { .. })
|
|
}
|
|
}
|
|
|
|
/// An opaque reference to a live session handle (a [`crate::terminal::TerminalSession`]).
|
|
///
|
|
/// The domain never owns the PTY; it only names the session by its [`SessionId`].
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
#[serde(transparent)]
|
|
pub struct SessionRef(pub SessionId);
|
|
|
|
impl SessionRef {
|
|
/// Wraps a [`SessionId`].
|
|
#[must_use]
|
|
pub const fn new(id: SessionId) -> Self {
|
|
Self(id)
|
|
}
|
|
|
|
/// Returns the referenced [`SessionId`].
|
|
#[must_use]
|
|
pub const fn session_id(&self) -> SessionId {
|
|
self.0
|
|
}
|
|
}
|
|
|
|
/// A thread between two distinct parties, with its own session and resumable id.
|
|
///
|
|
/// Identity is the **unordered pair** `{left, right}`: see [`Conversation::same_pair`].
|
|
/// Pure value/entity — no I/O.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct Conversation {
|
|
/// Stable identifier (minted by the registry on first resolve).
|
|
pub id: ConversationId,
|
|
/// One end of the thread.
|
|
pub left: ConversationParty,
|
|
/// The other end of the thread.
|
|
pub right: ConversationParty,
|
|
/// Current I/O state.
|
|
pub session: ConversationSession,
|
|
/// CLI session-id to resume on restart (`suspend` stores it; `Dormant` keeps it).
|
|
pub resumable_id: Option<String>,
|
|
}
|
|
|
|
impl Conversation {
|
|
/// Builds a `Dormant` conversation for the pair `{left, right}`.
|
|
///
|
|
/// # Errors
|
|
/// - [`ConversationError::SameParty`] if `left == right`.
|
|
/// - [`ConversationError::TwoUsers`] if both ends are [`ConversationParty::User`].
|
|
pub fn try_new(
|
|
id: ConversationId,
|
|
left: ConversationParty,
|
|
right: ConversationParty,
|
|
) -> Result<Self, ConversationError> {
|
|
if left.is_user() && right.is_user() {
|
|
// More specific than SameParty: "at most one User" invariant.
|
|
return Err(ConversationError::TwoUsers);
|
|
}
|
|
if left == right {
|
|
return Err(ConversationError::SameParty);
|
|
}
|
|
Ok(Self {
|
|
id,
|
|
left,
|
|
right,
|
|
session: ConversationSession::Dormant,
|
|
resumable_id: None,
|
|
})
|
|
}
|
|
|
|
/// Whether this conversation relates the **same unordered pair** as `{a, b}`.
|
|
///
|
|
/// `{left, right}` is order-insensitive: `same_pair(a, b) == same_pair(b, a)`.
|
|
#[must_use]
|
|
pub fn same_pair(&self, a: ConversationParty, b: ConversationParty) -> bool {
|
|
(self.left == a && self.right == b) || (self.left == b && self.right == a)
|
|
}
|
|
}
|
|
|
|
/// A pure wait-for graph for deadlock (cycle) detection on inter-agent delegation.
|
|
///
|
|
/// Each edge `(A, B)` reads «A is waiting for B». A delegation `from → to` would
|
|
/// deadlock iff `to` already (transitively) waits on `from`; [`WaitForGraph::would_cycle`]
|
|
/// answers that **without** mutating the graph, so the caller can refuse the ask
|
|
/// **before** posting the edge. 100 % pure and testable.
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct WaitForGraph {
|
|
edges: Vec<(AgentId, AgentId)>,
|
|
}
|
|
|
|
impl WaitForGraph {
|
|
/// An empty graph.
|
|
#[must_use]
|
|
pub fn new() -> Self {
|
|
Self { edges: Vec::new() }
|
|
}
|
|
|
|
/// Records that `from` waits for `to` (no-op if the edge already exists).
|
|
pub fn add_edge(&mut self, from: AgentId, to: AgentId) {
|
|
if !self.edges.contains(&(from, to)) {
|
|
self.edges.push((from, to));
|
|
}
|
|
}
|
|
|
|
/// Removes the edge `from → to` (the wait resolved or timed out).
|
|
pub fn remove_edge(&mut self, from: AgentId, to: AgentId) {
|
|
self.edges.retain(|e| *e != (from, to));
|
|
}
|
|
|
|
/// Number of recorded edges (test/inspection helper).
|
|
#[must_use]
|
|
pub fn len(&self) -> usize {
|
|
self.edges.len()
|
|
}
|
|
|
|
/// Whether the graph holds no edges.
|
|
#[must_use]
|
|
pub fn is_empty(&self) -> bool {
|
|
self.edges.is_empty()
|
|
}
|
|
|
|
/// Whether adding `from → to` would close a cycle in the wait-for graph.
|
|
///
|
|
/// `true` when `to` already reaches `from` through existing edges (so `from`
|
|
/// waiting on `to` would deadlock), or when `from == to` (self-wait). The graph
|
|
/// is **not** modified.
|
|
#[must_use]
|
|
pub fn would_cycle(&self, from: AgentId, to: AgentId) -> bool {
|
|
if from == to {
|
|
return true;
|
|
}
|
|
// Does `to` already reach `from`? DFS over existing edges from `to`.
|
|
let mut stack = vec![to];
|
|
let mut seen = vec![to];
|
|
while let Some(node) = stack.pop() {
|
|
if node == from {
|
|
return true;
|
|
}
|
|
for (a, b) in &self.edges {
|
|
if *a == node && !seen.contains(b) {
|
|
seen.push(*b);
|
|
stack.push(*b);
|
|
}
|
|
}
|
|
}
|
|
false
|
|
}
|
|
}
|
|
|
|
/// Lazy, get-or-create registry of conversations keyed by **unordered pair**.
|
|
///
|
|
/// Pure port (no I/O); the infra adapter `InMemoryConversationRegistry` owns the
|
|
/// interior mutability (`HashMap` + sync mutex, never held across an `.await`).
|
|
/// Kept object-safe so the application holds it as `Arc<dyn ConversationRegistry>`.
|
|
pub trait ConversationRegistry: Send + Sync {
|
|
/// Get-or-create: returns the thread for the pair `{a, b}`, opening it
|
|
/// (`Dormant`) if it did not exist. Pure registry — opens **no** session.
|
|
/// The same unordered pair always yields the same [`ConversationId`].
|
|
fn resolve(&self, a: ConversationParty, b: ConversationParty) -> Conversation;
|
|
|
|
/// Marks the conversation `id` `Live` with the given session reference.
|
|
fn bind_session(&self, id: ConversationId, session: SessionRef);
|
|
|
|
/// Suspends `id` (back to `Dormant`), retaining `resumable_id` for restart.
|
|
fn suspend(&self, id: ConversationId, resumable_id: Option<String>);
|
|
|
|
/// Returns the current snapshot of `id`, if it exists.
|
|
fn get(&self, id: ConversationId) -> Option<Conversation>;
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn agent(n: u128) -> AgentId {
|
|
AgentId::from_uuid(uuid::Uuid::from_u128(n))
|
|
}
|
|
|
|
fn conv_id(n: u128) -> ConversationId {
|
|
ConversationId::from_uuid(uuid::Uuid::from_u128(n))
|
|
}
|
|
|
|
#[test]
|
|
fn conversation_id_roundtrips_through_uuid() {
|
|
let u = uuid::Uuid::from_u128(42);
|
|
let id = ConversationId::from_uuid(u);
|
|
assert_eq!(id.as_uuid(), u);
|
|
assert_eq!(id.to_string(), u.to_string());
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_same_party_pair() {
|
|
let a = ConversationParty::agent(agent(1));
|
|
assert_eq!(
|
|
Conversation::try_new(conv_id(1), a, a),
|
|
Err(ConversationError::SameParty)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_two_users() {
|
|
assert_eq!(
|
|
Conversation::try_new(conv_id(1), ConversationParty::User, ConversationParty::User),
|
|
Err(ConversationError::TwoUsers)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn accepts_user_agent_and_agent_agent() {
|
|
assert!(Conversation::try_new(
|
|
conv_id(1),
|
|
ConversationParty::User,
|
|
ConversationParty::agent(agent(1))
|
|
)
|
|
.is_ok());
|
|
assert!(Conversation::try_new(
|
|
conv_id(2),
|
|
ConversationParty::agent(agent(1)),
|
|
ConversationParty::agent(agent(2))
|
|
)
|
|
.is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn fresh_conversation_is_dormant_without_resumable() {
|
|
let c = Conversation::try_new(
|
|
conv_id(1),
|
|
ConversationParty::User,
|
|
ConversationParty::agent(agent(1)),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(c.session, ConversationSession::Dormant);
|
|
assert!(!c.session.is_live());
|
|
assert_eq!(c.resumable_id, None);
|
|
}
|
|
|
|
#[test]
|
|
fn identity_is_the_unordered_pair() {
|
|
let a = ConversationParty::agent(agent(1));
|
|
let b = ConversationParty::agent(agent(2));
|
|
let c = Conversation::try_new(conv_id(1), a, b).unwrap();
|
|
assert!(c.same_pair(a, b));
|
|
assert!(c.same_pair(b, a), "pair identity is order-insensitive");
|
|
let other = ConversationParty::agent(agent(3));
|
|
assert!(!c.same_pair(a, other));
|
|
}
|
|
|
|
#[test]
|
|
fn would_cycle_refuses_direct_back_edge() {
|
|
// A→B exists; B→A would close the cycle.
|
|
let mut g = WaitForGraph::new();
|
|
g.add_edge(agent(1), agent(2)); // A waits B
|
|
assert!(g.would_cycle(agent(2), agent(1)), "B→A closes A→B→A");
|
|
}
|
|
|
|
#[test]
|
|
fn would_cycle_allows_linear_chain() {
|
|
// A→B exists; B→C is fine (no path C→…→B).
|
|
let mut g = WaitForGraph::new();
|
|
g.add_edge(agent(1), agent(2)); // A waits B
|
|
assert!(!g.would_cycle(agent(2), agent(3)), "A→B→C is acyclic");
|
|
}
|
|
|
|
#[test]
|
|
fn would_cycle_detects_transitive_cycle() {
|
|
// A→B, B→C; C→A would close A→B→C→A.
|
|
let mut g = WaitForGraph::new();
|
|
g.add_edge(agent(1), agent(2));
|
|
g.add_edge(agent(2), agent(3));
|
|
assert!(g.would_cycle(agent(3), agent(1)), "transitive cycle");
|
|
}
|
|
|
|
#[test]
|
|
fn would_cycle_refuses_self_wait() {
|
|
let g = WaitForGraph::new();
|
|
assert!(g.would_cycle(agent(1), agent(1)));
|
|
}
|
|
|
|
#[test]
|
|
fn add_edge_is_idempotent_and_removable() {
|
|
let mut g = WaitForGraph::new();
|
|
g.add_edge(agent(1), agent(2));
|
|
g.add_edge(agent(1), agent(2));
|
|
assert_eq!(g.len(), 1);
|
|
g.remove_edge(agent(1), agent(2));
|
|
assert!(g.is_empty());
|
|
}
|
|
}
|