fix(runtime): isolate agent state by project (#101)
This commit is contained in:
@ -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={}",
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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![]);
|
||||
|
||||
@ -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"));
|
||||
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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()),
|
||||
|
||||
@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -5,7 +5,7 @@ use std::time::Duration;
|
||||
|
||||
use domain::{
|
||||
AgentId, AgentInbox, InboxError, InboxItem, InboxItemKind, InboxReceiptStatus, InboxSource,
|
||||
InputMediator, Ticket, TicketId,
|
||||
InputMediator, ProjectId, RuntimeAgentKey, Ticket, TicketId,
|
||||
};
|
||||
use infrastructure::{
|
||||
start_background_ready_inbox_bridge, BackgroundTaskReadyToDeliver, InMemoryMailbox,
|
||||
@ -26,6 +26,14 @@ fn agent(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn project_id(n: u128) -> ProjectId {
|
||||
ProjectId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn key(project: u128, target: AgentId) -> RuntimeAgentKey {
|
||||
RuntimeAgentKey::new(project_id(project), target)
|
||||
}
|
||||
|
||||
fn ticket_id(n: u128) -> TicketId {
|
||||
TicketId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
@ -70,7 +78,7 @@ fn completion_item(id: u128, target: AgentId, task: u128) -> InboxItem {
|
||||
|
||||
fn make_busy(inbox: &MediatedInbox, target: AgentId) {
|
||||
let ticket = Ticket::new(ticket_id(900), "active", "active turn");
|
||||
let _pending = inbox.enqueue(target, ticket);
|
||||
let _pending = inbox.enqueue(key(1, target), ticket);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -80,12 +88,12 @@ fn enqueue_message_while_busy_is_queued_not_rejected() {
|
||||
make_busy(&inbox, target);
|
||||
|
||||
let receipt = inbox
|
||||
.enqueue_message(target, user_item(1, target, "queued"))
|
||||
.enqueue_message(key(1, target), user_item(1, target, "queued"))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(receipt.status, InboxReceiptStatus::Queued);
|
||||
assert_eq!(receipt.depth, 2);
|
||||
let snapshot = inbox.snapshot(target);
|
||||
let snapshot = inbox.snapshot(key(1, target));
|
||||
assert_eq!(snapshot.depth, 2);
|
||||
assert_eq!(snapshot.items.len(), 1);
|
||||
assert_eq!(snapshot.items[0].body, "queued");
|
||||
@ -98,12 +106,16 @@ fn inbox_fifo_drains_two_entrants_in_order() {
|
||||
let first = user_item(1, target, "first");
|
||||
let second = user_item(2, target, "second");
|
||||
|
||||
inbox.enqueue_message(target, first.clone()).unwrap();
|
||||
inbox.enqueue_message(target, second.clone()).unwrap();
|
||||
inbox
|
||||
.enqueue_message(key(1, target), first.clone())
|
||||
.unwrap();
|
||||
inbox
|
||||
.enqueue_message(key(1, target), second.clone())
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(inbox.dequeue_next(target), Some(first));
|
||||
assert_eq!(inbox.dequeue_next(target), Some(second));
|
||||
assert_eq!(inbox.dequeue_next(target), None);
|
||||
assert_eq!(inbox.dequeue_next(key(1, target)), Some(first));
|
||||
assert_eq!(inbox.dequeue_next(key(1, target)), Some(second));
|
||||
assert_eq!(inbox.dequeue_next(key(1, target)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -113,7 +125,7 @@ fn overflow_normal_message_returns_inbox_full() {
|
||||
make_busy(&inbox, target);
|
||||
|
||||
let err = inbox
|
||||
.enqueue_message(target, user_item(1, target, "overflow"))
|
||||
.enqueue_message(key(1, target), user_item(1, target, "overflow"))
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
@ -132,12 +144,12 @@ fn overflow_completion_is_deferred_not_lost_or_enqueued() {
|
||||
make_busy(&inbox, target);
|
||||
|
||||
let receipt = inbox
|
||||
.enqueue_message(target, completion_item(1, target, 42))
|
||||
.enqueue_message(key(1, target), completion_item(1, target, 42))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(receipt.status, InboxReceiptStatus::Deferred);
|
||||
assert_eq!(receipt.depth, 1);
|
||||
assert!(inbox.snapshot(target).items.is_empty());
|
||||
assert!(inbox.snapshot(key(1, target)).items.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -146,13 +158,13 @@ fn snapshot_exposes_queue_depth_and_items() {
|
||||
let target = agent(1);
|
||||
|
||||
inbox
|
||||
.enqueue_message(target, user_item(1, target, "first"))
|
||||
.enqueue_message(key(1, target), user_item(1, target, "first"))
|
||||
.unwrap();
|
||||
inbox
|
||||
.enqueue_message(target, user_item(2, target, "second"))
|
||||
.enqueue_message(key(1, target), user_item(2, target, "second"))
|
||||
.unwrap();
|
||||
|
||||
let snapshot = inbox.snapshot(target);
|
||||
let snapshot = inbox.snapshot(key(1, target));
|
||||
assert_eq!(snapshot.agent_id, target);
|
||||
assert_eq!(snapshot.depth, 2);
|
||||
assert_eq!(
|
||||
@ -170,19 +182,38 @@ fn drain_removes_only_one_item_at_a_time() {
|
||||
let inbox = inbox(10);
|
||||
let target = agent(1);
|
||||
inbox
|
||||
.enqueue_message(target, user_item(1, target, "first"))
|
||||
.enqueue_message(key(1, target), user_item(1, target, "first"))
|
||||
.unwrap();
|
||||
inbox
|
||||
.enqueue_message(target, user_item(2, target, "second"))
|
||||
.enqueue_message(key(1, target), user_item(2, target, "second"))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(inbox.dequeue_next(target).unwrap().body, "first");
|
||||
assert_eq!(inbox.dequeue_next(key(1, target)).unwrap().body, "first");
|
||||
|
||||
let snapshot = inbox.snapshot(target);
|
||||
let snapshot = inbox.snapshot(key(1, target));
|
||||
assert_eq!(snapshot.depth, 1);
|
||||
assert_eq!(snapshot.items[0].body, "second");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_agent_id_in_distinct_projects_has_isolated_inbox_queues() {
|
||||
let inbox = inbox(10);
|
||||
let target = agent(1);
|
||||
let p1 = key(1, target);
|
||||
let p2 = key(2, target);
|
||||
let first = user_item(1, target, "project one");
|
||||
let second = user_item(2, target, "project two");
|
||||
|
||||
inbox.enqueue_message(p1, first.clone()).unwrap();
|
||||
inbox.enqueue_message(p2, second.clone()).unwrap();
|
||||
|
||||
assert_eq!(inbox.snapshot(p1).depth, 1);
|
||||
assert_eq!(inbox.snapshot(p2).depth, 1);
|
||||
assert_eq!(inbox.dequeue_next(p1), Some(first));
|
||||
assert_eq!(inbox.snapshot(p1).depth, 0);
|
||||
assert_eq!(inbox.snapshot(p2).items, vec![second]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ready_bridge_enqueues_background_completion_item() {
|
||||
let inbox = Arc::new(inbox(10));
|
||||
@ -192,13 +223,13 @@ async fn ready_bridge_enqueues_background_completion_item() {
|
||||
|
||||
tx.send(BackgroundTaskReadyToDeliver {
|
||||
task_id: task_id(42),
|
||||
project_id: domain::ProjectId::from_uuid(Uuid::from_u128(7)),
|
||||
project_id: project_id(7),
|
||||
owner_agent_id: target,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
let snapshot = inbox.snapshot(target);
|
||||
let snapshot = inbox.snapshot(key(7, target));
|
||||
assert_eq!(snapshot.depth, 1);
|
||||
assert_eq!(snapshot.items[0].kind, InboxItemKind::BackgroundCompletion);
|
||||
assert_eq!(
|
||||
|
||||
@ -1146,7 +1146,8 @@ async fn stop_agent_call_closes_the_live_session() {
|
||||
let (service, sessions) = build_service(contexts);
|
||||
// Pre-bind a live PTY session for the agent so StopAgent has something to close.
|
||||
let session_id = SessionId::from_uuid(Uuid::from_u128(555));
|
||||
sessions.insert(
|
||||
sessions.insert_in_project(
|
||||
project().id,
|
||||
PtyHandle { session_id },
|
||||
TerminalSession::starting(
|
||||
session_id,
|
||||
@ -1156,7 +1157,9 @@ async fn stop_agent_call_closes_the_live_session() {
|
||||
PtySize { rows: 24, cols: 80 },
|
||||
),
|
||||
);
|
||||
assert!(sessions.session_for_agent(&agent_id).is_some());
|
||||
assert!(sessions
|
||||
.session_for_agent_in_project(project().id, &agent_id)
|
||||
.is_some());
|
||||
let server = server(service);
|
||||
|
||||
let raw = tools_call(1, "idea_stop_agent", json!({ "target": "dev" }));
|
||||
@ -1166,7 +1169,9 @@ async fn stop_agent_call_closes_the_live_session() {
|
||||
|
||||
// CloseTerminal ran → the agent no longer has a live session.
|
||||
assert!(
|
||||
sessions.session_for_agent(&agent_id).is_none(),
|
||||
sessions
|
||||
.session_for_agent_in_project(project().id, &agent_id)
|
||||
.is_none(),
|
||||
"stop_agent should have removed the session"
|
||||
);
|
||||
}
|
||||
|
||||
@ -17,7 +17,7 @@ use async_trait::async_trait;
|
||||
use domain::agent::{AgentManifest, ManifestEntry};
|
||||
use domain::events::{DomainEvent, OrchestrationSource};
|
||||
use domain::ids::SkillId;
|
||||
use domain::ids::{AgentId, ProfileId, ProjectId};
|
||||
use domain::ids::{AgentId, ProfileId, ProjectId, RuntimeAgentKey};
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::ports::{
|
||||
AgentContextStore, AgentRuntime, AgentSession, AgentSessionError, AgentSessionFactory,
|
||||
@ -840,12 +840,13 @@ async fn ask_request_surfaces_reply_alongside_detail() {
|
||||
|
||||
let svc = Arc::clone(&service);
|
||||
let proj = project();
|
||||
let runtime_key = RuntimeAgentKey::new(proj.id, agent_id);
|
||||
let ask_req = req.clone();
|
||||
let ask = tokio::spawn(async move { process_request_file(&ask_req, &proj, &svc).await });
|
||||
|
||||
// Wait until the ask has enqueued its ticket (blocked awaiting the reply).
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), async {
|
||||
while mailbox.pending(&agent_id) == 0 {
|
||||
while mailbox.pending(&runtime_key) == 0 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
@ -918,11 +919,12 @@ async fn ask_request_no_reply_persists_failed_rendezvous_task() {
|
||||
|
||||
let svc = Arc::clone(&service);
|
||||
let proj = project();
|
||||
let runtime_key = RuntimeAgentKey::new(proj.id, agent_id);
|
||||
let ask_req = req.clone();
|
||||
let ask = tokio::spawn(async move { process_request_file(&ask_req, &proj, &svc).await });
|
||||
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), async {
|
||||
while mailbox.pending(&agent_id) == 0 {
|
||||
while mailbox.pending(&runtime_key) == 0 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
@ -951,7 +953,11 @@ async fn ask_request_no_reply_persists_failed_rendezvous_task() {
|
||||
}
|
||||
other => panic!("expected failure result, got {other:?}"),
|
||||
}
|
||||
assert_eq!(mailbox.pending(&agent_id), 0, "turn-lock mailbox is freed");
|
||||
assert_eq!(
|
||||
mailbox.pending(&runtime_key),
|
||||
0,
|
||||
"turn-lock mailbox is freed"
|
||||
);
|
||||
}
|
||||
|
||||
/// Point 2 — a non-`ask` command (here `spawn_agent`, reply `None`) ⇒ the `reply`
|
||||
|
||||
@ -7,7 +7,9 @@ use std::sync::Arc;
|
||||
|
||||
use domain::ids::ProfileId;
|
||||
use domain::ports::{FileSystem, ProfileStore, RemotePath, SecretRef, StoreError};
|
||||
use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig, OpenCodeProviderConfig, StructuredAdapter};
|
||||
use domain::profile::{
|
||||
AgentProfile, ContextInjection, OpenCodeConfig, OpenCodeProviderConfig, StructuredAdapter,
|
||||
};
|
||||
use infrastructure::{FsProfileStore, LocalFileSystem};
|
||||
use uuid::Uuid;
|
||||
|
||||
@ -173,11 +175,20 @@ async fn profiles_file_is_camelcase_versioned() {
|
||||
/// cloud provider (`opencodeProvider`) — the two mutually exclusive OpenCode
|
||||
/// backends. A profile carrying both is inconsistent.
|
||||
fn opencode_backends() -> (OpenCodeConfig, OpenCodeProviderConfig) {
|
||||
let local =
|
||||
OpenCodeConfig::new("http://localhost:8080/v1", None, "qwen3-coder-30b", None, None).unwrap();
|
||||
let cloud =
|
||||
OpenCodeProviderConfig::new("anthropic", "claude-sonnet-5", SecretRef::new("secret-cloud"))
|
||||
.unwrap();
|
||||
let local = OpenCodeConfig::new(
|
||||
"http://localhost:8080/v1",
|
||||
None,
|
||||
"qwen3-coder-30b",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let cloud = OpenCodeProviderConfig::new(
|
||||
"anthropic",
|
||||
"claude-sonnet-5",
|
||||
SecretRef::new("secret-cloud"),
|
||||
)
|
||||
.unwrap();
|
||||
(local, cloud)
|
||||
}
|
||||
|
||||
@ -203,15 +214,21 @@ async fn read_doc_repairs_stale_opencode_when_both_sections_present() {
|
||||
|
||||
let fs = LocalFileSystem::new();
|
||||
let doc = serde_json::json!({ "version": 1, "profiles": [corrupted] });
|
||||
fs.write(&tmp.child("profiles.json"), &serde_json::to_vec_pretty(&doc).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
fs.write(
|
||||
&tmp.child("profiles.json"),
|
||||
&serde_json::to_vec_pretty(&doc).unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let store = store(&tmp);
|
||||
let listed = store.list().await.expect("read repairs instead of failing");
|
||||
assert_eq!(listed.len(), 1, "corrupted profile preserved as an entry");
|
||||
let repaired = &listed[0];
|
||||
assert!(repaired.opencode.is_none(), "stale local `opencode` dropped on read");
|
||||
assert!(
|
||||
repaired.opencode.is_none(),
|
||||
"stale local `opencode` dropped on read"
|
||||
);
|
||||
assert_eq!(
|
||||
repaired.opencode_provider.as_ref().unwrap().provider_id,
|
||||
"anthropic",
|
||||
|
||||
Reference in New Issue
Block a user