2291 lines
96 KiB
Rust
2291 lines
96 KiB
Rust
//! [`MediatedInbox`] — the [`InputMediator`] adapter (lot C2).
|
|
//!
|
|
//! The single convergence point of an agent's input. It **composes** the existing
|
|
//! [`InMemoryMailbox`] (FIFO + one-shot reply — the correlation engine) and adds the
|
|
//! two things the mailbox alone does not express:
|
|
//!
|
|
//! - a **busy/turn** bookkeeping per agent ([`AgentBusyState`]), so the front can be
|
|
//! told when an agent is processing;
|
|
//! - a **preempt** signal distinct from `enqueue` (Interrompre ≠ Envoyer): it does
|
|
//! **not** queue anything and correlates no ticket.
|
|
//!
|
|
//! It does **not** spawn a second queue: the FIFO is the mailbox's. The first
|
|
//! enqueue while `Idle` starts a turn (agent → `Busy`); `mark_idle` ends it and lets
|
|
//! the next ticket start. In doubt we stay `Busy` but **keep accepting** enqueues
|
|
//! (forward, never reject — cf. cadrage §6 fallback).
|
|
//!
|
|
//! ## Concurrency
|
|
//!
|
|
//! Busy state lives behind a **synchronous** [`Mutex`], held only for O(1) reads and
|
|
//! mutations and **never across an `.await`** (the await is the caller's, on the
|
|
//! returned [`PendingReply`]). The mailbox owns its own locking.
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::Duration;
|
|
|
|
use domain::events::DomainEvent;
|
|
use domain::ids::RuntimeAgentKey;
|
|
use domain::inbox::{
|
|
AgentInbox, AgentInboxSnapshot, InboxError, InboxItem, InboxReceipt, InboxReceiptStatus,
|
|
InboxSource, DEFAULT_AGENT_INBOX_CAPACITY,
|
|
};
|
|
use domain::input::{AgentBusyState, AgentLiveness, InputMediator, SubmitConfig};
|
|
use domain::mailbox::{AgentMailbox, AgentQueueSnapshot, PendingReply, Ticket, TicketId};
|
|
use domain::ports::{EventBus, PtyHandle, PtyPort};
|
|
|
|
use crate::mailbox::InMemoryMailbox;
|
|
|
|
/// Shared busy/idle bookkeeping for one set of agents.
|
|
///
|
|
/// Extracted so the **prompt-ready watcher** (a detached thread observing an agent's
|
|
/// PTY output, lot C5) can flip an agent back to `Idle` without holding the whole
|
|
/// [`MediatedInbox`]: it only needs the busy map + the event bus. This is the single
|
|
/// 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<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<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
|
|
/// [`BusyTracker::mark_starting`] (uniquement quand un pont MCP le libérera), puis
|
|
/// 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<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<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
|
|
/// `deferred`. Sans ce latch, ce signal trouverait `deferred` vide, ne ferait rien
|
|
/// d'utile (l'agent encore `starting` ⇒ pas de `mark_idle`), puis l'`enqueue`
|
|
/// parquerait le tour dans `deferred` — où il resterait à jamais (le watcher
|
|
/// prompt-ready PTY ayant été supprimé, aucun second signal ne viendrait le drainer). Le
|
|
/// 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<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
|
|
/// présent ; `None` ⇒ toute livraison passe par l'événement (médiateur sans PTY,
|
|
/// utilisé par les tests événementiels — comportement historique).
|
|
headless_sink: Option<HeadlessSink>,
|
|
/// Mailbox sous-jacent, pour armer la **fenêtre de grâce** au `prompt_ready` sans
|
|
/// délégation différée : si la cible revient à son prompt sans `idea_reply`, on
|
|
/// réveille l'appelant (via [`AgentMailbox::complete_without_reply`]) au lieu de le
|
|
/// laisser bloqué jusqu'au timeout long. `None` ⇒ pas de grâce (repli sur le filet
|
|
/// timeout — comportement historique pour les médiateurs sans mailbox câblé).
|
|
mailbox: Option<Arc<InMemoryMailbox>>,
|
|
/// Durée de la **fenêtre de grâce** (G) accordée à la cible après son retour au
|
|
/// prompt pour qu'un `idea_reply` arrive avant qu'on complète le tour « sans
|
|
/// réponse ». Prod = [`PROMPT_READY_GRACE`] (2 s) ; un test peut la raccourcir via
|
|
/// [`MediatedInbox::with_grace`].
|
|
grace: Duration,
|
|
}
|
|
|
|
/// Fenêtre de grâce (G) par défaut entre le retour au prompt de la cible et la
|
|
/// complétion « sans réponse » du tour : laisse à un `idea_reply` tardif le temps
|
|
/// d'arriver d'abord. Décision produit (Main) : **2 secondes**.
|
|
const PROMPT_READY_GRACE: Duration = Duration::from_secs(2);
|
|
|
|
/// Payload d'une [`DomainEvent::DelegationReady`] **différée** le temps qu'un agent
|
|
/// lancé à froid atteigne son prompt (fix race cold-launch). Reconstruite à l'identique
|
|
/// quand le prompt-ready watcher déclenche, de sorte que le premier tour soit livré au
|
|
/// bon moment (et un seul `DelegationReady`, comme pour un agent chaud).
|
|
#[derive(Debug, Clone)]
|
|
struct DeferredDelegation {
|
|
ticket: TicketId,
|
|
text: String,
|
|
submit_sequence: Option<String>,
|
|
submit_delay_ms: Option<u32>,
|
|
}
|
|
|
|
/// Sink de livraison « headless » optionnel branché sur le [`BusyTracker`].
|
|
///
|
|
/// Appelé pour **chaque** tour à livrer (chemin chaud immédiat comme drains à froid).
|
|
/// Reçoit l'agent + le payload de délégation et décide :
|
|
/// - `None` ⇒ il a **pris en charge** la livraison (écriture directe dans le PTY d'un
|
|
/// agent headless qui n'a pas de cellule frontend) ;
|
|
/// - `Some(d)` ⇒ il **rend la main** : le tracker publie alors le `DelegationReady`
|
|
/// 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(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).
|
|
/// Aligné sur le défaut du write-portal frontend (`DEFAULT_SUBMIT_DELAY_MS`) : sépare
|
|
/// la soumission du collage pour esquiver la paste-detection des CLI. Utilisé seulement
|
|
/// quand le profil de la cible ne fournit pas de `submit_delay_ms`.
|
|
const DEFAULT_SUBMIT_DELAY_MS: u32 = 350;
|
|
/// Taille maximale d'un fragment de consigne écrit dans le PTY en mode headless.
|
|
const DELEGATION_WRITE_CHUNK_BYTES: usize = 512;
|
|
/// Pause entre deux fragments de consigne en mode headless.
|
|
const DELEGATION_WRITE_CHUNK_DELAY_MS: u64 = 8;
|
|
|
|
/// État de vivacité d'un agent maintenu par le [`BusyTracker`] (lot 2).
|
|
///
|
|
/// Pur (aucune I/O) : un timestamp `last_seen_ms` rafraîchi à chaque battement, le
|
|
/// seuil `stall_after_ms` du profil (cf. [`domain::profile::LivenessStrategy`]) et
|
|
/// l'état courant `current` pour décider d'une **transition** (et donc d'un seul
|
|
/// événement). `stall_after_ms = None` ⇒ pas de détection (comportement legacy).
|
|
#[derive(Debug, Clone, Copy)]
|
|
struct LivenessState {
|
|
/// Epoch-millis du dernier battement (ou du démarrage du tour).
|
|
last_seen_ms: u64,
|
|
/// Seuil de stagnation du profil de l'agent. `None` ⇒ détection désactivée.
|
|
stall_after_ms: Option<u32>,
|
|
/// État de vivacité courant (pour n'émettre qu'à la transition).
|
|
current: AgentLiveness,
|
|
}
|
|
|
|
impl BusyTracker {
|
|
fn new(events: Option<Arc<dyn EventBus>>) -> Self {
|
|
Self {
|
|
busy: Mutex::new(HashMap::new()),
|
|
liveness: Mutex::new(HashMap::new()),
|
|
starting: Mutex::new(HashSet::new()),
|
|
deferred: Mutex::new(HashMap::new()),
|
|
released: Mutex::new(HashSet::new()),
|
|
events,
|
|
headless_sink: None,
|
|
mailbox: None,
|
|
grace: PROMPT_READY_GRACE,
|
|
}
|
|
}
|
|
|
|
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<RuntimeAgentKey, DeferredDelegation>> {
|
|
self.deferred
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
}
|
|
|
|
fn lock_released(&self) -> std::sync::MutexGuard<'_, HashSet<RuntimeAgentKey>> {
|
|
self.released
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
}
|
|
|
|
/// Marque un agent **en démarrage à froid** : son tout premier tour devra être
|
|
/// *gaté* sur la readiness MCP (la `DelegationReady` sera différée jusqu'à la
|
|
/// connexion du pont MCP). À n'appeler **que** lorsqu'un pont MCP libérera le tour
|
|
/// (`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: 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<RuntimeAgentKey, AgentBusyState>> {
|
|
self.busy
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
}
|
|
|
|
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: RuntimeAgentKey, liveness: AgentLiveness) {
|
|
if let Some(events) = &self.events {
|
|
events.publish(DomainEvent::AgentLivenessChanged {
|
|
agent_id: agent.agent_id,
|
|
liveness,
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Enregistre le seuil de stagnation du profil d'un agent au moment où son tour
|
|
/// 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: RuntimeAgentKey, stall_after_ms: Option<u32>, now_ms: u64) {
|
|
self.lock_liveness().insert(
|
|
agent,
|
|
LivenessState {
|
|
last_seen_ms: now_ms,
|
|
stall_after_ms,
|
|
current: AgentLiveness::Alive,
|
|
},
|
|
);
|
|
}
|
|
|
|
/// 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: RuntimeAgentKey, now_ms: u64) {
|
|
let recovered = {
|
|
let mut map = self.lock_liveness();
|
|
let Some(state) = map.get_mut(&agent) else {
|
|
return;
|
|
};
|
|
state.last_seen_ms = now_ms;
|
|
if state.current == AgentLiveness::Stalled {
|
|
state.current = AgentLiveness::Alive;
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
};
|
|
if recovered {
|
|
self.publish_liveness(agent, AgentLiveness::Alive);
|
|
}
|
|
}
|
|
|
|
/// Balaye tous les agents et, pour chacun dont le tour stagne
|
|
/// (`now - last_seen > stall_after_ms` et seuil défini), bascule `Alive→Stalled`
|
|
/// en émettant **une seule** transition. **Pure et testable sans horloge réelle** :
|
|
/// `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<RuntimeAgentKey> = {
|
|
let mut map = self.lock_liveness();
|
|
map.iter_mut()
|
|
.filter_map(|(agent, state)| {
|
|
let threshold = state.stall_after_ms?;
|
|
if state.current != AgentLiveness::Alive {
|
|
return None; // déjà Stalled : pas de ré-émission.
|
|
}
|
|
let elapsed = now_ms.saturating_sub(state.last_seen_ms);
|
|
if elapsed > u64::from(threshold) {
|
|
state.current = AgentLiveness::Stalled;
|
|
Some(*agent)
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect()
|
|
};
|
|
for agent in newly_stalled {
|
|
// Transition de vivacité Alive→Stalled : le tour de l'agent n'a pas battu
|
|
// depuis plus que son seuil. Beacon événementiel (une fois par transition,
|
|
// jamais en boucle) — utile pour repérer une cible silencieuse pendant un ask.
|
|
application::diag!("[input-mediator] liveness Alive->Stalled agent={agent}");
|
|
self.publish_liveness(agent, AgentLiveness::Stalled);
|
|
}
|
|
}
|
|
|
|
/// 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: RuntimeAgentKey) {
|
|
let was_stalled = self
|
|
.lock_liveness()
|
|
.remove(&agent)
|
|
.is_some_and(|s| s.current == AgentLiveness::Stalled);
|
|
if was_stalled {
|
|
self.publish_liveness(agent, AgentLiveness::Alive);
|
|
}
|
|
}
|
|
|
|
fn busy_state(&self, agent: RuntimeAgentKey) -> AgentBusyState {
|
|
self.lock()
|
|
.get(&agent)
|
|
.copied()
|
|
.unwrap_or(AgentBusyState::Idle)
|
|
}
|
|
|
|
/// 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: RuntimeAgentKey, state: AgentBusyState) -> bool {
|
|
let mut busy = self.lock();
|
|
let entry = busy.entry(agent).or_insert(AgentBusyState::Idle);
|
|
if entry.is_busy() {
|
|
application::diag!("[input-mediator] start_turn queued behind busy agent={agent}");
|
|
false
|
|
} else {
|
|
application::diag!("[input-mediator] start_turn started agent={agent} state={state:?}");
|
|
*entry = state;
|
|
true
|
|
}
|
|
}
|
|
|
|
/// 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: 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
|
|
// charge) ; sinon il renvoie `Some(d)` et on retombe sur l'événement.
|
|
let d = match &self.headless_sink {
|
|
Some(sink) => match sink(agent, d) {
|
|
Some(d) => d,
|
|
None => {
|
|
application::diag!(
|
|
"[input-mediator] delegationReady handled by headless sink agent={agent}"
|
|
);
|
|
return;
|
|
}
|
|
},
|
|
None => d,
|
|
};
|
|
if let Some(events) = &self.events {
|
|
application::diag!(
|
|
"[input-mediator] delegationReady -> write-portal agent={agent} ticket={} text_bytes={} submit_sequence={} submit_delay_ms={:?}",
|
|
d.ticket,
|
|
d.text.as_bytes().len(),
|
|
describe_submit_sequence(d.submit_sequence.as_deref()),
|
|
d.submit_delay_ms,
|
|
);
|
|
events.publish(DomainEvent::DelegationReady {
|
|
agent_id: agent.agent_id,
|
|
ticket: d.ticket,
|
|
text: d.text,
|
|
submit_sequence: d.submit_sequence,
|
|
submit_delay_ms: d.submit_delay_ms,
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Signal de **fin de tour** émis par le [`domain::ports::TurnWatcher`] à l'apparition
|
|
/// d'une fin de tour dans le transcript (Claude `turn_duration`). Remplace **verbatim**
|
|
/// l'ancienne branche `None` du watcher prompt-ready PTY supprimé : seul le déclencheur
|
|
/// change (transcript au lieu d'un sigil PTY).
|
|
///
|
|
/// La cible a terminé son tour sans `idea_reply`. On capture le ticket actif AVANT
|
|
/// `mark_idle` (qui efface l'état Busy), puis on libère le busy state / la FIFO. La
|
|
/// fin de tour ne porte AUCUNE réponse : pour ne pas laisser l'appelant bloqué jusqu'au
|
|
/// timeout long, on **arme une fenêtre de grâce** G — si aucun `idea_reply` n'a corrélé
|
|
/// le ticket d'ici là, on complète le tour « sans réponse » (réveille l'appelant avec
|
|
/// 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: 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"
|
|
);
|
|
self.mark_idle(agent);
|
|
if let (Some(ticket), Some(mailbox)) = (active, self.mailbox.clone()) {
|
|
let grace = self.grace;
|
|
application::diag!(
|
|
"[input-mediator] turn_ended arming grace agent={agent} ticket={ticket} grace_ms={}",
|
|
grace.as_millis(),
|
|
);
|
|
// Thread détaché (jamais de hot loop, jamais de blocage de l'appelant) :
|
|
// attend G puis tente la complétion « sans réponse ». `complete_without_reply`
|
|
// est head-only/idempotent et préserve le fire-and-forget humain.
|
|
std::thread::spawn(move || {
|
|
std::thread::sleep(grace);
|
|
mailbox.complete_without_reply(agent, ticket);
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Libère un premier tour différé sur **readiness de démarrage** (connexion du pont
|
|
/// MCP de l'agent). Draine le `DeferredDelegation` retenu s'il existe (et retire
|
|
/// 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: 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.
|
|
application::diag!(
|
|
"[input-mediator] release_cold_start (mcp-ready) released deferred delegation agent={agent} ticket={}",
|
|
d.ticket
|
|
);
|
|
self.publish_deferred(agent, d);
|
|
} else if was_starting {
|
|
// **Ordre inverse de la race** : la readiness MCP est arrivée AVANT que
|
|
// l'`enqueue` ait parqué son tour. On pose le latch « déjà libéré » : le
|
|
// prochain `enqueue` (agent encore vu comme démarrant) livrera immédiatement au
|
|
// lieu de parquer dans `deferred` — où le tour resterait à jamais (le watcher
|
|
// prompt-ready PTY ayant été supprimé, aucun second signal ne le drainerait).
|
|
// Hors démarrage à froid (`was_starting == false`), aucun latch : no-op.
|
|
application::diag!(
|
|
"[input-mediator] release_cold_start before deferred enqueue (cold-start) -> released latch agent={agent}"
|
|
);
|
|
self.lock_released().insert(agent);
|
|
}
|
|
}
|
|
|
|
/// 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: RuntimeAgentKey) {
|
|
let was_busy = {
|
|
let mut busy = self.lock();
|
|
busy.insert(agent, AgentBusyState::Idle)
|
|
.is_some_and(|s| s.is_busy())
|
|
};
|
|
if was_busy {
|
|
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,
|
|
busy: false,
|
|
});
|
|
}
|
|
}
|
|
// Fin de tour : retirer l'entrée de vivacité (émet la reprise si l'agent
|
|
// était `Stalled`). Indépendant de `was_busy` pour rester idempotent.
|
|
self.clear_liveness(agent);
|
|
}
|
|
}
|
|
|
|
/// Supplies the epoch-millis stamp used for `AgentBusyState::Busy { since_ms }`.
|
|
///
|
|
/// Injectable so tests are deterministic and the adapter stays decoupled from the
|
|
/// wall clock (the composition root wires the real clock).
|
|
pub trait MillisClock: Send + Sync {
|
|
/// Current time as milliseconds since the Unix epoch.
|
|
fn now_ms(&self) -> u64;
|
|
}
|
|
|
|
/// Wall-clock implementation of [`MillisClock`] (composition-root default).
|
|
#[derive(Debug, Clone, Copy, Default)]
|
|
pub struct SystemMillisClock;
|
|
|
|
impl MillisClock for SystemMillisClock {
|
|
fn now_ms(&self) -> u64 {
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
|
|
.unwrap_or(0)
|
|
}
|
|
}
|
|
|
|
/// In-memory mediated inbox: one FIFO per agent (the mailbox) plus busy state.
|
|
///
|
|
/// Since ARCHITECTURE §20, `enqueue` **no longer writes the turn into the PTY** (the
|
|
/// `\n` band-aid is gone — it never submitted in raw mode and produced a "double chat").
|
|
/// On the enqueue that starts a turn (Idle→Busy) it publishes a
|
|
/// [`DomainEvent::DelegationReady`] carrying the task text + ticket + the target's
|
|
/// submit config; the **frontend** write-portal owns the single physical PTY write. A
|
|
/// wired [`PtyPort`] is kept only to observe the output stream for prompt-ready
|
|
/// detection (lot C5) and to deliver the `preempt` interrupt byte.
|
|
pub struct MediatedInbox {
|
|
mailbox: Arc<InMemoryMailbox>,
|
|
/// Shared busy/idle authority (also handed to prompt-ready watcher threads, C5).
|
|
tracker: Arc<BusyTracker>,
|
|
clock: Arc<dyn MillisClock>,
|
|
/// Optional PTY port: present ⇒ the inbox owns the turn-delivery write **and** can
|
|
/// observe an agent's output stream for prompt-ready detection (lot C5).
|
|
pty: Option<Arc<dyn PtyPort>>,
|
|
/// 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<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<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<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<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>>,
|
|
/// Bounded inbox capacity per agent.
|
|
inbox_capacity: usize,
|
|
/// Fenêtre de grâce (G) propagée au [`BusyTracker`] à chaque (re)construction.
|
|
/// Prod = [`PROMPT_READY_GRACE`] (2 s) ; surchargée par [`Self::with_grace`] en test.
|
|
grace: Duration,
|
|
}
|
|
|
|
impl MediatedInbox {
|
|
/// Builds an inbox over a shared [`InMemoryMailbox`] with the given clock, without
|
|
/// turn delivery (the orchestrator writes the turn itself).
|
|
#[must_use]
|
|
pub fn new(mailbox: Arc<InMemoryMailbox>, clock: Arc<dyn MillisClock>) -> Self {
|
|
let handles = Arc::new(Mutex::new(HashMap::new()));
|
|
let front_owned = Arc::new(Mutex::new(HashSet::new()));
|
|
let tracker = Self::build_tracker(
|
|
None,
|
|
None,
|
|
&handles,
|
|
&front_owned,
|
|
&mailbox,
|
|
PROMPT_READY_GRACE,
|
|
);
|
|
Self {
|
|
mailbox,
|
|
tracker,
|
|
clock,
|
|
pty: None,
|
|
handles,
|
|
front_owned,
|
|
submit: Mutex::new(HashMap::new()),
|
|
stall: Mutex::new(HashMap::new()),
|
|
inbox_items: Mutex::new(HashMap::new()),
|
|
inbox_capacity: configured_inbox_capacity(),
|
|
grace: PROMPT_READY_GRACE,
|
|
}
|
|
}
|
|
|
|
/// Construit le [`BusyTracker`] avec, quand un PTY est présent, le **sink headless**
|
|
/// branché (cf. [`HeadlessSink`]). Sans PTY ⇒ aucun sink (livraison par événement).
|
|
fn build_tracker(
|
|
events: Option<Arc<dyn EventBus>>,
|
|
pty: Option<&Arc<dyn PtyPort>>,
|
|
handles: &Arc<Mutex<HashMap<RuntimeAgentKey, PtyHandle>>>,
|
|
front_owned: &Arc<Mutex<HashSet<RuntimeAgentKey>>>,
|
|
mailbox: &Arc<InMemoryMailbox>,
|
|
grace: Duration,
|
|
) -> Arc<BusyTracker> {
|
|
let mut tracker = BusyTracker::new(events);
|
|
// Le tracker porte le mailbox + la grâce pour pouvoir compléter « sans réponse »
|
|
// un tour dont la cible est revenue au prompt sans `idea_reply` (cf. prompt_ready).
|
|
tracker.mailbox = Some(Arc::clone(mailbox));
|
|
tracker.grace = grace;
|
|
if let Some(pty) = pty {
|
|
tracker.headless_sink = Some(Self::make_headless_sink(
|
|
Arc::clone(pty),
|
|
Arc::clone(handles),
|
|
Arc::clone(front_owned),
|
|
));
|
|
}
|
|
Arc::new(tracker)
|
|
}
|
|
|
|
/// Fabrique le sink de livraison headless : pour un agent **sans cellule frontend**
|
|
/// (absent de `front_owned`), écrit la tâche dans son PTY (texte, puis — après le
|
|
/// délai anti-paste-detection — la séquence de soumission) et renvoie `None` (pris
|
|
/// en charge). Pour un agent avec cellule, ou sans handle PTY connu, renvoie
|
|
/// `Some(d)` ⇒ le tracker publie l'événement `DelegationReady` comme avant.
|
|
fn make_headless_sink(
|
|
pty: Arc<dyn PtyPort>,
|
|
handles: Arc<Mutex<HashMap<RuntimeAgentKey, PtyHandle>>>,
|
|
front_owned: Arc<Mutex<HashSet<RuntimeAgentKey>>>,
|
|
) -> HeadlessSink {
|
|
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
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.contains(&agent)
|
|
{
|
|
eprintln!(
|
|
"[input-mediator] front-owned delivery selected agent={agent} ticket={}",
|
|
d.ticket
|
|
);
|
|
return Some(d);
|
|
}
|
|
// Agent headless : récupérer son handle PTY. Absent ⇒ repli sur l'événement
|
|
// (best-effort, ne devrait pas arriver pour un agent qui livre un tour).
|
|
let handle = handles
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.get(&agent)
|
|
.cloned();
|
|
let Some(handle) = handle else {
|
|
eprintln!(
|
|
"[input-mediator] no PTY handle for headless delivery agent={agent} ticket={}",
|
|
d.ticket
|
|
);
|
|
return Some(d);
|
|
};
|
|
let pty = Arc::clone(&pty);
|
|
let text = d.text;
|
|
let ticket = d.ticket;
|
|
let submit = d
|
|
.submit_sequence
|
|
.filter(|s| !s.is_empty())
|
|
.unwrap_or_else(|| "\r".to_owned());
|
|
let delay = u64::from(d.submit_delay_ms.unwrap_or(DEFAULT_SUBMIT_DELAY_MS));
|
|
// Écriture sur un thread détaché : ne JAMAIS bloquer l'appelant (thread du
|
|
// watcher prompt-ready, tâche tokio du serveur MCP, ou le fil de l'enqueue).
|
|
// Texte d'abord, puis la séquence de soumission après le délai — comme le
|
|
// write-portal frontend — pour esquiver la détection de coller des CLI.
|
|
// La consigne est fragmentée : les TUI (Codex surtout) peuvent traiter un
|
|
// gros write unique comme un paste et garder/perdre la fin au démarrage.
|
|
std::thread::spawn(move || {
|
|
eprintln!(
|
|
"[input-mediator] headless write text start agent={agent} ticket={ticket} handle={} text_bytes={} submit_sequence={} submit_delay_ms={delay}",
|
|
handle.session_id,
|
|
text.as_bytes().len(),
|
|
describe_submit_sequence(Some(&submit)),
|
|
);
|
|
match write_delegation_chunks(pty.as_ref(), &handle, &text) {
|
|
Ok(()) => eprintln!(
|
|
"[input-mediator] headless write text ok agent={agent} ticket={ticket} handle={}",
|
|
handle.session_id
|
|
),
|
|
Err(e) => {
|
|
eprintln!(
|
|
"[input-mediator] headless write text failed agent={agent} ticket={ticket} handle={} error={e}",
|
|
handle.session_id
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
if delay > 0 {
|
|
std::thread::sleep(std::time::Duration::from_millis(delay));
|
|
}
|
|
eprintln!(
|
|
"[input-mediator] headless submit start agent={agent} ticket={ticket} handle={} submit_sequence={}",
|
|
handle.session_id,
|
|
describe_submit_sequence(Some(&submit)),
|
|
);
|
|
match pty.write(&handle, submit.as_bytes()) {
|
|
Ok(()) => eprintln!(
|
|
"[input-mediator] headless submit ok agent={agent} ticket={ticket} handle={}",
|
|
handle.session_id
|
|
),
|
|
Err(e) => eprintln!(
|
|
"[input-mediator] headless submit failed agent={agent} ticket={ticket} handle={} error={e}",
|
|
handle.session_id
|
|
),
|
|
}
|
|
});
|
|
None
|
|
})
|
|
}
|
|
|
|
/// Wires an [`EventBus`] so busy/idle transitions publish
|
|
/// [`DomainEvent::AgentBusyChanged`] at their source (cadrage C4 §4.2). Builder
|
|
/// additive: callers that do not wire a bus stay silent.
|
|
#[must_use]
|
|
pub fn with_events(mut self, events: Arc<dyn EventBus>) -> Self {
|
|
// Reconstruit le tracker avec le bus ET — si un PTY est déjà câblé (cas
|
|
// `with_pty().with_events()` du composition root) — le sink headless, sinon
|
|
// l'ordre des builders perdrait la livraison headless.
|
|
self.tracker = Self::build_tracker(
|
|
Some(events),
|
|
self.pty.as_ref(),
|
|
&self.handles,
|
|
&self.front_owned,
|
|
&self.mailbox,
|
|
self.grace,
|
|
);
|
|
self
|
|
}
|
|
|
|
/// **Test seam**: shrinks the prompt-ready **grace window** (G, see
|
|
/// [`PROMPT_READY_GRACE`]) so a no-reply test need not wait the full 2 s. Rebuilds
|
|
/// the tracker with the new grace; production code keeps the default. Call at
|
|
/// construction time (before any turn) — it resets the freshly built tracker.
|
|
#[doc(hidden)]
|
|
#[must_use]
|
|
pub fn with_grace(mut self, grace: Duration) -> Self {
|
|
self.grace = grace;
|
|
self.tracker = Self::build_tracker(
|
|
self.tracker.events.clone(),
|
|
self.pty.as_ref(),
|
|
&self.handles,
|
|
&self.front_owned,
|
|
&self.mailbox,
|
|
grace,
|
|
);
|
|
self
|
|
}
|
|
|
|
/// Builds an inbox that **delivers** the turn through `pty` to each agent's bound
|
|
/// handle (cadrage C3 §5.2). Use [`MediatedInbox::bind_handle`] to register the
|
|
/// agent's live handle before/at enqueue time.
|
|
#[must_use]
|
|
pub fn with_pty(
|
|
mailbox: Arc<InMemoryMailbox>,
|
|
clock: Arc<dyn MillisClock>,
|
|
pty: Arc<dyn PtyPort>,
|
|
) -> Self {
|
|
let handles = Arc::new(Mutex::new(HashMap::new()));
|
|
let front_owned = Arc::new(Mutex::new(HashSet::new()));
|
|
let pty_opt = Some(pty);
|
|
let tracker = Self::build_tracker(
|
|
None,
|
|
pty_opt.as_ref(),
|
|
&handles,
|
|
&front_owned,
|
|
&mailbox,
|
|
PROMPT_READY_GRACE,
|
|
);
|
|
Self {
|
|
mailbox,
|
|
tracker,
|
|
clock,
|
|
pty: pty_opt,
|
|
handles,
|
|
front_owned,
|
|
submit: Mutex::new(HashMap::new()),
|
|
stall: Mutex::new(HashMap::new()),
|
|
inbox_items: Mutex::new(HashMap::new()),
|
|
inbox_capacity: configured_inbox_capacity(),
|
|
grace: PROMPT_READY_GRACE,
|
|
}
|
|
}
|
|
|
|
/// Convenience constructor over a fresh mailbox and the wall clock.
|
|
#[must_use]
|
|
pub fn in_memory() -> Self {
|
|
Self::new(
|
|
Arc::new(InMemoryMailbox::new()),
|
|
Arc::new(SystemMillisClock),
|
|
)
|
|
}
|
|
|
|
/// Overrides bounded inbox capacity. Intended for tests and explicit wiring.
|
|
#[must_use]
|
|
pub fn with_inbox_capacity(mut self, capacity: usize) -> Self {
|
|
self.inbox_capacity = capacity;
|
|
self
|
|
}
|
|
|
|
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<RuntimeAgentKey, SubmitConfig>> {
|
|
self.submit
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
}
|
|
|
|
fn stall(&self) -> std::sync::MutexGuard<'_, HashMap<RuntimeAgentKey, Option<u32>>> {
|
|
self.stall
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
}
|
|
|
|
fn inbox_items(&self) -> std::sync::MutexGuard<'_, HashMap<TicketId, InboxItem>> {
|
|
self.inbox_items
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
}
|
|
|
|
/// **Détection de stagnation** (lot 2) : balaie les agents `Busy` et bascule
|
|
/// `Alive→Stalled` ceux dont le dernier battement remonte à plus de
|
|
/// `stall_after_ms`. Émet `AgentLivenessChanged` **une fois par transition**. La
|
|
/// logique de décision est **pure** ([`BusyTracker::sweep_stalled`]) : `now_ms` est
|
|
/// fourni par l'horloge injectée, donc testable sans horloge réelle. Destinée à
|
|
/// être appelée périodiquement par une tâche détenue au composition root.
|
|
pub fn sweep_stalled(&self) {
|
|
self.tracker.sweep_stalled(self.clock.now_ms());
|
|
}
|
|
|
|
/// The underlying mailbox (e.g. for `cancel_head` / `resolve` from the orchestrator).
|
|
#[must_use]
|
|
pub fn mailbox(&self) -> Arc<InMemoryMailbox> {
|
|
Arc::clone(&self.mailbox)
|
|
}
|
|
}
|
|
|
|
/// Composes the text delivered into a human-facing terminal turn.
|
|
///
|
|
/// Inter-agent conversation no longer uses this path: structured/headless asks send the
|
|
/// raw task through `AgentSession::send` and capture the model `Final`. Therefore this
|
|
/// terminal delivery must not inject any inter-agent ticket/reply protocol.
|
|
fn delegation_preamble(requester: &str, ticket: TicketId, task: &str) -> String {
|
|
let _ = (requester, ticket);
|
|
task.to_owned()
|
|
}
|
|
|
|
fn write_delegation_chunks(
|
|
pty: &dyn PtyPort,
|
|
handle: &PtyHandle,
|
|
text: &str,
|
|
) -> Result<(), domain::ports::PtyError> {
|
|
let mut start = 0;
|
|
let mut current_len = 0;
|
|
|
|
for (idx, ch) in text.char_indices() {
|
|
let len = ch.len_utf8();
|
|
if current_len > 0 && current_len + len > DELEGATION_WRITE_CHUNK_BYTES {
|
|
pty.write(handle, text[start..idx].as_bytes())?;
|
|
std::thread::sleep(std::time::Duration::from_millis(
|
|
DELEGATION_WRITE_CHUNK_DELAY_MS,
|
|
));
|
|
start = idx;
|
|
current_len = 0;
|
|
}
|
|
current_len += len;
|
|
}
|
|
|
|
pty.write(handle, text[start..].as_bytes())
|
|
}
|
|
|
|
fn describe_submit_sequence(value: Option<&str>) -> String {
|
|
match value {
|
|
None => "<default>".to_owned(),
|
|
Some("") => "<empty>".to_owned(),
|
|
Some(value) => value
|
|
.chars()
|
|
.map(|ch| match ch {
|
|
'\r' => "\\r".to_owned(),
|
|
'\n' => "\\n".to_owned(),
|
|
'\u{7f}' => "\\x7f".to_owned(),
|
|
ch if ch.is_control() => format!("\\x{:02x}", ch as u32),
|
|
ch => ch.to_string(),
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(""),
|
|
}
|
|
}
|
|
|
|
impl InputMediator for MediatedInbox {
|
|
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
|
|
// reject the sender (forward fallback).
|
|
let now_ms = self.clock.now_ms();
|
|
let started_turn = self.tracker.start_turn(
|
|
agent,
|
|
AgentBusyState::Busy {
|
|
ticket: ticket_id,
|
|
since_ms: now_ms,
|
|
},
|
|
);
|
|
// Sur l'enqueue qui **démarre** un tour : armer une fenêtre de vivacité fraîche
|
|
// avec le seuil de stagnation stashé du profil (lot 2). `last_seen = now`, état
|
|
// `Alive`. Un profil sans seuil (`None`) arme une entrée jamais déclarée stalled
|
|
// (zéro régression). Un re-enqueue pendant `Busy` ne ré-arme pas (le tour court).
|
|
if started_turn {
|
|
let stall_after_ms = self.stall().get(&agent).copied().flatten();
|
|
self.tracker.arm_liveness(agent, stall_after_ms, now_ms);
|
|
}
|
|
// Publish only on the enqueue that **starts** a turn (Idle→Busy); a second
|
|
// enqueue while Busy queues behind without re-announcing (cadrage C4 §4.2,
|
|
// ARCHITECTURE §20). Published outside the busy mutex (the tracker released it
|
|
// above). On a started turn we emit BOTH the advisory busy beacon AND the new
|
|
// `DelegationReady` carrying the task text + ticket + the target's submit
|
|
// config: the backend NO LONGER writes the turn into the PTY (the `\n` band-aid
|
|
// is gone — it never submitted in raw mode). The frontend write-portal owns the
|
|
// physical write (text + submit_sequence) through the single PTY writer.
|
|
if started_turn {
|
|
if let Some(events) = &self.tracker.events {
|
|
events.publish(DomainEvent::AgentBusyChanged {
|
|
agent_id: agent.agent_id,
|
|
busy: true,
|
|
});
|
|
let submit = self.submit().get(&agent).cloned().unwrap_or_default();
|
|
let text = delegation_preamble(&ticket.requester, ticket_id, &ticket.task);
|
|
// **Fix race cold-launch** : si l'agent est en démarrage à froid (inscrit
|
|
// par `mark_starting` quand un pont MCP le libérera), ON DIFFÈRE la
|
|
// `DelegationReady` jusqu'à la readiness MCP (le CLI n'a pas encore chargé
|
|
// ses outils — la livrer maintenant perdrait le premier tour).
|
|
// `release_cold_start` la publiera. Agent chaud (pas dans `starting`) ⇒
|
|
// publication immédiate, comportement inchangé.
|
|
let deferred = DeferredDelegation {
|
|
ticket: ticket_id,
|
|
text,
|
|
submit_sequence: submit.sequence,
|
|
submit_delay_ms: submit.delay_ms,
|
|
};
|
|
if self.tracker.lock_starting().contains(&agent) {
|
|
// Agent en démarrage à froid. Si un signal de readiness est DÉJÀ arrivé
|
|
// (latch « déjà libéré » posé par `release_cold_start`
|
|
// AVANT cet enqueue — l'ordre inverse de la race), on consomme le latch,
|
|
// on retire l'agent du gate et on livre TOUT DE SUITE : sinon ce tour
|
|
// serait parqué dans `deferred` sans plus aucun signal pour le drainer
|
|
// (blocage éternel). Sinon (readiness pas encore arrivée), on parque
|
|
// normalement : le prochain signal le drainera. Exactement-une-fois dans
|
|
// les deux ordres (release-avant-enqueue ET enqueue-avant-release).
|
|
if self.tracker.lock_released().remove(&agent) {
|
|
application::diag!(
|
|
"[input-mediator] enqueue sees released latch (cold-start, reverse order) -> deliver now agent={agent} ticket={ticket_id}"
|
|
);
|
|
self.tracker.lock_starting().remove(&agent);
|
|
self.tracker.publish_deferred(agent, deferred);
|
|
} else {
|
|
application::diag!(
|
|
"[input-mediator] enqueue deferred (cold-start gate) agent={agent} ticket={ticket_id}"
|
|
);
|
|
self.tracker.lock_deferred().insert(agent, deferred);
|
|
}
|
|
} else {
|
|
self.tracker.publish_deferred(agent, deferred);
|
|
}
|
|
}
|
|
}
|
|
self.mailbox.enqueue(agent, ticket)
|
|
}
|
|
|
|
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.
|
|
// Do not publish DelegationReady here: that would leak the task into the
|
|
// human-facing terminal/write portal while the headless turn is already running.
|
|
let now_ms = self.clock.now_ms();
|
|
let started_turn = self.tracker.start_turn(
|
|
agent,
|
|
AgentBusyState::Busy {
|
|
ticket: ticket_id,
|
|
since_ms: now_ms,
|
|
},
|
|
);
|
|
if started_turn {
|
|
let stall_after_ms = self.stall().get(&agent).copied().flatten();
|
|
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,
|
|
busy: true,
|
|
});
|
|
}
|
|
}
|
|
self.mailbox.enqueue(agent, ticket)
|
|
}
|
|
|
|
fn bind_handle(&self, agent: RuntimeAgentKey, handle: PtyHandle) {
|
|
eprintln!(
|
|
"[input-mediator] bind handle agent={agent} handle={}",
|
|
handle.session_id
|
|
);
|
|
self.handles().insert(agent, handle);
|
|
}
|
|
|
|
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
|
|
// was replaced by the transcript `TurnWatcher` (armed at the composition root,
|
|
// once per live session) which calls `turn_ended`.
|
|
eprintln!(
|
|
"[input-mediator] bind handle with submit agent={agent} handle={} submit_sequence={} submit_delay_ms={:?}",
|
|
handle.session_id,
|
|
describe_submit_sequence(submit.sequence.as_deref()),
|
|
submit.delay_ms,
|
|
);
|
|
self.handles().insert(agent, handle);
|
|
self.submit().insert(agent, submit);
|
|
}
|
|
|
|
fn delivers_turn(&self, agent: RuntimeAgentKey) -> bool {
|
|
self.pty.is_some() && self.handles().get(&agent).is_some()
|
|
}
|
|
|
|
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
|
|
// (diffère la `DelegationReady`) puis par `release_cold_start` (la libère).
|
|
self.tracker.mark_starting(agent);
|
|
}
|
|
|
|
fn release_cold_start(&self, agent: RuntimeAgentKey) {
|
|
self.tracker.release_cold_start(agent);
|
|
}
|
|
|
|
fn set_front_attached(&self, agent: RuntimeAgentKey, attached: bool) {
|
|
let mut front = self
|
|
.front_owned
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
if attached {
|
|
eprintln!("[input-mediator] front attached agent={agent}");
|
|
front.insert(agent);
|
|
} else {
|
|
eprintln!("[input-mediator] front detached agent={agent}");
|
|
front.remove(&agent);
|
|
}
|
|
}
|
|
|
|
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
|
|
// byte written into the agent's bound PTY handle: ESC (`\x1b`), the stop key
|
|
// CLI agents honour. A missing handle/port is a no-op (the agent simply has no
|
|
// live stream to interrupt). The busy state is left untouched: it returns to
|
|
// Idle through `mark_idle` (prompt-ready / explicit signal), not here.
|
|
application::diag!("[input-mediator] preempt agent={agent} (interrupt byte, no ticket)");
|
|
if let Some(pty) = &self.pty {
|
|
if let Some(handle) = self.handles().get(&agent).cloned() {
|
|
let _ = pty.write(&handle, b"\x1b");
|
|
}
|
|
}
|
|
}
|
|
|
|
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: 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: 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: 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: RuntimeAgentKey) -> AgentBusyState {
|
|
self.tracker.busy_state(agent)
|
|
}
|
|
}
|
|
|
|
impl AgentInbox for MediatedInbox {
|
|
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,
|
|
item_agent_id: item.agent_id,
|
|
});
|
|
}
|
|
|
|
let depth_before = self.mailbox.pending(&agent);
|
|
if depth_before >= self.inbox_capacity {
|
|
if item.is_lossless_system() {
|
|
return Ok(InboxReceipt {
|
|
item_id: item.id,
|
|
agent_id: agent.agent_id,
|
|
runtime_key: agent,
|
|
depth: depth_before,
|
|
status: InboxReceiptStatus::Deferred,
|
|
});
|
|
}
|
|
return Err(InboxError::InboxFull {
|
|
agent_id: agent.agent_id,
|
|
capacity: self.inbox_capacity,
|
|
});
|
|
}
|
|
|
|
let item_id = item.id;
|
|
let ticket = inbox_item_to_ticket(&item);
|
|
self.inbox_items().insert(item_id, item);
|
|
let _pending = self.mailbox.enqueue(agent, ticket);
|
|
let depth = self.mailbox.pending(&agent);
|
|
if let Some(events) = &self.tracker.events {
|
|
events.publish(DomainEvent::AgentInboxQueued {
|
|
agent_id: agent.agent_id,
|
|
depth,
|
|
});
|
|
}
|
|
Ok(InboxReceipt {
|
|
item_id,
|
|
agent_id: agent.agent_id,
|
|
runtime_key: agent,
|
|
depth,
|
|
status: InboxReceiptStatus::Queued,
|
|
})
|
|
}
|
|
|
|
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,
|
|
depth: self.snapshot(agent).depth,
|
|
});
|
|
}
|
|
Some(item)
|
|
}
|
|
|
|
fn snapshot(&self, agent: RuntimeAgentKey) -> AgentInboxSnapshot {
|
|
let items_by_id = self.inbox_items();
|
|
let items: Vec<InboxItem> = self
|
|
.mailbox
|
|
.queue_for(agent)
|
|
.into_iter()
|
|
.filter_map(|ticket| items_by_id.get(&ticket.id).cloned())
|
|
.collect();
|
|
AgentInboxSnapshot {
|
|
agent_id: agent.agent_id,
|
|
runtime_key: agent,
|
|
depth: self.mailbox.pending(&agent),
|
|
items,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn configured_inbox_capacity() -> usize {
|
|
std::env::var("IDEA_AGENT_INBOX_CAPACITY")
|
|
.ok()
|
|
.and_then(|raw| raw.parse::<usize>().ok())
|
|
.filter(|capacity| *capacity > 0)
|
|
.unwrap_or(DEFAULT_AGENT_INBOX_CAPACITY)
|
|
}
|
|
|
|
fn inbox_item_to_ticket(item: &InboxItem) -> Ticket {
|
|
let conversation = domain::conversation::ConversationId::from_uuid(uuid::Uuid::nil());
|
|
match item.source {
|
|
InboxSource::Agent { agent_id } => Ticket::from_agent(
|
|
item.id,
|
|
agent_id,
|
|
conversation,
|
|
agent_id.to_string(),
|
|
item.body.clone(),
|
|
),
|
|
InboxSource::Human => Ticket::from_human(item.id, conversation, "vous", item.body.clone()),
|
|
InboxSource::BackgroundTask { .. } | InboxSource::System => {
|
|
Ticket::from_human(item.id, conversation, "IdeA", item.body.clone())
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
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);
|
|
impl MillisClock for FixedClock {
|
|
fn now_ms(&self) -> u64 {
|
|
self.0
|
|
}
|
|
}
|
|
|
|
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::from_human(
|
|
TicketId::from_uuid(uuid::Uuid::from_u128(n)),
|
|
ConversationId::from_uuid(uuid::Uuid::from_u128(1000 + n)),
|
|
"User",
|
|
task,
|
|
)
|
|
}
|
|
|
|
fn inbox_at(now_ms: u64) -> MediatedInbox {
|
|
MediatedInbox::new(
|
|
Arc::new(InMemoryMailbox::new()),
|
|
Arc::new(FixedClock(now_ms)),
|
|
)
|
|
}
|
|
|
|
/// Records every [`DomainEvent`] published, for busy/idle assertions.
|
|
#[derive(Default)]
|
|
struct RecordingBus(Mutex<Vec<DomainEvent>>);
|
|
impl EventBus for RecordingBus {
|
|
fn publish(&self, event: DomainEvent) {
|
|
self.0
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.push(event);
|
|
}
|
|
fn subscribe(&self) -> domain::ports::EventStream {
|
|
unreachable!("RecordingBus is publish-only for these tests")
|
|
}
|
|
}
|
|
impl RecordingBus {
|
|
fn busy_events(&self) -> Vec<(AgentId, bool)> {
|
|
self.0
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.iter()
|
|
.filter_map(|e| match e {
|
|
DomainEvent::AgentBusyChanged { agent_id, busy } => Some((*agent_id, *busy)),
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// The `(ticket, text, submit_sequence, submit_delay_ms)` of every
|
|
/// `DelegationReady` published, in order.
|
|
#[allow(clippy::type_complexity)]
|
|
fn delegation_ready(&self) -> Vec<(TicketId, String, Option<String>, Option<u32>)> {
|
|
self.0
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.iter()
|
|
.filter_map(|e| match e {
|
|
DomainEvent::DelegationReady {
|
|
ticket,
|
|
text,
|
|
submit_sequence,
|
|
submit_delay_ms,
|
|
..
|
|
} => Some((
|
|
*ticket,
|
|
text.clone(),
|
|
submit_sequence.clone(),
|
|
*submit_delay_ms,
|
|
)),
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn busy_event_fires_on_turn_start_and_idle_on_mark_idle() {
|
|
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 = 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.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.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.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.agent_id, true), (a.agent_id, false)]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn preempt_emits_no_busy_event() {
|
|
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 = 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.agent_id, true)]);
|
|
}
|
|
|
|
// ====================================================================
|
|
// §20 — enqueue no longer PTY-writes; it publishes DelegationReady
|
|
// ====================================================================
|
|
|
|
#[test]
|
|
fn enqueue_publishes_exactly_one_delegation_ready_on_idle_to_busy() {
|
|
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 = key(1);
|
|
|
|
// Idle→Busy ⇒ exactly one DelegationReady carrying the task text + ticket.
|
|
inbox.enqueue(a, ticket(10, "do the thing"));
|
|
let ready = bus.delegation_ready();
|
|
assert_eq!(ready.len(), 1, "exactly one DelegationReady on turn start");
|
|
let tid = TicketId::from_uuid(uuid::Uuid::from_u128(10));
|
|
assert_eq!(ready[0].0, tid);
|
|
assert_eq!(ready[0].1, delegation_preamble("User", tid, "do the thing"));
|
|
assert!(
|
|
ready[0].1 == "do the thing",
|
|
"delivered text must be the raw task: {:?}",
|
|
ready[0].1
|
|
);
|
|
|
|
// A second enqueue while Busy must NOT re-publish (consistent with started_turn).
|
|
inbox.enqueue(a, ticket(11, "queued"));
|
|
assert_eq!(
|
|
bus.delegation_ready().len(),
|
|
1,
|
|
"no DelegationReady on a re-enqueue while Busy"
|
|
);
|
|
|
|
// After mark_idle, a fresh enqueue starts a new turn ⇒ a second DelegationReady.
|
|
inbox.mark_idle(a);
|
|
inbox.enqueue(a, ticket(12, "next turn"));
|
|
let ready = bus.delegation_ready();
|
|
assert_eq!(ready.len(), 2, "new turn ⇒ a new DelegationReady");
|
|
assert_eq!(ready[1].1, "next turn");
|
|
}
|
|
|
|
#[test]
|
|
fn enqueue_silent_marks_busy_without_delivery() {
|
|
let bus = Arc::new(RecordingBus::default());
|
|
let pty = Arc::new(FakePty::new());
|
|
let inbox = MediatedInbox::with_pty(
|
|
Arc::new(InMemoryMailbox::new()),
|
|
Arc::new(FixedClock(1)),
|
|
Arc::clone(&pty) as Arc<dyn PtyPort>,
|
|
)
|
|
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
|
|
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.agent_id, true)]);
|
|
assert!(
|
|
bus.delegation_ready().is_empty(),
|
|
"headless bookkeeping must not leak a prompt to the terminal"
|
|
);
|
|
assert!(
|
|
pty.writes.lock().unwrap().is_empty(),
|
|
"headless bookkeeping must not write into the visible PTY"
|
|
);
|
|
assert_eq!(inbox.mailbox.pending(&a), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn enqueue_message_queues_inbox_item_without_starting_turn() {
|
|
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 = 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,
|
|
source: InboxSource::BackgroundTask { task_id },
|
|
kind: domain::InboxItemKind::BackgroundCompletion,
|
|
body: "Background task completed.".to_owned(),
|
|
created_at_ms: 1,
|
|
correlation_id: Some(task_id.to_string()),
|
|
};
|
|
|
|
let receipt = inbox.enqueue_message(a, item.clone()).unwrap();
|
|
|
|
assert_eq!(receipt.status, InboxReceiptStatus::Queued);
|
|
assert_eq!(inbox.busy_state(a), AgentBusyState::Idle);
|
|
assert_eq!(inbox.mailbox.pending(&a), 1);
|
|
assert!(
|
|
bus.busy_events().is_empty(),
|
|
"inbox enqueue must not start a mediated turn"
|
|
);
|
|
assert_eq!(inbox.dequeue_next(a), Some(item));
|
|
assert_eq!(inbox.mailbox.pending(&a), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn delegation_ready_carries_bound_submit_config() {
|
|
let bus = Arc::new(RecordingBus::default());
|
|
let pty = Arc::new(FakePty::new());
|
|
let inbox = MediatedInbox::with_pty(
|
|
Arc::new(InMemoryMailbox::new()),
|
|
Arc::new(FixedClock(1)),
|
|
Arc::clone(&pty) as Arc<dyn PtyPort>,
|
|
)
|
|
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
|
|
let a = key(1);
|
|
let h = handle(1);
|
|
|
|
// Bind the target's submit config (resolved from its profile by the service).
|
|
inbox.bind_handle_with_submit(a, h, SubmitConfig::new(Some("\r".to_owned()), Some(42)));
|
|
// A frontend cell is mounted ⇒ delivery flows through the event (the front
|
|
// writes). This test asserts the event carries the bound submit config.
|
|
inbox.set_front_attached(a, true);
|
|
inbox.enqueue(a, ticket(10, "task"));
|
|
|
|
let ready = bus.delegation_ready();
|
|
assert_eq!(ready.len(), 1);
|
|
assert_eq!(ready[0].2.as_deref(), Some("\r"), "submit_sequence echoed");
|
|
assert_eq!(ready[0].3, Some(42), "submit_delay_ms echoed");
|
|
}
|
|
|
|
#[test]
|
|
fn enqueue_without_bound_submit_yields_none_fields() {
|
|
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 = key(1);
|
|
inbox.enqueue(a, ticket(10, "task"));
|
|
let ready = bus.delegation_ready();
|
|
assert_eq!(ready.len(), 1);
|
|
assert_eq!(
|
|
ready[0].2, None,
|
|
"no bound submit ⇒ None (front applies default)"
|
|
);
|
|
assert_eq!(ready[0].3, None);
|
|
}
|
|
|
|
#[test]
|
|
fn front_attached_agent_is_delivered_via_event_not_backend_write() {
|
|
// §20 nominal: a mounted frontend cell owns the physical write. The backend
|
|
// publishes DelegationReady and writes NOTHING into the PTY itself.
|
|
let pty = Arc::new(FakePty::new());
|
|
let bus = Arc::new(RecordingBus::default());
|
|
let inbox = MediatedInbox::with_pty(
|
|
Arc::new(InMemoryMailbox::new()),
|
|
Arc::new(FixedClock(1)),
|
|
Arc::clone(&pty) as Arc<dyn PtyPort>,
|
|
)
|
|
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
|
|
let a = key(1);
|
|
inbox.bind_handle_with_submit(a, handle(1), SubmitConfig::default());
|
|
inbox.set_front_attached(a, true);
|
|
|
|
inbox.enqueue(a, ticket(10, "do X"));
|
|
|
|
assert_eq!(
|
|
bus.delegation_ready().len(),
|
|
1,
|
|
"front-attached ⇒ exactly one DelegationReady event for the write-portal"
|
|
);
|
|
// The headless path is not taken ⇒ no thread is spawned ⇒ no PTY write, ever.
|
|
assert!(
|
|
pty.writes.lock().unwrap().is_empty(),
|
|
"front-attached ⇒ the backend must NOT write the PTY (the front does)"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn headless_agent_without_front_cell_is_written_by_the_backend() {
|
|
// Cold/background delegation target: no frontend cell is mounted, so NOBODY
|
|
// consumes DelegationReady. The mediator must therefore write the turn into the
|
|
// PTY itself (text + submit), else the agent never receives its task — the
|
|
// root cause of "a delegated agent never replies".
|
|
let pty = Arc::new(FakePty::new());
|
|
let bus = Arc::new(RecordingBus::default());
|
|
let inbox = MediatedInbox::with_pty(
|
|
Arc::new(InMemoryMailbox::new()),
|
|
Arc::new(FixedClock(1)),
|
|
Arc::clone(&pty) as Arc<dyn PtyPort>,
|
|
)
|
|
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
|
|
let a = key(1);
|
|
// No set_front_attached ⇒ headless.
|
|
inbox.bind_handle_with_submit(a, handle(1), SubmitConfig::default());
|
|
|
|
inbox.enqueue(a, ticket(10, "do X"));
|
|
|
|
assert!(
|
|
wait_until(|| pty.writes.lock().unwrap().len() >= 2),
|
|
"headless delivery writes the task text + submit sequence into the PTY"
|
|
);
|
|
let writes = pty.writes.lock().unwrap().clone();
|
|
assert!(
|
|
String::from_utf8_lossy(&writes[0]).contains("do X"),
|
|
"first write carries the task text"
|
|
);
|
|
assert_eq!(
|
|
writes[1], b"\r",
|
|
"second write is the default submit sequence"
|
|
);
|
|
assert!(
|
|
bus.delegation_ready().is_empty(),
|
|
"headless delivery takes over ⇒ no DelegationReady event is published"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn headless_agent_chunks_long_delegation_before_submit() {
|
|
let pty = Arc::new(FakePty::new());
|
|
let bus = Arc::new(RecordingBus::default());
|
|
let inbox = MediatedInbox::with_pty(
|
|
Arc::new(InMemoryMailbox::new()),
|
|
Arc::new(FixedClock(1)),
|
|
Arc::clone(&pty) as Arc<dyn PtyPort>,
|
|
)
|
|
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
|
|
let a = key(1);
|
|
let task = format!("début {} fin", "x".repeat(1200));
|
|
inbox.bind_handle_with_submit(a, handle(1), SubmitConfig::default());
|
|
|
|
inbox.enqueue(a, ticket(10, &task));
|
|
|
|
// Attendre le SUBMIT final (`\r`, écrit après le délai), pas un simple seuil de
|
|
// writes : le nombre de chunks de contenu dépend de la taille du payload (préambule
|
|
// + tâche), donc seul le `\r` terminal est un point d'arrêt stable.
|
|
assert!(
|
|
wait_until(|| {
|
|
let w = pty.writes.lock().unwrap();
|
|
w.len() >= 4 && w.last().map(Vec::as_slice) == Some(b"\r".as_slice())
|
|
}),
|
|
"long headless delivery writes multiple chunks followed by submit"
|
|
);
|
|
let writes = pty.writes.lock().unwrap().clone();
|
|
assert_eq!(writes.last().map(Vec::as_slice), Some(b"\r".as_slice()));
|
|
let delivered = writes[..writes.len() - 1]
|
|
.iter()
|
|
.flat_map(|chunk| chunk.iter().copied())
|
|
.collect::<Vec<_>>();
|
|
let delivered = String::from_utf8(delivered).unwrap();
|
|
assert!(
|
|
delivered.contains(&task),
|
|
"chunked writes reconstruct the full task"
|
|
);
|
|
assert!(
|
|
bus.delegation_ready().is_empty(),
|
|
"headless delivery does not also publish DelegationReady"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn enqueue_returns_pending_reply_resolved_via_mailbox() {
|
|
let inbox = inbox_at(5);
|
|
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();
|
|
assert_eq!(
|
|
pending.await.unwrap(),
|
|
domain::mailbox::TurnResolution::Replied("done".to_owned())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn first_enqueue_marks_busy_with_ticket_and_stamp() {
|
|
let inbox = inbox_at(1234);
|
|
let a = key(1);
|
|
assert_eq!(inbox.busy_state(a), AgentBusyState::Idle);
|
|
inbox.enqueue(a, ticket(10, "t"));
|
|
assert_eq!(
|
|
inbox.busy_state(a),
|
|
AgentBusyState::Busy {
|
|
ticket: TicketId::from_uuid(uuid::Uuid::from_u128(10)),
|
|
since_ms: 1234,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn second_enqueue_while_busy_keeps_first_ticket_and_does_not_reject() {
|
|
let inbox = inbox_at(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.
|
|
assert_eq!(
|
|
inbox.busy_state(a).ticket(),
|
|
Some(TicketId::from_uuid(uuid::Uuid::from_u128(10)))
|
|
);
|
|
assert_eq!(inbox.mailbox().pending(&a), 2, "forward, never reject");
|
|
}
|
|
|
|
#[test]
|
|
fn mark_idle_returns_to_idle_so_next_turn_can_start() {
|
|
let inbox = inbox_at(1);
|
|
let a = key(1);
|
|
inbox.enqueue(a, ticket(10, "t"));
|
|
assert!(inbox.busy_state(a).is_busy());
|
|
inbox.mark_idle(a);
|
|
assert_eq!(inbox.busy_state(a), AgentBusyState::Idle);
|
|
// A subsequent enqueue starts a fresh turn.
|
|
inbox.enqueue(a, ticket(11, "t2"));
|
|
assert_eq!(
|
|
inbox.busy_state(a).ticket(),
|
|
Some(TicketId::from_uuid(uuid::Uuid::from_u128(11)))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn preempt_is_distinct_from_enqueue_and_resolves_no_ticket() {
|
|
let inbox = inbox_at(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.
|
|
assert_eq!(inbox.mailbox().pending(&a), 1);
|
|
// Nothing answered the caller via preempt.
|
|
inbox.mailbox().resolve(a, "real".to_owned()).unwrap();
|
|
assert_eq!(
|
|
pending.await.unwrap(),
|
|
domain::mailbox::TurnResolution::Replied("real".to_owned())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn two_enqueues_same_agent_serialise_in_one_fifo() {
|
|
let inbox = inbox_at(1);
|
|
let a = key(1);
|
|
inbox.enqueue(a, ticket(10, "first"));
|
|
inbox.enqueue(a, ticket(11, "second"));
|
|
assert_eq!(inbox.mailbox().pending(&a), 2);
|
|
assert_eq!(
|
|
inbox.mailbox().head_ticket(&a),
|
|
Some(TicketId::from_uuid(uuid::Uuid::from_u128(10))),
|
|
"FIFO order preserved"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn different_agents_are_independent_not_blocking() {
|
|
let inbox = inbox_at(1);
|
|
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());
|
|
assert!(inbox.busy_state(b).is_busy());
|
|
// Marking A idle leaves B untouched.
|
|
inbox.mark_idle(a);
|
|
assert_eq!(inbox.busy_state(a), AgentBusyState::Idle);
|
|
assert!(inbox.busy_state(b).is_busy());
|
|
assert_eq!(inbox.mailbox().pending(&a), 1);
|
|
assert_eq!(inbox.mailbox().pending(&b), 1);
|
|
}
|
|
|
|
// ====================================================================
|
|
// Headless delivery — the backend writes the PTY when no front cell is mounted
|
|
// ====================================================================
|
|
|
|
use domain::ids::SessionId;
|
|
use domain::ports::{ExitStatus, OutputStream, PtyError, PtyHandle as Handle, SpawnSpec};
|
|
use domain::terminal::PtySize;
|
|
|
|
/// A fake [`PtyPort`] that records `write`s (for the headless-delivery tests).
|
|
/// `subscribe_output` yields an empty stream; `spawn`/`resize`/`kill`/`scrollback`
|
|
/// are unused stubs.
|
|
struct FakePty {
|
|
writes: Mutex<Vec<Vec<u8>>>,
|
|
}
|
|
impl FakePty {
|
|
fn new() -> Self {
|
|
Self {
|
|
writes: Mutex::new(Vec::new()),
|
|
}
|
|
}
|
|
}
|
|
#[async_trait::async_trait]
|
|
impl PtyPort for FakePty {
|
|
async fn spawn(&self, _spec: SpawnSpec, _size: PtySize) -> Result<Handle, PtyError> {
|
|
Ok(Handle {
|
|
session_id: SessionId::new_random(),
|
|
})
|
|
}
|
|
fn write(&self, _handle: &Handle, data: &[u8]) -> Result<(), PtyError> {
|
|
self.writes.lock().unwrap().push(data.to_vec());
|
|
Ok(())
|
|
}
|
|
fn resize(&self, _handle: &Handle, _size: PtySize) -> Result<(), PtyError> {
|
|
Ok(())
|
|
}
|
|
fn subscribe_output(&self, _handle: &Handle) -> Result<OutputStream, PtyError> {
|
|
Ok(Box::new(std::iter::empty()))
|
|
}
|
|
fn scrollback(&self, _handle: &Handle) -> Result<Vec<u8>, PtyError> {
|
|
Ok(Vec::new())
|
|
}
|
|
async fn wait(&self, _handle: &Handle) -> Result<ExitStatus, PtyError> {
|
|
Ok(ExitStatus { code: Some(0) })
|
|
}
|
|
fn try_wait(&self, _handle: &Handle) -> Result<Option<ExitStatus>, PtyError> {
|
|
Ok(Some(ExitStatus { code: Some(0) }))
|
|
}
|
|
async fn kill(&self, _handle: &Handle) -> Result<ExitStatus, PtyError> {
|
|
Ok(ExitStatus { code: Some(0) })
|
|
}
|
|
}
|
|
|
|
fn handle(n: u128) -> Handle {
|
|
Handle {
|
|
session_id: SessionId::from_uuid(uuid::Uuid::from_u128(n)),
|
|
}
|
|
}
|
|
|
|
/// Spins until `cond` holds or the deadline passes (headless writes happen on a
|
|
/// detached thread, so the effect is observed asynchronously).
|
|
fn wait_until(mut cond: impl FnMut() -> bool) -> bool {
|
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
|
|
while std::time::Instant::now() < deadline {
|
|
if cond() {
|
|
return true;
|
|
}
|
|
std::thread::sleep(std::time::Duration::from_millis(5));
|
|
}
|
|
cond()
|
|
}
|
|
|
|
// --- backstop no-reply : `turn_ended` (fin de tour transcript) ⇒ réveil après G ---
|
|
//
|
|
// Le déclencheur n'est plus un watcher PTY mais le `TurnWatcher` transcript ; on
|
|
// appelle donc `turn_ended` directement (le watcher est testé à part dans
|
|
// `inspector::claude_turn_watcher`). La logique grâce/complétion est inchangée.
|
|
|
|
/// Builds a grace-shortened inbox (no PTY needed: `turn_ended` is invoked directly).
|
|
fn inbox_grace(grace: Duration) -> MediatedInbox {
|
|
MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1)))
|
|
.with_grace(grace)
|
|
}
|
|
|
|
/// La cible termine son tour SANS `idea_reply` : après la fenêtre de grâce,
|
|
/// l'appelant en attente est réveillé avec `ReturnedToPromptNoReply` — pas de
|
|
/// blocage jusqu'au timeout long (le bug corrigé).
|
|
#[tokio::test]
|
|
async fn turn_ended_without_reply_wakes_caller_after_grace() {
|
|
let a = key(1);
|
|
let inbox = inbox_grace(Duration::from_millis(40));
|
|
|
|
let pending = inbox.enqueue(a, ticket(10, "task"));
|
|
inbox.turn_ended(a);
|
|
|
|
// Bounded await: the grace (40 ms) must resolve the caller well under this.
|
|
let res = tokio::time::timeout(Duration::from_secs(2), pending)
|
|
.await
|
|
.expect("must NOT hang until the long timeout")
|
|
.expect("a turn outcome, not a closed channel");
|
|
assert_eq!(
|
|
res,
|
|
domain::mailbox::TurnResolution::ReturnedToPromptNoReply
|
|
);
|
|
assert_eq!(
|
|
inbox.mailbox().pending(&a),
|
|
0,
|
|
"head retired by the no-reply completion"
|
|
);
|
|
}
|
|
|
|
/// `idea_reply` arrivé AVANT la fin de tour gagne : l'appelant reçoit le `Replied`
|
|
/// 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 = key(1);
|
|
let inbox = inbox_grace(Duration::from_millis(40));
|
|
|
|
let pending = inbox.enqueue(a, ticket(10, "task"));
|
|
// idea_reply resolves the head BEFORE the turn-end signal.
|
|
inbox.mailbox().resolve(a, "answer".to_owned()).unwrap();
|
|
inbox.turn_ended(a);
|
|
|
|
let res = tokio::time::timeout(Duration::from_secs(2), pending)
|
|
.await
|
|
.expect("no hang")
|
|
.expect("outcome");
|
|
assert_eq!(
|
|
res,
|
|
domain::mailbox::TurnResolution::Replied("answer".to_owned())
|
|
);
|
|
}
|
|
|
|
/// `idea_reply` arrivé APRÈS la fin de tour mais DANS la fenêtre de grâce gagne :
|
|
/// 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 = key(1);
|
|
// Long grace ⇒ the reply lands well within it.
|
|
let inbox = inbox_grace(Duration::from_secs(30));
|
|
|
|
let pending = inbox.enqueue(a, ticket(10, "task"));
|
|
inbox.turn_ended(a);
|
|
// turn_ended a marqué Idle et armé la grâce ; on répond pendant la grâce.
|
|
assert!(
|
|
!inbox.busy_state(a).is_busy(),
|
|
"turn_ended marks idle (grace armed)"
|
|
);
|
|
inbox.mailbox().resolve(a, "in-time".to_owned()).unwrap();
|
|
|
|
let res = tokio::time::timeout(Duration::from_secs(2), pending)
|
|
.await
|
|
.expect("no hang")
|
|
.expect("outcome");
|
|
assert_eq!(
|
|
res,
|
|
domain::mailbox::TurnResolution::Replied("in-time".to_owned())
|
|
);
|
|
}
|
|
|
|
/// `idea_reply` arrivé APRÈS l'expiration de la grâce devient *unmatched* (la tête a
|
|
/// été retirée par la complétion) — typé, idempotent, jamais un panic.
|
|
#[tokio::test]
|
|
async fn reply_after_grace_is_unmatched() {
|
|
let a = key(1);
|
|
let inbox = inbox_grace(Duration::from_millis(30));
|
|
|
|
let pending = inbox.enqueue(a, ticket(10, "task"));
|
|
inbox.turn_ended(a);
|
|
// The caller is woken by the grace completion (head retired).
|
|
let res = tokio::time::timeout(Duration::from_secs(2), pending)
|
|
.await
|
|
.expect("no hang")
|
|
.expect("outcome");
|
|
assert_eq!(
|
|
res,
|
|
domain::mailbox::TurnResolution::ReturnedToPromptNoReply
|
|
);
|
|
|
|
// A late idea_reply now has no head to resolve ⇒ typed NoPendingRequest.
|
|
assert!(
|
|
inbox.mailbox().resolve(a, "too late".to_owned()).is_err(),
|
|
"late reply is unmatched, not a panic"
|
|
);
|
|
}
|
|
|
|
/// Submit humain *fire-and-forget* (PendingReply non attendu) : la grâce ne doit PAS
|
|
/// 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 = key(1);
|
|
let inbox = inbox_grace(Duration::from_millis(20));
|
|
|
|
// Human submit: the reply handle is dropped immediately (not awaited).
|
|
drop(inbox.enqueue(a, ticket(10, "human submit")));
|
|
inbox.turn_ended(a);
|
|
// Give the grace thread time to run; the head must survive (skip-closed).
|
|
std::thread::sleep(Duration::from_millis(120));
|
|
assert_eq!(
|
|
inbox.mailbox().pending(&a),
|
|
1,
|
|
"fire-and-forget head preserved (grace is a no-op on a closed receiver)"
|
|
);
|
|
}
|
|
|
|
/// `turn_ended` sur un agent déjà `Idle` (aucun ticket actif) ⇒ simple `mark_idle`
|
|
/// idempotent, aucune grâce armée, aucun panic.
|
|
#[test]
|
|
fn turn_ended_on_idle_agent_is_noop() {
|
|
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);
|
|
}
|
|
|
|
// ====================================================================
|
|
// Fix race cold-launch — `mark_starting` gate le PREMIER tour sur la readiness MCP
|
|
// ====================================================================
|
|
|
|
/// Readiness de démarrage MCP : un 1er tour différé (`mark_starting` + enqueue) reste
|
|
/// retenu tant que rien ne le libère ; `release_cold_start` (signal connexion MCP) le
|
|
/// draine ⇒ exactement UNE `DelegationReady`. C'est le seul drain du tour différé.
|
|
#[test]
|
|
fn release_cold_start_delivers_deferred_first_turn() {
|
|
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 = key(1);
|
|
|
|
// Démarrage à froid : gate armé AVANT l'enqueue (ordre de l'orchestrateur).
|
|
inbox.mark_starting(a);
|
|
inbox.enqueue(a, ticket(10, "cold task"));
|
|
assert!(inbox.busy_state(a).is_busy(), "le tour démarre (Busy)");
|
|
assert!(
|
|
bus.delegation_ready().is_empty(),
|
|
"cold-launch ⇒ pas de DelegationReady tant que rien ne libère"
|
|
);
|
|
|
|
// Connexion du pont MCP ⇒ libère le 1er tour différé.
|
|
inbox.release_cold_start(a);
|
|
let ready = bus.delegation_ready();
|
|
assert_eq!(
|
|
ready.len(),
|
|
1,
|
|
"release_cold_start ⇒ exactement une DelegationReady"
|
|
);
|
|
assert_eq!(ready[0].1, "cold task");
|
|
}
|
|
|
|
/// **Régression de la race cold-start (ordre INVERSE)** : `release_cold_start`
|
|
/// (signal de connexion du pont MCP) arrive AVANT que l'`enqueue` n'ait parqué son
|
|
/// tour différé. Avant le fix, le signal trouvait `deferred` vide (no-op), puis
|
|
/// l'`enqueue` parquait le tour dans `deferred` — où il restait à JAMAIS (aucun second
|
|
/// signal ne venait le drainer ⇒ `idea_ask_agent` bloquait jusqu'au timeout, le watcher
|
|
/// prompt-ready PTY ayant été supprimé). Après le fix : exactement UNE
|
|
/// `DelegationReady` est livrée (le latch « déjà libéré » garantit la livraison).
|
|
#[test]
|
|
fn release_cold_start_before_deferred_enqueue_still_delivers_once() {
|
|
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 = key(1);
|
|
|
|
// Démarrage à froid : gate armé AVANT l'enqueue (ordre de l'orchestrateur).
|
|
inbox.mark_starting(a);
|
|
// **La race** : la readiness MCP arrive AVANT l'enqueue qui parquera le tour.
|
|
inbox.release_cold_start(a);
|
|
assert!(
|
|
bus.delegation_ready().is_empty(),
|
|
"rien à livrer tant que l'enqueue n'a pas fourni le tour"
|
|
);
|
|
|
|
// Puis l'enqueue fournit le tour : grâce au latch « déjà libéré », il livre
|
|
// immédiatement au lieu de le perdre dans `deferred`.
|
|
inbox.enqueue(a, ticket(10, "cold task"));
|
|
let ready = bus.delegation_ready();
|
|
assert_eq!(
|
|
ready.len(),
|
|
1,
|
|
"release AVANT enqueue ⇒ exactement une DelegationReady (jamais zéro, jamais deux)"
|
|
);
|
|
assert_eq!(ready[0].1, "cold task");
|
|
assert!(
|
|
inbox.busy_state(a).is_busy(),
|
|
"le tour froid livré ⇒ l'agent reste Busy (idle viendra d'idea_reply)"
|
|
);
|
|
}
|
|
|
|
/// `release_cold_start` sans tour différé ⇒ no-op : aucune `DelegationReady` et,
|
|
/// surtout, PAS de transition Idle (signal de DÉMARRAGE, pas de fin de tour).
|
|
#[test]
|
|
fn release_cold_start_is_noop_without_deferred() {
|
|
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 = key(1);
|
|
|
|
// Démarre un tour normal (Busy), SANS gate cold-launch (pas de mark_starting).
|
|
inbox.enqueue(a, ticket(10, "task"));
|
|
let busy_before = bus.busy_events();
|
|
|
|
inbox.release_cold_start(a);
|
|
assert!(
|
|
bus.delegation_ready().len() == 1,
|
|
"pas de DelegationReady supplémentaire (la seule est celle de l'enqueue)"
|
|
);
|
|
assert_eq!(
|
|
bus.busy_events(),
|
|
busy_before,
|
|
"release_cold_start ne marque PAS Idle (aucun AgentBusyChanged{{busy:false}})"
|
|
);
|
|
}
|
|
|
|
/// `release_cold_start` est idempotent : un second appel ne re-draine rien ⇒ une seule
|
|
/// `DelegationReady` au total.
|
|
#[test]
|
|
fn release_cold_start_is_idempotent() {
|
|
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 = key(1);
|
|
|
|
inbox.mark_starting(a);
|
|
inbox.enqueue(a, ticket(10, "cold task"));
|
|
|
|
inbox.release_cold_start(a);
|
|
inbox.release_cold_start(a);
|
|
assert_eq!(
|
|
bus.delegation_ready().len(),
|
|
1,
|
|
"deux release_cold_start ⇒ une seule DelegationReady"
|
|
);
|
|
}
|
|
|
|
/// (b) Agent chaud (non `mark_starting`) : la `DelegationReady` est publiée
|
|
/// IMMÉDIATEMENT à l'enqueue — non-régression du chemin existant.
|
|
#[test]
|
|
fn warm_agent_delivers_first_turn_immediately() {
|
|
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 = key(1);
|
|
|
|
// Pas de mark_starting ⇒ agent chaud.
|
|
inbox.enqueue(a, ticket(10, "warm task"));
|
|
let ready = bus.delegation_ready();
|
|
assert_eq!(
|
|
ready.len(),
|
|
1,
|
|
"agent chaud ⇒ DelegationReady immédiate (zéro régression)"
|
|
);
|
|
assert_eq!(ready[0].1, "warm task");
|
|
}
|
|
|
|
/// (c) Cas limite : si on ne marque PAS `starting` (l'orchestrateur n'arme pas le
|
|
/// gate quand la cible n'a pas de pont MCP), l'enqueue livre la `DelegationReady`
|
|
/// immédiatement — donc PAS de blocage indéfini du premier tour.
|
|
#[test]
|
|
fn no_mcp_no_starting_gate_delivers_immediately() {
|
|
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 = key(1);
|
|
|
|
// Cold launch SANS pont MCP ⇒ l'orchestrateur N'APPELLE PAS mark_starting.
|
|
inbox.enqueue(a, ticket(10, "task"));
|
|
// Premier tour livré tout de suite : aucun signal de readiness ne viendrait le
|
|
// débloquer, donc surtout pas de gate.
|
|
assert_eq!(
|
|
bus.delegation_ready().len(),
|
|
1,
|
|
"sans gate ⇒ DelegationReady immédiate (pas de blocage indéfini)"
|
|
);
|
|
// Reste Busy (idle viendra d'idea_reply / turn_ended / timeout), tour bien livré.
|
|
assert!(inbox.busy_state(a).is_busy());
|
|
}
|
|
|
|
/// Après livraison du 1er tour froid (drainé par `release_cold_start`), un `mark_idle`
|
|
/// (idea_reply) puis un nouvel enqueue (agent désormais chaud) repart en livraison
|
|
/// immédiate.
|
|
#[test]
|
|
fn after_cold_first_turn_subsequent_enqueue_is_immediate() {
|
|
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 = key(1);
|
|
|
|
inbox.mark_starting(a);
|
|
inbox.enqueue(a, ticket(10, "first"));
|
|
// Readiness MCP ⇒ draine le 1er tour différé.
|
|
inbox.release_cold_start(a);
|
|
assert_eq!(bus.delegation_ready().len(), 1, "1er tour froid livré");
|
|
|
|
// Fin du 1er tour (idea_reply) puis nouvel enqueue ⇒ livraison immédiate.
|
|
inbox.mark_idle(a);
|
|
inbox.enqueue(a, ticket(11, "second"));
|
|
let ready = bus.delegation_ready();
|
|
assert_eq!(
|
|
ready.len(),
|
|
2,
|
|
"second tour livré immédiatement (plus de gate)"
|
|
);
|
|
assert_eq!(ready[1].1, "second");
|
|
}
|
|
|
|
// ====================================================================
|
|
// Lot 2 — détection de stagnation (last_seen / sweep_stalled / liveness)
|
|
// ====================================================================
|
|
|
|
use domain::input::AgentLiveness;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
|
|
/// Horloge millis **mutable** (atomique) pour piloter le temps dans les tests de
|
|
/// stagnation sans horloge réelle.
|
|
struct MutClock(AtomicU64);
|
|
impl MutClock {
|
|
fn new(start: u64) -> Arc<Self> {
|
|
Arc::new(Self(AtomicU64::new(start)))
|
|
}
|
|
fn set(&self, now: u64) {
|
|
self.0.store(now, Ordering::SeqCst);
|
|
}
|
|
}
|
|
impl MillisClock for MutClock {
|
|
fn now_ms(&self) -> u64 {
|
|
self.0.load(Ordering::SeqCst)
|
|
}
|
|
}
|
|
|
|
/// Construit un inbox sur l'horloge mutable + un bus enregistreur, renvoie les deux.
|
|
fn inbox_with_clock(clock: Arc<MutClock>) -> (MediatedInbox, Arc<RecordingBus>) {
|
|
let bus = Arc::new(RecordingBus::default());
|
|
let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), clock)
|
|
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
|
|
(inbox, bus)
|
|
}
|
|
|
|
impl RecordingBus {
|
|
/// Toutes les transitions de vivacité publiées, dans l'ordre.
|
|
fn liveness_events(&self) -> Vec<(AgentId, AgentLiveness)> {
|
|
self.0
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.iter()
|
|
.filter_map(|e| match e {
|
|
DomainEvent::AgentLivenessChanged { agent_id, liveness } => {
|
|
Some((*agent_id, *liveness))
|
|
}
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
// (a) absence de battement > stall_after_ms ⇒ Stalled
|
|
#[test]
|
|
fn no_heartbeat_past_threshold_marks_stalled() {
|
|
let clock = MutClock::new(1_000);
|
|
let (inbox, bus) = inbox_with_clock(Arc::clone(&clock));
|
|
let a = key(1);
|
|
|
|
// Profil : seuil de stagnation à 30_000 ms. Armé avant le tour.
|
|
inbox.set_stall_threshold(a, Some(30_000));
|
|
inbox.enqueue(a, ticket(10, "task")); // démarre le tour à t=1_000 ⇒ last_seen=1_000
|
|
|
|
// Dans la fenêtre : pas de transition.
|
|
clock.set(1_000 + 30_000);
|
|
inbox.sweep_stalled();
|
|
assert_eq!(
|
|
bus.liveness_events(),
|
|
vec![],
|
|
"à la limite exacte, pas encore stalled"
|
|
);
|
|
|
|
// Au-delà du seuil : exactement une transition Stalled.
|
|
clock.set(1_000 + 30_001);
|
|
inbox.sweep_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.agent_id, AgentLiveness::Stalled)],
|
|
"déjà stalled ⇒ pas de spam"
|
|
);
|
|
}
|
|
|
|
// (b) un battement dans la fenêtre réinitialise last_seen (pas de stall)
|
|
#[test]
|
|
fn heartbeat_within_window_resets_last_seen() {
|
|
let clock = MutClock::new(1_000);
|
|
let (inbox, bus) = inbox_with_clock(Arc::clone(&clock));
|
|
let a = key(1);
|
|
inbox.set_stall_threshold(a, Some(30_000));
|
|
inbox.enqueue(a, ticket(10, "task")); // last_seen=1_000
|
|
|
|
// Battement à t=20_000 ⇒ last_seen=20_000.
|
|
clock.set(20_000);
|
|
inbox.mark_alive(a);
|
|
|
|
// À t=45_000 : 45_000 - 20_000 = 25_000 < 30_000 ⇒ toujours Alive.
|
|
clock.set(45_000);
|
|
inbox.sweep_stalled();
|
|
assert_eq!(
|
|
bus.liveness_events(),
|
|
vec![],
|
|
"un battement a réinitialisé la fenêtre ⇒ pas de stall"
|
|
);
|
|
}
|
|
|
|
// (d) AgentLivenessChanged émis une seule fois par transition (Alive→Stalled→Alive)
|
|
#[test]
|
|
fn liveness_event_once_per_transition() {
|
|
let clock = MutClock::new(0);
|
|
let (inbox, bus) = inbox_with_clock(Arc::clone(&clock));
|
|
let a = key(1);
|
|
inbox.set_stall_threshold(a, Some(10_000));
|
|
inbox.enqueue(a, ticket(10, "t")); // last_seen=0
|
|
|
|
// Stall.
|
|
clock.set(10_001);
|
|
inbox.sweep_stalled();
|
|
// Sweeps répétés : pas de ré-émission.
|
|
inbox.sweep_stalled();
|
|
inbox.sweep_stalled();
|
|
assert_eq!(
|
|
bus.liveness_events(),
|
|
vec![(a.agent_id, AgentLiveness::Stalled)]
|
|
);
|
|
|
|
// Battement tardif ⇒ une seule reprise Alive.
|
|
clock.set(20_000);
|
|
inbox.mark_alive(a);
|
|
inbox.mark_alive(a); // second battement : déjà Alive ⇒ rien.
|
|
assert_eq!(
|
|
bus.liveness_events(),
|
|
vec![
|
|
(a.agent_id, AgentLiveness::Stalled),
|
|
(a.agent_id, AgentLiveness::Alive)
|
|
]
|
|
);
|
|
|
|
// Re-stall possible après reprise (nouvelle transition).
|
|
clock.set(20_000 + 10_001);
|
|
inbox.sweep_stalled();
|
|
assert_eq!(
|
|
bus.liveness_events(),
|
|
vec![
|
|
(a.agent_id, AgentLiveness::Stalled),
|
|
(a.agent_id, AgentLiveness::Alive),
|
|
(a.agent_id, AgentLiveness::Stalled),
|
|
]
|
|
);
|
|
}
|
|
|
|
// mark_idle (fin de tour) sur un agent Stalled émet la reprise et retire l'entrée.
|
|
#[test]
|
|
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 = 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.agent_id, AgentLiveness::Stalled)]
|
|
);
|
|
|
|
inbox.mark_idle(a); // fin de tour ⇒ reprise Alive + entrée retirée.
|
|
assert_eq!(
|
|
bus.liveness_events(),
|
|
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).
|
|
clock.set(1_000_000);
|
|
inbox.sweep_stalled();
|
|
assert_eq!(
|
|
bus.liveness_events(),
|
|
vec![
|
|
(a.agent_id, AgentLiveness::Stalled),
|
|
(a.agent_id, AgentLiveness::Alive)
|
|
]
|
|
);
|
|
}
|
|
|
|
// (e) agent sans LivenessStrategy (stall_after_ms = None) ⇒ comportement legacy :
|
|
// jamais Stalled, aucun AgentLivenessChanged.
|
|
#[test]
|
|
fn agent_without_threshold_is_never_stalled() {
|
|
let clock = MutClock::new(0);
|
|
let (inbox, bus) = inbox_with_clock(Arc::clone(&clock));
|
|
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.
|
|
inbox.sweep_stalled();
|
|
assert_eq!(
|
|
bus.liveness_events(),
|
|
vec![],
|
|
"sans seuil ⇒ jamais stalled (zéro régression)"
|
|
);
|
|
// Et un mark_alive sur un agent jamais stalled n'émet rien non plus.
|
|
inbox.mark_alive(a);
|
|
assert_eq!(bus.liveness_events(), vec![]);
|
|
}
|
|
|
|
// Un battement n'a aucun effet sur un agent dont aucune entrée n'est armée.
|
|
#[test]
|
|
fn mark_alive_on_unarmed_agent_is_noop() {
|
|
let clock = MutClock::new(0);
|
|
let (inbox, bus) = inbox_with_clock(Arc::clone(&clock));
|
|
let a = key(1);
|
|
inbox.mark_alive(a); // jamais de tour démarré.
|
|
inbox.sweep_stalled();
|
|
assert_eq!(bus.liveness_events(), vec![]);
|
|
}
|
|
}
|