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

@ -195,7 +195,10 @@ pub fn start_background_ready_inbox_bridge(
created_at_ms: now_ms(),
correlation_id: Some(ready.task_id.to_string()),
};
match inbox.enqueue_message(ready.owner_agent_id, item) {
match inbox.enqueue_message(
domain::RuntimeAgentKey::new(ready.project_id, ready.owner_agent_id),
item,
) {
Ok(receipt) if receipt.status == InboxReceiptStatus::Deferred => {
application::diag!(
"[background-task] completion deferred: task={} owner={} queue_depth={}",

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);
}
}

View File

@ -25,7 +25,7 @@ use std::sync::{Arc, Mutex};
use std::time::Duration;
use domain::events::DomainEvent;
use domain::ids::AgentId;
use domain::ids::RuntimeAgentKey;
use domain::inbox::{
AgentInbox, AgentInboxSnapshot, InboxError, InboxItem, InboxReceipt, InboxReceiptStatus,
InboxSource, DEFAULT_AGENT_INBOX_CAPACITY,
@ -44,11 +44,11 @@ use crate::mailbox::InMemoryMailbox;
/// authority for the `Busy→Idle` transition and its `AgentBusyChanged` event, so
/// every path (explicit `mark_idle`, prompt-ready match) stays consistent.
struct BusyTracker {
busy: Mutex<HashMap<AgentId, AgentBusyState>>,
busy: Mutex<HashMap<RuntimeAgentKey, AgentBusyState>>,
/// Per-agent **liveness** bookkeeping (lot 2) : dernier battement observé, seuil de
/// stagnation issu du profil, et état de vivacité courant pour n'émettre
/// `AgentLivenessChanged` qu'**une fois par transition** (pas de spam).
liveness: Mutex<HashMap<AgentId, LivenessState>>,
liveness: Mutex<HashMap<RuntimeAgentKey, LivenessState>>,
/// **Démarrage à froid** (fix race cold-launch) : ensemble des agents fraîchement
/// lancés à froid pour lesquels la livraison du **premier** tour doit être *gatée*
/// sur la readiness MCP. Un agent y est inscrit par
@ -56,11 +56,11 @@ struct BusyTracker {
/// consommé par l'`enqueue` qui démarre le tour : la `DelegationReady`
/// est alors **différée** dans `deferred` au lieu d'être publiée immédiatement (le
/// CLI n'a pas encore chargé ses outils MCP). Vide ⇒ comportement chaud inchangé.
starting: Mutex<HashSet<AgentId>>,
starting: Mutex<HashSet<RuntimeAgentKey>>,
/// **Tour différé** (fix race cold-launch) : payload de la `DelegationReady` retenue
/// pour un démarrage à froid, publiée par [`BusyTracker::release_cold_start`] à la
/// connexion du pont MCP (jamais avant). Absent ⇒ aucun tour en attente de gate.
deferred: Mutex<HashMap<AgentId, DeferredDelegation>>,
deferred: Mutex<HashMap<RuntimeAgentKey, DeferredDelegation>>,
/// **Latch « déjà libéré »** (fix race cold-start, ordre inverse) : ensemble des
/// agents pour lesquels le signal de readiness (`release_cold_start`, pont MCP) est
/// arrivé **avant** que l'`enqueue` n'ait parqué son tour dans
@ -71,7 +71,7 @@ struct BusyTracker {
/// latch enregistre « cet agent en démarrage est déjà prêt » : l'`enqueue` qui suit
/// livre alors **immédiatement** au lieu de parquer. Consommé (retiré) à la livraison
/// ⇒ exactement-une-fois, quel que soit l'ordre. Vide ⇒ comportement inchangé.
released: Mutex<HashSet<AgentId>>,
released: Mutex<HashSet<RuntimeAgentKey>>,
events: Option<Arc<dyn EventBus>>,
/// Sink de livraison headless (cf. [`HeadlessSink`]). Câblé par
/// [`MediatedInbox::with_pty`]/[`MediatedInbox::with_events`] quand un PTY est
@ -118,7 +118,7 @@ struct DeferredDelegation {
/// normal (cellule frontend présente ⇒ c'est le write-portal qui écrira, ou pas de
/// PTY/handle disponible ⇒ repli sur l'événement, comportement historique).
type HeadlessSink =
Arc<dyn Fn(AgentId, DeferredDelegation) -> Option<DeferredDelegation> + Send + Sync>;
Arc<dyn Fn(RuntimeAgentKey, DeferredDelegation) -> Option<DeferredDelegation> + Send + Sync>;
/// Délai (ms) entre l'écriture du texte de la tâche et celle de la séquence de
/// soumission lors d'une livraison **headless** (le médiateur écrit lui-même le PTY).
@ -162,19 +162,21 @@ impl BusyTracker {
}
}
fn lock_starting(&self) -> std::sync::MutexGuard<'_, HashSet<AgentId>> {
fn lock_starting(&self) -> std::sync::MutexGuard<'_, HashSet<RuntimeAgentKey>> {
self.starting
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn lock_deferred(&self) -> std::sync::MutexGuard<'_, HashMap<AgentId, DeferredDelegation>> {
fn lock_deferred(
&self,
) -> std::sync::MutexGuard<'_, HashMap<RuntimeAgentKey, DeferredDelegation>> {
self.deferred
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn lock_released(&self) -> std::sync::MutexGuard<'_, HashSet<AgentId>> {
fn lock_released(&self) -> std::sync::MutexGuard<'_, HashSet<RuntimeAgentKey>> {
self.released
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
@ -186,28 +188,28 @@ impl BusyTracker {
/// (`release_cold_start`), sinon le premier tour resterait bloqué indéfiniment
/// (aucun signal ne viendrait le libérer). Sans cet appel, l'`enqueue` publie la
/// `DelegationReady` immédiatement (chemin chaud, zéro régression).
fn mark_starting(&self, agent: AgentId) {
fn mark_starting(&self, agent: RuntimeAgentKey) {
application::diag!("[input-mediator] mark_starting cold-start gate armed agent={agent}");
self.lock_starting().insert(agent);
}
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<AgentId, AgentBusyState>> {
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<RuntimeAgentKey, AgentBusyState>> {
self.busy
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn lock_liveness(&self) -> std::sync::MutexGuard<'_, HashMap<AgentId, LivenessState>> {
fn lock_liveness(&self) -> std::sync::MutexGuard<'_, HashMap<RuntimeAgentKey, LivenessState>> {
self.liveness
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// Publie un `AgentLivenessChanged` (si un bus est câblé).
fn publish_liveness(&self, agent: AgentId, liveness: AgentLiveness) {
fn publish_liveness(&self, agent: RuntimeAgentKey, liveness: AgentLiveness) {
if let Some(events) = &self.events {
events.publish(DomainEvent::AgentLivenessChanged {
agent_id: agent,
agent_id: agent.agent_id,
liveness,
});
}
@ -217,7 +219,7 @@ impl BusyTracker {
/// démarre (depuis l'enqueue) : (re)initialise `last_seen` à `now` et repart d'un
/// état `Alive`. Sans seuil (`None`), l'entrée existe quand même mais le sweep ne
/// la déclarera jamais `Stalled` (zéro régression pour un profil sans liveness).
fn arm_liveness(&self, agent: AgentId, stall_after_ms: Option<u32>, now_ms: u64) {
fn arm_liveness(&self, agent: RuntimeAgentKey, stall_after_ms: Option<u32>, now_ms: u64) {
self.lock_liveness().insert(
agent,
LivenessState {
@ -231,7 +233,7 @@ impl BusyTracker {
/// Rafraîchit le `last_seen` d'un agent (un **battement**) et, s'il était
/// `Stalled`, le ramène à `Alive` en émettant l'unique transition de reprise. No-op
/// si l'agent n'a pas d'entrée de vivacité armée (tour non structuré / legacy).
fn touch(&self, agent: AgentId, now_ms: u64) {
fn touch(&self, agent: RuntimeAgentKey, now_ms: u64) {
let recovered = {
let mut map = self.lock_liveness();
let Some(state) = map.get_mut(&agent) else {
@ -256,7 +258,7 @@ impl BusyTracker {
/// `now_ms` est passé en paramètre. Idempotente : un agent déjà `Stalled` ne ré-émet
/// pas. Un agent sans seuil (`None`) ou redevenu `Idle` n'est jamais déclaré stalled.
fn sweep_stalled(&self, now_ms: u64) {
let newly_stalled: Vec<AgentId> = {
let newly_stalled: Vec<RuntimeAgentKey> = {
let mut map = self.lock_liveness();
map.iter_mut()
.filter_map(|(agent, state)| {
@ -285,7 +287,7 @@ impl BusyTracker {
/// Retire l'entrée de vivacité d'un agent (fin de tour). Si l'agent était `Stalled`,
/// la fin de tour est en soi un retour à `Alive` ⇒ on émet la transition de reprise.
fn clear_liveness(&self, agent: AgentId) {
fn clear_liveness(&self, agent: RuntimeAgentKey) {
let was_stalled = self
.lock_liveness()
.remove(&agent)
@ -295,7 +297,7 @@ impl BusyTracker {
}
}
fn busy_state(&self, agent: AgentId) -> AgentBusyState {
fn busy_state(&self, agent: RuntimeAgentKey) -> AgentBusyState {
self.lock()
.get(&agent)
.copied()
@ -304,7 +306,7 @@ impl BusyTracker {
/// Marks `agent` `Busy` if it was `Idle`, returning whether a turn actually
/// started (so the caller publishes `AgentBusyChanged{busy:true}` only once).
fn start_turn(&self, agent: AgentId, state: AgentBusyState) -> bool {
fn start_turn(&self, agent: RuntimeAgentKey, state: AgentBusyState) -> bool {
let mut busy = self.lock();
let entry = busy.entry(agent).or_insert(AgentBusyState::Idle);
if entry.is_busy() {
@ -320,7 +322,7 @@ impl BusyTracker {
/// Publie une [`DomainEvent::DelegationReady`] depuis un payload différé (si un bus
/// est câblé). Utilisée pour livrer le **premier** tour d'un agent froid au moment
/// où son prompt apparaît.
fn publish_deferred(&self, agent: AgentId, d: DeferredDelegation) {
fn publish_deferred(&self, agent: RuntimeAgentKey, d: DeferredDelegation) {
// Point de livraison unique (tours chauds immédiats ET drains à froid). Si un
// sink headless est câblé, il a la priorité : pour un agent sans cellule
// frontend il écrit lui-même la tâche dans le PTY et renvoie `None` (pris en
@ -346,7 +348,7 @@ impl BusyTracker {
d.submit_delay_ms,
);
events.publish(DomainEvent::DelegationReady {
agent_id: agent,
agent_id: agent.agent_id,
ticket: d.ticket,
text: d.text,
submit_sequence: d.submit_sequence,
@ -368,7 +370,7 @@ impl BusyTracker {
/// une erreur typée). Un `idea_reply` arrivé pendant G gagne (la complétion devient un
/// no-op, tête déjà retirée). Sur un agent déjà `Idle` (aucun ticket actif) ⇒ simple
/// `mark_idle` idempotent, pas de grâce.
fn turn_ended(&self, agent: AgentId) {
fn turn_ended(&self, agent: RuntimeAgentKey) {
let active = self.lock().get(&agent).and_then(AgentBusyState::ticket);
application::diag!(
"[input-mediator] turn_ended agent={agent} no_reply_payload -> mark_idle + grace"
@ -395,7 +397,7 @@ impl BusyTracker {
/// l'agent de `starting`) ; sinon no-op. **Pas de `mark_idle`** (signal de démarrage,
/// pas de fin de tour). Idempotent : c'est le seul drain du tour différé (le `remove`
/// ne rend `Some` qu'une fois).
fn release_cold_start(&self, agent: AgentId) {
fn release_cold_start(&self, agent: RuntimeAgentKey) {
let was_starting = self.lock_starting().remove(&agent);
if let Some(d) = self.lock_deferred().remove(&agent) {
// Cas nominal : l'`enqueue` a déjà parqué le tour ⇒ on le draine.
@ -421,7 +423,7 @@ impl BusyTracker {
/// Marks `agent` `Idle`, publishing `AgentBusyChanged{busy:false}` only on a real
/// `Busy→Idle` transition. Idempotent: a `mark_idle` on an already-idle agent is a
/// no-op and emits nothing.
fn mark_idle(&self, agent: AgentId) {
fn mark_idle(&self, agent: RuntimeAgentKey) {
let was_busy = {
let mut busy = self.lock();
busy.insert(agent, AgentBusyState::Idle)
@ -431,7 +433,7 @@ impl BusyTracker {
application::diag!("[input-mediator] mark_idle turn ended agent={agent}");
if let Some(events) = &self.events {
events.publish(DomainEvent::AgentBusyChanged {
agent_id: agent,
agent_id: agent.agent_id,
busy: false,
});
}
@ -485,21 +487,21 @@ pub struct MediatedInbox {
/// Per-agent live input handle (one stream per agent), fed by `bind_handle`.
/// `Arc` so the headless delivery sink (wired into the [`BusyTracker`]) can read it
/// to resolve an agent's PTY handle when it must write the turn itself.
handles: Arc<Mutex<HashMap<AgentId, PtyHandle>>>,
handles: Arc<Mutex<HashMap<RuntimeAgentKey, PtyHandle>>>,
/// Agents qui ont une **cellule terminal frontend montée** (write-portal actif),
/// tenu à jour par [`InputMediator::set_front_attached`]. Quand un agent y figure,
/// la livraison passe par l'événement `DelegationReady` (le front écrit) ; sinon
/// (agent headless / délégué en arrière-plan) le médiateur écrit lui-même le tour
/// dans le PTY. `Arc` car le sink headless du tracker le consulte.
front_owned: Arc<Mutex<HashSet<AgentId>>>,
front_owned: Arc<Mutex<HashSet<RuntimeAgentKey>>>,
/// Per-agent submit config (target profile's `submit_sequence`/`submit_delay_ms`),
/// stashed at bind time (§20.3) and echoed on the `DelegationReady` event when a
/// turn starts. Absent ⇒ both `None` (the front applies its defaults).
submit: Mutex<HashMap<AgentId, SubmitConfig>>,
submit: Mutex<HashMap<RuntimeAgentKey, SubmitConfig>>,
/// Per-agent stall threshold (`LivenessStrategy::stall_after_ms`, lot 2), stashed by
/// `set_stall_threshold` and consumed to arm a fresh liveness window on the enqueue
/// that starts a turn. Absent ⇒ `None` (no stall detection — legacy behaviour).
stall: Mutex<HashMap<AgentId, Option<u32>>>,
stall: Mutex<HashMap<RuntimeAgentKey, Option<u32>>>,
/// Rich inbox payloads keyed by the mailbox ticket id. The mailbox remains the
/// only FIFO; this map is metadata only.
inbox_items: Mutex<HashMap<TicketId, InboxItem>>,
@ -545,8 +547,8 @@ impl MediatedInbox {
fn build_tracker(
events: Option<Arc<dyn EventBus>>,
pty: Option<&Arc<dyn PtyPort>>,
handles: &Arc<Mutex<HashMap<AgentId, PtyHandle>>>,
front_owned: &Arc<Mutex<HashSet<AgentId>>>,
handles: &Arc<Mutex<HashMap<RuntimeAgentKey, PtyHandle>>>,
front_owned: &Arc<Mutex<HashSet<RuntimeAgentKey>>>,
mailbox: &Arc<InMemoryMailbox>,
grace: Duration,
) -> Arc<BusyTracker> {
@ -572,10 +574,10 @@ impl MediatedInbox {
/// `Some(d)` ⇒ le tracker publie l'événement `DelegationReady` comme avant.
fn make_headless_sink(
pty: Arc<dyn PtyPort>,
handles: Arc<Mutex<HashMap<AgentId, PtyHandle>>>,
front_owned: Arc<Mutex<HashSet<AgentId>>>,
handles: Arc<Mutex<HashMap<RuntimeAgentKey, PtyHandle>>>,
front_owned: Arc<Mutex<HashSet<RuntimeAgentKey>>>,
) -> HeadlessSink {
Arc::new(move |agent: AgentId, d: DeferredDelegation| {
Arc::new(move |agent: RuntimeAgentKey, d: DeferredDelegation| {
// Cellule frontend montée ⇒ c'est le write-portal qui écrit (et qui sait
// composer avec une saisie humaine en cours). On rend la main.
if front_owned
@ -749,19 +751,19 @@ impl MediatedInbox {
self
}
fn handles(&self) -> std::sync::MutexGuard<'_, HashMap<AgentId, PtyHandle>> {
fn handles(&self) -> std::sync::MutexGuard<'_, HashMap<RuntimeAgentKey, PtyHandle>> {
self.handles
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn submit(&self) -> std::sync::MutexGuard<'_, HashMap<AgentId, SubmitConfig>> {
fn submit(&self) -> std::sync::MutexGuard<'_, HashMap<RuntimeAgentKey, SubmitConfig>> {
self.submit
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn stall(&self) -> std::sync::MutexGuard<'_, HashMap<AgentId, Option<u32>>> {
fn stall(&self) -> std::sync::MutexGuard<'_, HashMap<RuntimeAgentKey, Option<u32>>> {
self.stall
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
@ -843,7 +845,7 @@ fn describe_submit_sequence(value: Option<&str>) -> String {
}
impl InputMediator for MediatedInbox {
fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply {
fn enqueue(&self, agent: RuntimeAgentKey, ticket: Ticket) -> PendingReply {
let ticket_id = ticket.id;
// If the agent is Idle, this enqueue starts its turn ⇒ go Busy. If already
// Busy, we still accept (queue grows; the turn advances on mark_idle) — never
@ -875,7 +877,7 @@ impl InputMediator for MediatedInbox {
if started_turn {
if let Some(events) = &self.tracker.events {
events.publish(DomainEvent::AgentBusyChanged {
agent_id: agent,
agent_id: agent.agent_id,
busy: true,
});
let submit = self.submit().get(&agent).cloned().unwrap_or_default();
@ -921,7 +923,7 @@ impl InputMediator for MediatedInbox {
self.mailbox.enqueue(agent, ticket)
}
fn enqueue_silent(&self, agent: AgentId, ticket: Ticket) -> PendingReply {
fn enqueue_silent(&self, agent: RuntimeAgentKey, ticket: Ticket) -> PendingReply {
let ticket_id = ticket.id;
// Headless/system turns share the same FIFO and busy/liveness accounting as
// terminal-delivered turns, but their prompt is sent through AgentSession::send.
@ -940,7 +942,7 @@ impl InputMediator for MediatedInbox {
self.tracker.arm_liveness(agent, stall_after_ms, now_ms);
if let Some(events) = &self.tracker.events {
events.publish(DomainEvent::AgentBusyChanged {
agent_id: agent,
agent_id: agent.agent_id,
busy: true,
});
}
@ -948,7 +950,7 @@ impl InputMediator for MediatedInbox {
self.mailbox.enqueue(agent, ticket)
}
fn bind_handle(&self, agent: AgentId, handle: PtyHandle) {
fn bind_handle(&self, agent: RuntimeAgentKey, handle: PtyHandle) {
eprintln!(
"[input-mediator] bind handle agent={agent} handle={}",
handle.session_id
@ -956,7 +958,12 @@ impl InputMediator for MediatedInbox {
self.handles().insert(agent, handle);
}
fn bind_handle_with_submit(&self, agent: AgentId, handle: PtyHandle, submit: SubmitConfig) {
fn bind_handle_with_submit(
&self,
agent: RuntimeAgentKey,
handle: PtyHandle,
submit: SubmitConfig,
) {
// Register the input handle exactly like `bind_handle` and stash the target's
// submit config (echoed on `DelegationReady` at the next turn start, §20.3).
// Turn-end detection is NO LONGER armed here: the dead PTY prompt-ready watcher
@ -972,11 +979,11 @@ impl InputMediator for MediatedInbox {
self.submit().insert(agent, submit);
}
fn delivers_turn(&self, agent: AgentId) -> bool {
fn delivers_turn(&self, agent: RuntimeAgentKey) -> bool {
self.pty.is_some() && self.handles().get(&agent).is_some()
}
fn mark_starting(&self, agent: AgentId) {
fn mark_starting(&self, agent: RuntimeAgentKey) {
// Gate du premier tour d'un agent froid : appelé par l'orchestrateur juste après
// un (re)lancement à froid, AVANT le bind/enqueue, et uniquement si un signal de
// readiness le libérera (pont MCP). Consommé par l'`enqueue` qui démarre le tour
@ -984,11 +991,11 @@ impl InputMediator for MediatedInbox {
self.tracker.mark_starting(agent);
}
fn release_cold_start(&self, agent: AgentId) {
fn release_cold_start(&self, agent: RuntimeAgentKey) {
self.tracker.release_cold_start(agent);
}
fn set_front_attached(&self, agent: AgentId, attached: bool) {
fn set_front_attached(&self, agent: RuntimeAgentKey, attached: bool) {
let mut front = self
.front_owned
.lock()
@ -1002,7 +1009,7 @@ impl InputMediator for MediatedInbox {
}
}
fn preempt(&self, agent: AgentId) {
fn preempt(&self, agent: RuntimeAgentKey) {
// Interrompre: signals the running turn to stop. It is NOT an enqueue and
// correlates **no** ticket (we never pop/resolve a pending caller — preempt
// must never silently answer one). The only effect is a best-effort interrupt
@ -1018,42 +1025,46 @@ impl InputMediator for MediatedInbox {
}
}
fn mark_idle(&self, agent: AgentId) {
fn mark_idle(&self, agent: RuntimeAgentKey) {
// Single authority: real Busy→Idle only, publishing AgentBusyChanged{busy:false}
// once. Also clears the liveness entry (fin de tour) — émet la reprise si l'agent
// était `Stalled`.
self.tracker.mark_idle(agent);
}
fn turn_ended(&self, agent: AgentId) {
fn turn_ended(&self, agent: RuntimeAgentKey) {
// Déclenché par le `TurnWatcher` transcript (Claude `turn_duration`) : fin de tour
// sans `idea_reply` ⇒ `mark_idle` + fenêtre de grâce pour réveiller l'appelant
// parqué (cf. [`BusyTracker::turn_ended`]). Backstop no-reply.
self.tracker.turn_ended(agent);
}
fn mark_alive(&self, agent: AgentId) {
fn mark_alive(&self, agent: RuntimeAgentKey) {
// Un battement (delta / activité / heartbeat) rafraîchit `last_seen` et ramène
// l'agent à `Alive` s'il était `Stalled` (lot 2).
self.tracker.touch(agent, self.clock.now_ms());
}
fn set_stall_threshold(&self, agent: AgentId, stall_after_ms: Option<u32>) {
fn set_stall_threshold(&self, agent: RuntimeAgentKey, stall_after_ms: Option<u32>) {
// Stashé par l'orchestrateur depuis le profil de la cible AVANT l'enqueue qui
// démarre le tour ; consommé par `arm_liveness` au start_turn (lot 2).
self.stall().insert(agent, stall_after_ms);
}
fn busy_state(&self, agent: AgentId) -> AgentBusyState {
fn busy_state(&self, agent: RuntimeAgentKey) -> AgentBusyState {
self.tracker.busy_state(agent)
}
}
impl AgentInbox for MediatedInbox {
fn enqueue_message(&self, agent: AgentId, item: InboxItem) -> Result<InboxReceipt, InboxError> {
if item.agent_id != agent {
fn enqueue_message(
&self,
agent: RuntimeAgentKey,
item: InboxItem,
) -> Result<InboxReceipt, InboxError> {
if item.agent_id != agent.agent_id {
return Err(InboxError::AgentMismatch {
agent_id: agent,
agent_id: agent.agent_id,
item_agent_id: item.agent_id,
});
}
@ -1063,13 +1074,14 @@ impl AgentInbox for MediatedInbox {
if item.is_lossless_system() {
return Ok(InboxReceipt {
item_id: item.id,
agent_id: agent,
agent_id: agent.agent_id,
runtime_key: agent,
depth: depth_before,
status: InboxReceiptStatus::Deferred,
});
}
return Err(InboxError::InboxFull {
agent_id: agent,
agent_id: agent.agent_id,
capacity: self.inbox_capacity,
});
}
@ -1081,32 +1093,33 @@ impl AgentInbox for MediatedInbox {
let depth = self.mailbox.pending(&agent);
if let Some(events) = &self.tracker.events {
events.publish(DomainEvent::AgentInboxQueued {
agent_id: agent,
agent_id: agent.agent_id,
depth,
});
}
Ok(InboxReceipt {
item_id,
agent_id: agent,
agent_id: agent.agent_id,
runtime_key: agent,
depth,
status: InboxReceiptStatus::Queued,
})
}
fn dequeue_next(&self, agent: AgentId) -> Option<InboxItem> {
fn dequeue_next(&self, agent: RuntimeAgentKey) -> Option<InboxItem> {
let head = self.mailbox.head_ticket(&agent)?;
let item = self.inbox_items().remove(&head)?;
self.mailbox.cancel_head(agent, head);
if let Some(events) = &self.tracker.events {
events.publish(DomainEvent::AgentInboxDrained {
agent_id: agent,
agent_id: agent.agent_id,
depth: self.snapshot(agent).depth,
});
}
Some(item)
}
fn snapshot(&self, agent: AgentId) -> AgentInboxSnapshot {
fn snapshot(&self, agent: RuntimeAgentKey) -> AgentInboxSnapshot {
let items_by_id = self.inbox_items();
let items: Vec<InboxItem> = self
.mailbox
@ -1115,7 +1128,8 @@ impl AgentInbox for MediatedInbox {
.filter_map(|ticket| items_by_id.get(&ticket.id).cloned())
.collect();
AgentInboxSnapshot {
agent_id: agent,
agent_id: agent.agent_id,
runtime_key: agent,
depth: self.mailbox.pending(&agent),
items,
}
@ -1152,6 +1166,7 @@ mod tests {
use super::*;
use domain::conversation::ConversationId;
use domain::mailbox::TicketId;
use domain::AgentId;
/// Deterministic clock for assertions on `since_ms`.
struct FixedClock(u64);
@ -1165,6 +1180,13 @@ mod tests {
AgentId::from_uuid(uuid::Uuid::from_u128(n))
}
fn key(n: u128) -> RuntimeAgentKey {
RuntimeAgentKey::new(
domain::ProjectId::from_uuid(uuid::Uuid::from_u128(1000 + n)),
agent(n),
)
}
fn ticket(n: u128, task: &str) -> Ticket {
Ticket::from_human(
TicketId::from_uuid(uuid::Uuid::from_u128(n)),
@ -1240,27 +1262,33 @@ mod tests {
let bus = Arc::new(RecordingBus::default());
let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1)))
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1);
let a = key(1);
// First enqueue starts a turn ⇒ exactly one Busy(true) event.
inbox.enqueue(a, ticket(10, "first"));
assert_eq!(bus.busy_events(), vec![(a, true)]);
assert_eq!(bus.busy_events(), vec![(a.agent_id, true)]);
// Second enqueue while Busy queues behind ⇒ NO new busy event.
inbox.enqueue(a, ticket(11, "second"));
assert_eq!(
bus.busy_events(),
vec![(a, true)],
vec![(a.agent_id, true)],
"no re-announce while busy"
);
// mark_idle on a busy agent ⇒ exactly one Idle(false) event.
inbox.mark_idle(a);
assert_eq!(bus.busy_events(), vec![(a, true), (a, false)]);
assert_eq!(
bus.busy_events(),
vec![(a.agent_id, true), (a.agent_id, false)]
);
// mark_idle on an already-idle agent ⇒ no spurious event.
inbox.mark_idle(a);
assert_eq!(bus.busy_events(), vec![(a, true), (a, false)]);
assert_eq!(
bus.busy_events(),
vec![(a.agent_id, true), (a.agent_id, false)]
);
}
#[test]
@ -1268,11 +1296,11 @@ mod tests {
let bus = Arc::new(RecordingBus::default());
let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1)))
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1);
let a = key(1);
inbox.enqueue(a, ticket(10, "t"));
inbox.preempt(a);
// Only the enqueue's Busy(true); preempt does not toggle busy state.
assert_eq!(bus.busy_events(), vec![(a, true)]);
assert_eq!(bus.busy_events(), vec![(a.agent_id, true)]);
}
// ====================================================================
@ -1284,7 +1312,7 @@ mod tests {
let bus = Arc::new(RecordingBus::default());
let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1)))
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1);
let a = key(1);
// Idle→Busy ⇒ exactly one DelegationReady carrying the task text + ticket.
inbox.enqueue(a, ticket(10, "do the thing"));
@ -1325,14 +1353,14 @@ mod tests {
Arc::clone(&pty) as Arc<dyn PtyPort>,
)
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1);
let a = key(1);
inbox.bind_handle_with_submit(a, handle(1), SubmitConfig::default());
inbox.set_front_attached(a, true);
inbox.enqueue_silent(a, ticket(10, "headless turn"));
assert!(inbox.busy_state(a).is_busy());
assert_eq!(bus.busy_events(), vec![(a, true)]);
assert_eq!(bus.busy_events(), vec![(a.agent_id, true)]);
assert!(
bus.delegation_ready().is_empty(),
"headless bookkeeping must not leak a prompt to the terminal"
@ -1349,11 +1377,11 @@ mod tests {
let bus = Arc::new(RecordingBus::default());
let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1)))
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1);
let a = key(1);
let task_id = domain::TaskId::from_uuid(uuid::Uuid::from_u128(42));
let item = domain::InboxItem {
id: TicketId::from_uuid(uuid::Uuid::from_u128(10)),
agent_id: a,
agent_id: a.agent_id,
source: InboxSource::BackgroundTask { task_id },
kind: domain::InboxItemKind::BackgroundCompletion,
body: "Background task completed.".to_owned(),
@ -1384,7 +1412,7 @@ mod tests {
Arc::clone(&pty) as Arc<dyn PtyPort>,
)
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1);
let a = key(1);
let h = handle(1);
// Bind the target's submit config (resolved from its profile by the service).
@ -1405,7 +1433,7 @@ mod tests {
let bus = Arc::new(RecordingBus::default());
let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1)))
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1);
let a = key(1);
inbox.enqueue(a, ticket(10, "task"));
let ready = bus.delegation_ready();
assert_eq!(ready.len(), 1);
@ -1428,7 +1456,7 @@ mod tests {
Arc::clone(&pty) as Arc<dyn PtyPort>,
)
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1);
let a = key(1);
inbox.bind_handle_with_submit(a, handle(1), SubmitConfig::default());
inbox.set_front_attached(a, true);
@ -1460,7 +1488,7 @@ mod tests {
Arc::clone(&pty) as Arc<dyn PtyPort>,
)
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1);
let a = key(1);
// No set_front_attached ⇒ headless.
inbox.bind_handle_with_submit(a, handle(1), SubmitConfig::default());
@ -1495,7 +1523,7 @@ mod tests {
Arc::clone(&pty) as Arc<dyn PtyPort>,
)
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1);
let a = key(1);
let task = format!("début {} fin", "x".repeat(1200));
inbox.bind_handle_with_submit(a, handle(1), SubmitConfig::default());
@ -1531,7 +1559,7 @@ mod tests {
#[tokio::test]
async fn enqueue_returns_pending_reply_resolved_via_mailbox() {
let inbox = inbox_at(5);
let a = agent(1);
let a = key(1);
let pending = inbox.enqueue(a, ticket(10, "do X"));
// Resolve through the shared mailbox (the orchestrator's path).
inbox.mailbox().resolve(a, "done".to_owned()).unwrap();
@ -1544,7 +1572,7 @@ mod tests {
#[test]
fn first_enqueue_marks_busy_with_ticket_and_stamp() {
let inbox = inbox_at(1234);
let a = agent(1);
let a = key(1);
assert_eq!(inbox.busy_state(a), AgentBusyState::Idle);
inbox.enqueue(a, ticket(10, "t"));
assert_eq!(
@ -1559,7 +1587,7 @@ mod tests {
#[test]
fn second_enqueue_while_busy_keeps_first_ticket_and_does_not_reject() {
let inbox = inbox_at(1);
let a = agent(1);
let a = key(1);
inbox.enqueue(a, ticket(10, "first"));
inbox.enqueue(a, ticket(11, "second")); // accepted, queues behind
// Still busy on the FIRST ticket (turn unchanged), both queued in the mailbox.
@ -1573,7 +1601,7 @@ mod tests {
#[test]
fn mark_idle_returns_to_idle_so_next_turn_can_start() {
let inbox = inbox_at(1);
let a = agent(1);
let a = key(1);
inbox.enqueue(a, ticket(10, "t"));
assert!(inbox.busy_state(a).is_busy());
inbox.mark_idle(a);
@ -1589,7 +1617,7 @@ mod tests {
#[tokio::test]
async fn preempt_is_distinct_from_enqueue_and_resolves_no_ticket() {
let inbox = inbox_at(1);
let a = agent(1);
let a = key(1);
let pending = inbox.enqueue(a, ticket(10, "t"));
inbox.preempt(a);
// preempt did not pop/resolve the ticket: still pending in the mailbox.
@ -1605,7 +1633,7 @@ mod tests {
#[test]
fn two_enqueues_same_agent_serialise_in_one_fifo() {
let inbox = inbox_at(1);
let a = agent(1);
let a = key(1);
inbox.enqueue(a, ticket(10, "first"));
inbox.enqueue(a, ticket(11, "second"));
assert_eq!(inbox.mailbox().pending(&a), 2);
@ -1619,8 +1647,8 @@ mod tests {
#[test]
fn different_agents_are_independent_not_blocking() {
let inbox = inbox_at(1);
let a = agent(1);
let b = agent(2);
let a = key(1);
let b = key(2);
inbox.enqueue(a, ticket(10, "a"));
inbox.enqueue(b, ticket(20, "b"));
assert!(inbox.busy_state(a).is_busy());
@ -1721,7 +1749,7 @@ mod tests {
/// blocage jusqu'au timeout long (le bug corrigé).
#[tokio::test]
async fn turn_ended_without_reply_wakes_caller_after_grace() {
let a = agent(1);
let a = key(1);
let inbox = inbox_grace(Duration::from_millis(40));
let pending = inbox.enqueue(a, ticket(10, "task"));
@ -1747,7 +1775,7 @@ mod tests {
/// et la complétion de grâce (tête déjà retirée) est un no-op.
#[tokio::test]
async fn reply_before_turn_ended_wins() {
let a = agent(1);
let a = key(1);
let inbox = inbox_grace(Duration::from_millis(40));
let pending = inbox.enqueue(a, ticket(10, "task"));
@ -1769,7 +1797,7 @@ mod tests {
/// avec une grâce longue, on résout via la mailbox avant son expiration.
#[tokio::test]
async fn reply_within_grace_after_turn_ended_wins() {
let a = agent(1);
let a = key(1);
// Long grace ⇒ the reply lands well within it.
let inbox = inbox_grace(Duration::from_secs(30));
@ -1796,7 +1824,7 @@ mod tests {
/// été retirée par la complétion) — typé, idempotent, jamais un panic.
#[tokio::test]
async fn reply_after_grace_is_unmatched() {
let a = agent(1);
let a = key(1);
let inbox = inbox_grace(Duration::from_millis(30));
let pending = inbox.enqueue(a, ticket(10, "task"));
@ -1822,7 +1850,7 @@ mod tests {
/// retirer la tête — `complete_without_reply` skip quand le receiver est fermé.
#[test]
fn fire_and_forget_head_is_preserved_through_grace() {
let a = agent(1);
let a = key(1);
let inbox = inbox_grace(Duration::from_millis(20));
// Human submit: the reply handle is dropped immediately (not awaited).
@ -1841,7 +1869,7 @@ mod tests {
/// idempotent, aucune grâce armée, aucun panic.
#[test]
fn turn_ended_on_idle_agent_is_noop() {
let a = agent(1);
let a = key(1);
let inbox = inbox_grace(Duration::from_millis(20));
inbox.turn_ended(a); // jamais de tour démarré.
assert_eq!(inbox.busy_state(a), AgentBusyState::Idle);
@ -1859,7 +1887,7 @@ mod tests {
let bus = Arc::new(RecordingBus::default());
let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1)))
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1);
let a = key(1);
// Démarrage à froid : gate armé AVANT l'enqueue (ordre de l'orchestrateur).
inbox.mark_starting(a);
@ -1893,7 +1921,7 @@ mod tests {
let bus = Arc::new(RecordingBus::default());
let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1)))
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1);
let a = key(1);
// Démarrage à froid : gate armé AVANT l'enqueue (ordre de l'orchestrateur).
inbox.mark_starting(a);
@ -1927,7 +1955,7 @@ mod tests {
let bus = Arc::new(RecordingBus::default());
let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1)))
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1);
let a = key(1);
// Démarre un tour normal (Busy), SANS gate cold-launch (pas de mark_starting).
inbox.enqueue(a, ticket(10, "task"));
@ -1952,7 +1980,7 @@ mod tests {
let bus = Arc::new(RecordingBus::default());
let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1)))
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1);
let a = key(1);
inbox.mark_starting(a);
inbox.enqueue(a, ticket(10, "cold task"));
@ -1973,7 +2001,7 @@ mod tests {
let bus = Arc::new(RecordingBus::default());
let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1)))
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1);
let a = key(1);
// Pas de mark_starting ⇒ agent chaud.
inbox.enqueue(a, ticket(10, "warm task"));
@ -1994,7 +2022,7 @@ mod tests {
let bus = Arc::new(RecordingBus::default());
let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1)))
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1);
let a = key(1);
// Cold launch SANS pont MCP ⇒ l'orchestrateur N'APPELLE PAS mark_starting.
inbox.enqueue(a, ticket(10, "task"));
@ -2017,7 +2045,7 @@ mod tests {
let bus = Arc::new(RecordingBus::default());
let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1)))
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1);
let a = key(1);
inbox.mark_starting(a);
inbox.enqueue(a, ticket(10, "first"));
@ -2091,7 +2119,7 @@ mod tests {
fn no_heartbeat_past_threshold_marks_stalled() {
let clock = MutClock::new(1_000);
let (inbox, bus) = inbox_with_clock(Arc::clone(&clock));
let a = agent(1);
let a = key(1);
// Profil : seuil de stagnation à 30_000 ms. Armé avant le tour.
inbox.set_stall_threshold(a, Some(30_000));
@ -2109,14 +2137,17 @@ mod tests {
// Au-delà du seuil : exactement une transition Stalled.
clock.set(1_000 + 30_001);
inbox.sweep_stalled();
assert_eq!(bus.liveness_events(), vec![(a, AgentLiveness::Stalled)]);
assert_eq!(
bus.liveness_events(),
vec![(a.agent_id, AgentLiveness::Stalled)]
);
// Idempotent : un second sweep ne ré-émet pas.
clock.set(1_000 + 60_000);
inbox.sweep_stalled();
assert_eq!(
bus.liveness_events(),
vec![(a, AgentLiveness::Stalled)],
vec![(a.agent_id, AgentLiveness::Stalled)],
"déjà stalled ⇒ pas de spam"
);
}
@ -2126,7 +2157,7 @@ mod tests {
fn heartbeat_within_window_resets_last_seen() {
let clock = MutClock::new(1_000);
let (inbox, bus) = inbox_with_clock(Arc::clone(&clock));
let a = agent(1);
let a = key(1);
inbox.set_stall_threshold(a, Some(30_000));
inbox.enqueue(a, ticket(10, "task")); // last_seen=1_000
@ -2149,7 +2180,7 @@ mod tests {
fn liveness_event_once_per_transition() {
let clock = MutClock::new(0);
let (inbox, bus) = inbox_with_clock(Arc::clone(&clock));
let a = agent(1);
let a = key(1);
inbox.set_stall_threshold(a, Some(10_000));
inbox.enqueue(a, ticket(10, "t")); // last_seen=0
@ -2159,7 +2190,10 @@ mod tests {
// Sweeps répétés : pas de ré-émission.
inbox.sweep_stalled();
inbox.sweep_stalled();
assert_eq!(bus.liveness_events(), vec![(a, AgentLiveness::Stalled)]);
assert_eq!(
bus.liveness_events(),
vec![(a.agent_id, AgentLiveness::Stalled)]
);
// Battement tardif ⇒ une seule reprise Alive.
clock.set(20_000);
@ -2167,7 +2201,10 @@ mod tests {
inbox.mark_alive(a); // second battement : déjà Alive ⇒ rien.
assert_eq!(
bus.liveness_events(),
vec![(a, AgentLiveness::Stalled), (a, AgentLiveness::Alive)]
vec![
(a.agent_id, AgentLiveness::Stalled),
(a.agent_id, AgentLiveness::Alive)
]
);
// Re-stall possible après reprise (nouvelle transition).
@ -2176,9 +2213,9 @@ mod tests {
assert_eq!(
bus.liveness_events(),
vec![
(a, AgentLiveness::Stalled),
(a, AgentLiveness::Alive),
(a, AgentLiveness::Stalled),
(a.agent_id, AgentLiveness::Stalled),
(a.agent_id, AgentLiveness::Alive),
(a.agent_id, AgentLiveness::Stalled),
]
);
}
@ -2188,17 +2225,23 @@ mod tests {
fn mark_idle_on_stalled_emits_recovery_and_clears() {
let clock = MutClock::new(0);
let (inbox, bus) = inbox_with_clock(Arc::clone(&clock));
let a = agent(1);
let a = key(1);
inbox.set_stall_threshold(a, Some(5_000));
inbox.enqueue(a, ticket(10, "t"));
clock.set(5_001);
inbox.sweep_stalled();
assert_eq!(bus.liveness_events(), vec![(a, AgentLiveness::Stalled)]);
assert_eq!(
bus.liveness_events(),
vec![(a.agent_id, AgentLiveness::Stalled)]
);
inbox.mark_idle(a); // fin de tour ⇒ reprise Alive + entrée retirée.
assert_eq!(
bus.liveness_events(),
vec![(a, AgentLiveness::Stalled), (a, AgentLiveness::Alive)]
vec![
(a.agent_id, AgentLiveness::Stalled),
(a.agent_id, AgentLiveness::Alive)
]
);
// Plus d'entrée : un sweep ultérieur ne ré-émet rien (même très tard).
@ -2206,7 +2249,10 @@ mod tests {
inbox.sweep_stalled();
assert_eq!(
bus.liveness_events(),
vec![(a, AgentLiveness::Stalled), (a, AgentLiveness::Alive)]
vec![
(a.agent_id, AgentLiveness::Stalled),
(a.agent_id, AgentLiveness::Alive)
]
);
}
@ -2216,7 +2262,7 @@ mod tests {
fn agent_without_threshold_is_never_stalled() {
let clock = MutClock::new(0);
let (inbox, bus) = inbox_with_clock(Arc::clone(&clock));
let a = agent(1);
let a = key(1);
// Pas de set_stall_threshold (ou None) : armé sans seuil au start_turn.
inbox.enqueue(a, ticket(10, "t"));
clock.set(10_000_000); // très loin dans le futur.
@ -2236,7 +2282,7 @@ mod tests {
fn mark_alive_on_unarmed_agent_is_noop() {
let clock = MutClock::new(0);
let (inbox, bus) = inbox_with_clock(Arc::clone(&clock));
let a = agent(1);
let a = key(1);
inbox.mark_alive(a); // jamais de tour démarré.
inbox.sweep_stalled();
assert_eq!(bus.liveness_events(), vec![]);

View File

@ -24,7 +24,7 @@ use std::sync::Mutex;
use tokio::sync::oneshot;
use domain::ids::AgentId;
use domain::ids::RuntimeAgentKey;
use domain::mailbox::{
AgentMailbox, AgentQueueSnapshot, MailboxError, PendingReply, QueuedTicketSnapshot, Ticket,
TicketId, TurnResolution,
@ -44,7 +44,7 @@ struct Slot {
/// In-memory, per-agent FIFO mailbox (the production [`AgentMailbox`]).
#[derive(Default)]
pub struct InMemoryMailbox {
queues: Mutex<HashMap<AgentId, VecDeque<Slot>>>,
queues: Mutex<HashMap<RuntimeAgentKey, VecDeque<Slot>>>,
}
impl InMemoryMailbox {
@ -58,7 +58,7 @@ impl InMemoryMailbox {
/// Number of tickets currently queued for `agent` (test/inspection helper).
#[must_use]
pub fn pending(&self, agent: &AgentId) -> usize {
pub fn pending(&self, agent: &RuntimeAgentKey) -> usize {
self.queues
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
@ -69,7 +69,7 @@ impl InMemoryMailbox {
/// The id of the ticket currently at the head of `agent`'s queue, if any
/// (test/inspection helper).
#[must_use]
pub fn head_ticket(&self, agent: &AgentId) -> Option<TicketId> {
pub fn head_ticket(&self, agent: &RuntimeAgentKey) -> Option<TicketId> {
self.queues
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
@ -80,7 +80,7 @@ impl InMemoryMailbox {
}
impl AgentMailbox for InMemoryMailbox {
fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply {
fn enqueue(&self, agent: RuntimeAgentKey, ticket: Ticket) -> PendingReply {
let (tx, rx) = oneshot::channel::<TurnResolution>();
let ticket_id = ticket.id;
let (depth_before, depth_after) = {
@ -105,7 +105,7 @@ impl AgentMailbox for InMemoryMailbox {
}))
}
fn resolve(&self, agent: AgentId, result: String) -> Result<(), MailboxError> {
fn resolve(&self, agent: RuntimeAgentKey, result: String) -> Result<(), MailboxError> {
// Pop the head slot under the lock, then send **outside** any await (the send
// is non-blocking). If the receiver already went away (caller timed out), the
// ticket is still correctly retired from the head — the queue advances.
@ -121,7 +121,7 @@ impl AgentMailbox for InMemoryMailbox {
application::diag!(
"[mailbox] resolve no-pending agent={agent} (réponse orpheline ?)"
);
MailboxError::NoPendingRequest(agent)
MailboxError::NoPendingRequest(agent.agent_id)
})?;
let before = queue.len();
let slot = queue.pop_front().expect("non-empty queue has a head");
@ -140,7 +140,7 @@ impl AgentMailbox for InMemoryMailbox {
fn resolve_ticket(
&self,
agent: AgentId,
agent: RuntimeAgentKey,
ticket_id: TicketId,
result: String,
) -> Result<(), MailboxError> {
@ -159,7 +159,7 @@ impl AgentMailbox for InMemoryMailbox {
application::diag!(
"[mailbox] resolve_ticket no-queue agent={agent} ticket={ticket_id}"
);
MailboxError::NoPendingRequest(agent)
MailboxError::NoPendingRequest(agent.agent_id)
})?;
let before = queue.len();
let pos = queue
@ -170,7 +170,7 @@ impl AgentMailbox for InMemoryMailbox {
"[mailbox] resolve_ticket no-match agent={agent} ticket={ticket_id} \
queue_depth={before}"
);
MailboxError::NoPendingRequest(agent)
MailboxError::NoPendingRequest(agent.agent_id)
})?;
let slot = queue.remove(pos).expect("position just found is in range");
(slot, before, queue.len())
@ -183,7 +183,7 @@ impl AgentMailbox for InMemoryMailbox {
Ok(())
}
fn cancel_head(&self, agent: AgentId, ticket_id: TicketId) {
fn cancel_head(&self, agent: RuntimeAgentKey, ticket_id: TicketId) {
let (depth_before, depth_after, retired) = {
let mut queues = self
.queues
@ -218,7 +218,7 @@ impl AgentMailbox for InMemoryMailbox {
);
}
fn complete_without_reply(&self, agent: AgentId, ticket_id: TicketId) {
fn complete_without_reply(&self, agent: RuntimeAgentKey, ticket_id: TicketId) {
// Head-only, idempotent, and fire-and-forget-safe (see the port contract).
// `outcome` records what happened for the diag beacon: "sent" (head retired +
// caller woken with ReturnedToPromptNoReply), "skip-closed" (receiver already
@ -273,7 +273,7 @@ impl AgentMailbox for InMemoryMailbox {
}
impl AgentQueueSnapshot for InMemoryMailbox {
fn queue_for(&self, agent: AgentId) -> Vec<QueuedTicketSnapshot> {
fn queue_for(&self, agent: RuntimeAgentKey) -> Vec<QueuedTicketSnapshot> {
// Pure read: clone each ticket's *data* (never the `oneshot::Sender`) under
// the lock, recomputing the FIFO position from the current order (0 = head).
// The queue is observed, not mutated.
@ -304,11 +304,19 @@ impl AgentQueueSnapshot for InMemoryMailbox {
#[cfg(test)]
mod tests {
use super::*;
use domain::AgentId;
fn agent(n: u128) -> AgentId {
AgentId::from_uuid(uuid::Uuid::from_u128(n))
}
fn key(n: u128) -> RuntimeAgentKey {
RuntimeAgentKey::new(
domain::ProjectId::from_uuid(uuid::Uuid::from_u128(1000 + n)),
agent(n),
)
}
fn ticket(n: u128, task: &str) -> Ticket {
Ticket::new(TicketId::from_uuid(uuid::Uuid::from_u128(n)), "Main", task)
}
@ -316,7 +324,7 @@ mod tests {
#[tokio::test]
async fn enqueue_then_resolve_wakes_the_pending_reply() {
let mb = InMemoryMailbox::new();
let a = agent(1);
let a = key(1);
let pending = mb.enqueue(a, ticket(10, "do X"));
mb.resolve(a, "done X".to_owned()).expect("resolve ok");
@ -329,7 +337,7 @@ mod tests {
#[tokio::test]
async fn two_asks_same_target_resolve_fifo_head_first() {
let mb = InMemoryMailbox::new();
let a = agent(1);
let a = key(1);
let p1 = mb.enqueue(a, ticket(10, "first"));
let p2 = mb.enqueue(a, ticket(11, "second"));
assert_eq!(mb.pending(&a), 2);
@ -353,8 +361,8 @@ mod tests {
#[tokio::test]
async fn different_targets_do_not_block_each_other() {
let mb = InMemoryMailbox::new();
let a = agent(1);
let b = agent(2);
let a = key(1);
let b = key(2);
let pa = mb.enqueue(a, ticket(10, "task a"));
let _pb = mb.enqueue(b, ticket(20, "task b"));
@ -368,17 +376,17 @@ mod tests {
#[test]
fn resolve_without_pending_is_a_typed_error() {
let mb = InMemoryMailbox::new();
let a = agent(1);
let a = key(1);
assert_eq!(
mb.resolve(a, "orphan".to_owned()),
Err(MailboxError::NoPendingRequest(a))
Err(MailboxError::NoPendingRequest(a.agent_id))
);
}
#[tokio::test]
async fn cancel_head_retires_the_head_and_advances_the_queue() {
let mb = InMemoryMailbox::new();
let a = agent(1);
let a = key(1);
let p1 = mb.enqueue(a, ticket(10, "stuck"));
let p2 = mb.enqueue(a, ticket(11, "next"));
@ -401,7 +409,7 @@ mod tests {
#[tokio::test]
async fn complete_without_reply_wakes_head_caller_with_returned_to_prompt() {
let mb = InMemoryMailbox::new();
let a = agent(1);
let a = key(1);
let pending = mb.enqueue(a, ticket(10, "awaited"));
// The target returned to its prompt without idea_reply ⇒ the awaiting caller
@ -417,7 +425,7 @@ mod tests {
#[tokio::test]
async fn complete_without_reply_is_noop_when_receiver_already_gone() {
let mb = InMemoryMailbox::new();
let a = agent(1);
let a = key(1);
// Human fire-and-forget: the PendingReply is dropped immediately (not awaited).
drop(mb.enqueue(a, ticket(10, "human submit")));
@ -434,7 +442,7 @@ mod tests {
#[tokio::test]
async fn complete_without_reply_is_noop_when_not_head_and_idempotent() {
let mb = InMemoryMailbox::new();
let a = agent(1);
let a = key(1);
let p1 = mb.enqueue(a, ticket(10, "head"));
let _p2 = mb.enqueue(a, ticket(11, "behind"));
@ -455,7 +463,7 @@ mod tests {
#[tokio::test]
async fn reply_before_completion_wins_then_completion_is_noop() {
let mb = InMemoryMailbox::new();
let a = agent(1);
let a = key(1);
let pending = mb.enqueue(a, ticket(10, "raced"));
mb.resolve(a, "real answer".to_owned()).unwrap();
@ -472,7 +480,7 @@ mod tests {
#[test]
fn cancel_head_is_a_noop_when_head_is_a_different_ticket() {
let mb = InMemoryMailbox::new();
let a = agent(1);
let a = key(1);
let _p1 = mb.enqueue(a, ticket(10, "head"));
// Try to cancel a ticket that is NOT the head ⇒ nothing retired.
mb.cancel_head(a, TicketId::from_uuid(uuid::Uuid::from_u128(99)));
@ -490,13 +498,13 @@ mod tests {
#[test]
fn snapshot_of_empty_queue_is_empty() {
let mb = InMemoryMailbox::new();
assert!(mb.queue_for(agent(1)).is_empty());
assert!(mb.queue_for(key(1)).is_empty());
}
#[test]
fn snapshot_preserves_fifo_order_and_positions() {
let mb = InMemoryMailbox::new();
let a = agent(1);
let a = key(1);
let _p1 = mb.enqueue(a, ticket(10, "first"));
let _p2 = mb.enqueue(a, ticket(11, "second"));
@ -516,7 +524,7 @@ mod tests {
use domain::input::InputSource;
let mb = InMemoryMailbox::new();
let a = agent(1);
let a = key(1);
let from = agent(2);
let conv = ConversationId::from_uuid(uuid::Uuid::from_u128(42));
let _p = mb.enqueue(
@ -535,7 +543,7 @@ mod tests {
#[test]
fn snapshot_is_read_only() {
let mb = InMemoryMailbox::new();
let a = agent(1);
let a = key(1);
let _p1 = mb.enqueue(a, ticket(10, "first"));
let _p2 = mb.enqueue(a, ticket(11, "second"));
@ -550,7 +558,7 @@ mod tests {
#[test]
fn snapshot_updates_after_resolve_ticket() {
let mb = InMemoryMailbox::new();
let a = agent(1);
let a = key(1);
let _p1 = mb.enqueue(a, ticket(10, "first"));
let _p2 = mb.enqueue(a, ticket(11, "second"));
@ -572,7 +580,7 @@ mod tests {
application::diag::set_log_path(path.clone());
let mb = InMemoryMailbox::new();
let a = agent(777_001);
let a = key(777_001);
let t = tid(777_010);
let _p = mb.enqueue(a, Ticket::new(t, "Main", "diag task"));
mb.resolve(a, "done".to_owned()).expect("resolve ok");
@ -596,7 +604,7 @@ mod tests {
#[test]
fn snapshot_updates_after_cancel_head() {
let mb = InMemoryMailbox::new();
let a = agent(1);
let a = key(1);
let _p1 = mb.enqueue(a, ticket(10, "head"));
let _p2 = mb.enqueue(a, ticket(11, "next"));

View File

@ -265,9 +265,7 @@ impl PluginPackageStore for FsPluginPackageStore {
}
fn app_data_dir_label(&self) -> Option<String> {
self.root
.parent()
.map(|p| p.to_string_lossy().into_owned())
self.root.parent().map(|p| p.to_string_lossy().into_owned())
}
}

View File

@ -140,6 +140,7 @@ mod tests {
/// tâches concurrentes à l'arrivée).
fn task_with(conv: &str) -> ScheduledTask {
ScheduledTask::ResumeAgent {
project_id: domain::ProjectId::from_uuid(Uuid::nil()),
agent_id: AgentId::from_uuid(Uuid::from_u128(1)),
node_id: NodeId::from_uuid(Uuid::from_u128(2)),
conversation_id: Some(conv.to_owned()),

View File

@ -396,8 +396,7 @@ mod tests {
#[test]
fn parse_jsonl_event_error_accepts_raw_string() {
let event =
parse_jsonl_event(r#"{"type":"error","error":"panne réseau"}"#).unwrap();
let event = parse_jsonl_event(r#"{"type":"error","error":"panne réseau"}"#).unwrap();
assert_eq!(event, ParsedEvent::Error("panne réseau".to_owned()));
}
}

View File

@ -90,8 +90,8 @@ impl FsProfileStore {
async fn read_doc(&self) -> Result<ProfilesDoc, StoreError> {
match self.fs.read(&self.path()).await {
Ok(bytes) => {
let mut doc: ProfilesDoc =
serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string()))?;
let mut doc: ProfilesDoc = serde_json::from_slice(&bytes)
.map_err(|e| StoreError::Serialization(e.to_string()))?;
for profile in &mut doc.profiles {
if !profile.opencode_backend_is_consistent() {
profile.opencode = None;

View File

@ -99,10 +99,11 @@ impl FsSecretStore {
}
Err(FsError::NotFound(_)) => {
let mut key = [0_u8; KEY_LEN];
SystemRandom::new()
.fill(&mut key)
.map_err(|_| SecretStoreError::Crypto("failed to generate secret key".into()))?;
let dir = RemotePath::new(self.app_data_dir.trim_end_matches(['/', '\\']).to_owned());
SystemRandom::new().fill(&mut key).map_err(|_| {
SecretStoreError::Crypto("failed to generate secret key".into())
})?;
let dir =
RemotePath::new(self.app_data_dir.trim_end_matches(['/', '\\']).to_owned());
self.fs
.create_dir_all(&dir)
.await
@ -228,7 +229,10 @@ mod tests {
impl TempDir {
fn new(label: &str) -> Self {
let root = std::env::temp_dir().join(format!("idea-secrets-store-{label}-{}", uuid::Uuid::new_v4()));
let root = std::env::temp_dir().join(format!(
"idea-secrets-store-{label}-{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&root).unwrap();
Self(root)
}
@ -293,7 +297,10 @@ mod tests {
async fn key_file_has_owner_only_permissions_on_unix() {
let dir = TempDir::new("perms");
let store = store(&dir);
store.put(&SecretRef::new("secret-d"), "value").await.unwrap();
store
.put(&SecretRef::new("secret-d"), "value")
.await
.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;