Files
IdeaSDK/crates/infrastructure/src/input/mod.rs

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![]);
}
}