fix(runtime): isolate agent state by project (#101)

This commit is contained in:
2026-07-25 22:14:02 +02:00
parent 6a87c4635f
commit 6e98fd89f7
52 changed files with 1894 additions and 805 deletions

View File

@ -17,6 +17,7 @@ use domain::conversation::{
Conversation, ConversationId, ConversationParty, ConversationRegistry, ConversationSession,
SessionRef,
};
use domain::ids::ProjectId;
/// Order-insensitive key for a conversation pair `{a, b}`.
///
@ -47,7 +48,7 @@ pub struct InMemoryConversationRegistry {
#[derive(Default)]
struct Inner {
by_id: HashMap<ConversationId, Conversation>,
by_pair: HashMap<(ConversationParty, ConversationParty), ConversationId>,
by_pair: HashMap<(ProjectId, ConversationParty, ConversationParty), ConversationId>,
}
impl InMemoryConversationRegistry {
@ -79,10 +80,16 @@ impl InMemoryConversationRegistry {
}
impl ConversationRegistry for InMemoryConversationRegistry {
fn resolve(&self, a: ConversationParty, b: ConversationParty) -> Conversation {
fn resolve(
&self,
project_id: ProjectId,
a: ConversationParty,
b: ConversationParty,
) -> Conversation {
let key = pair_key(a, b);
let scoped_key = (project_id, key.0, key.1);
let mut inner = self.lock();
if let Some(id) = inner.by_pair.get(&key).copied() {
if let Some(id) = inner.by_pair.get(&scoped_key).copied() {
// Existing thread for this pair — return its current snapshot.
return inner
.by_id
@ -97,10 +104,10 @@ impl ConversationRegistry for InMemoryConversationRegistry {
// 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 id = ConversationId::for_project_pair(project_id, 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);
inner.by_pair.insert(scoped_key, id);
inner.by_id.insert(id, conv.clone());
conv
}
@ -136,14 +143,18 @@ mod tests {
ConversationParty::agent(AgentId::from_uuid(uuid::Uuid::from_u128(n)))
}
fn project(n: u128) -> ProjectId {
ProjectId::from_uuid(uuid::Uuid::from_u128(n))
}
#[test]
fn resolve_is_lazy_get_or_create() {
let reg = InMemoryConversationRegistry::new();
assert!(reg.is_empty());
let c = reg.resolve(ConversationParty::User, agent(1));
let c = reg.resolve(project(1), ConversationParty::User, agent(1));
assert_eq!(reg.len(), 1);
// Same pair ⇒ same id, no new conversation created.
let c2 = reg.resolve(ConversationParty::User, agent(1));
let c2 = reg.resolve(project(1), ConversationParty::User, agent(1));
assert_eq!(c.id, c2.id);
assert_eq!(reg.len(), 1);
}
@ -151,8 +162,8 @@ mod tests {
#[test]
fn same_pair_unordered_yields_same_id() {
let reg = InMemoryConversationRegistry::new();
let c1 = reg.resolve(agent(1), agent(2));
let c2 = reg.resolve(agent(2), agent(1)); // swapped order
let c1 = reg.resolve(project(1), agent(1), agent(2));
let c2 = reg.resolve(project(1), agent(2), agent(1)); // swapped order
assert_eq!(c1.id, c2.id, "unordered pair identity");
assert_eq!(reg.len(), 1);
}
@ -160,8 +171,8 @@ mod tests {
#[test]
fn distinct_pairs_get_distinct_ids() {
let reg = InMemoryConversationRegistry::new();
let user_b = reg.resolve(ConversationParty::User, agent(2));
let a_b = reg.resolve(agent(1), agent(2));
let user_b = reg.resolve(project(1), ConversationParty::User, agent(2));
let a_b = reg.resolve(project(1), agent(1), agent(2));
assert_ne!(user_b.id, a_b.id, "User↔B and A↔B are different threads");
assert_eq!(reg.len(), 2);
}
@ -169,14 +180,14 @@ mod tests {
#[test]
fn fresh_resolve_is_dormant() {
let reg = InMemoryConversationRegistry::new();
let c = reg.resolve(ConversationParty::User, agent(1));
let c = reg.resolve(project(1), ConversationParty::User, agent(1));
assert_eq!(c.session, ConversationSession::Dormant);
}
#[test]
fn bind_session_makes_it_live_then_suspend_restores_dormant() {
let reg = InMemoryConversationRegistry::new();
let c = reg.resolve(ConversationParty::User, agent(1));
let c = reg.resolve(project(1), ConversationParty::User, agent(1));
let sref = SessionRef::new(SessionId::from_uuid(uuid::Uuid::from_u128(99)));
reg.bind_session(c.id, sref);
let live = reg.get(c.id).unwrap();
@ -210,10 +221,10 @@ mod tests {
// l'IDE : la même paire User↔Agent doit produire le **même** id.
let a = agent(7);
let first = InMemoryConversationRegistry::new()
.resolve(ConversationParty::User, a)
.resolve(project(1), ConversationParty::User, a)
.id;
let second = InMemoryConversationRegistry::new()
.resolve(ConversationParty::User, a)
.resolve(project(1), ConversationParty::User, a)
.id;
assert_eq!(
first, second,
@ -226,8 +237,12 @@ mod tests {
// Même garantie pour une paire Agent↔Agent (dérivation XOR commutative).
let x = agent(11);
let y = agent(13);
let first = InMemoryConversationRegistry::new().resolve(x, y).id;
let second = InMemoryConversationRegistry::new().resolve(y, x).id;
let first = InMemoryConversationRegistry::new()
.resolve(project(1), x, y)
.id;
let second = InMemoryConversationRegistry::new()
.resolve(project(1), y, x)
.id;
assert_eq!(
first, second,
"id de paire Agent↔Agent stable au redémarrage, insensible à l'ordre"
@ -242,18 +257,13 @@ mod tests {
let agent_id = AgentId::from_uuid(uuid::Uuid::from_u128(42));
let party = ConversationParty::agent(agent_id);
let resolved = InMemoryConversationRegistry::new()
.resolve(ConversationParty::User, party)
.resolve(project(1), ConversationParty::User, party)
.id;
assert_eq!(
resolved,
ConversationId::for_pair(ConversationParty::User, party),
ConversationId::for_project_pair(project(1), ConversationParty::User, party),
"resolve == for_pair (alignement de clé)"
);
assert_eq!(
resolved,
ConversationId::from_uuid(agent_id.as_uuid()),
"User↔Agent ⇒ id == uuid de l'agent (repli resolve_conversation)"
);
}
#[test]
@ -262,12 +272,12 @@ mod tests {
let x = agent(101);
let y = agent(202);
let reg = InMemoryConversationRegistry::new();
let id_xy = reg.resolve(x, y).id;
let id_yx = reg.resolve(y, x).id;
let id_xy = reg.resolve(project(1), x, y).id;
let id_yx = reg.resolve(project(1), y, x).id;
assert_eq!(id_xy, id_yx, "resolve(a,b) == resolve(b,a)");
assert_eq!(
id_xy,
ConversationId::for_pair(x, y),
ConversationId::for_project_pair(project(1), x, y),
"resolve == for_pair (Agent↔Agent)"
);
assert_eq!(reg.len(), 1, "une seule conversation pour la paire {{a,b}}");
@ -277,11 +287,25 @@ mod tests {
fn distinct_pairs_yield_distinct_ids_across_kinds() {
// Deux paires distinctes ⇒ deux ids distincts (pas de collision de clé).
let reg = InMemoryConversationRegistry::new();
let user_a = reg.resolve(ConversationParty::User, agent(1)).id;
let user_b = reg.resolve(ConversationParty::User, agent(2)).id;
let a_b = reg.resolve(agent(1), agent(2)).id;
let user_a = reg
.resolve(project(1), ConversationParty::User, agent(1))
.id;
let user_b = reg
.resolve(project(1), ConversationParty::User, agent(2))
.id;
let a_b = reg.resolve(project(1), agent(1), agent(2)).id;
assert_ne!(user_a, user_b, "User↔A ≠ User↔B");
assert_ne!(user_a, a_b, "User↔A ≠ A↔B");
assert_ne!(user_b, a_b, "User↔B ≠ A↔B");
}
#[test]
fn same_agent_ids_in_distinct_projects_get_distinct_threads() {
let reg = InMemoryConversationRegistry::new();
let p1 = reg.resolve(project(1), ConversationParty::User, agent(7));
let p2 = reg.resolve(project(2), ConversationParty::User, agent(7));
assert_ne!(p1.id, p2.id);
assert_eq!(reg.len(), 2);
}
}