feat(agents): pont Codex inter-agents + readiness/heartbeat lot 1

Deux chantiers livrés au vert (workspace entier : domain+application+
infrastructure 42 + app-tauri --lib 128, 0 échec).

## Codex inter-agents
- domaine: McpConfigStrategy::TomlConfigHome { target, home_env } +
  toml_config_home(...); AgentProfile::materializes_idea_bridge()
  (whitelist Claude/ConfigFile + Codex/TomlConfigHome); McpServerWiring
  + encodeur TOML.
- application: lifecycle apply_mcp_config bras TomlConfigHome (écrit
  {runDir}/<target>, pousse (home_env, parent) dans spec.env);
  guard_mcp_bridge_supported ré-exprimée via materializes_idea_bridge();
  catalogue Codex porte toml_config_home(".codex/config.toml","CODEX_HOME").
- app-tauri: is_codex_mcp_profile, migrate_codex_run_dir,
  mcp_server_entry_toml.
- tests: matrice domaine TomlConfigHome + round-trip dual Claude/Codex
  sur loopback réel (fakes, zéro token).

## Readiness/heartbeat lot 1
- domaine: readiness.rs — ReadinessPolicy::classify (Final => TurnEnded),
  variantes ReplyEvent::Heartbeat / ToolActivity.
- application: drain_with_readiness consulte la policy et appelle
  mark_idle sur le signal déterministe; branché dans ask_agent.
  Corrige la cause racine: une cible qui ne renvoie qu'un Final (sans
  idea_reply) débloque désormais sa file Busy.
- infrastructure: adapters de session émettent Heartbeat/ToolActivity.
- tests: drain_with_readiness_lot1 (points QA 5 & 6) verts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 09:28:44 +02:00
parent fdcf16c387
commit 0f8ba38d51
24 changed files with 2745 additions and 156 deletions

View File

@ -25,7 +25,7 @@ use std::sync::{Arc, Mutex};
use domain::events::DomainEvent;
use domain::ids::AgentId;
use domain::input::{AgentBusyState, InputMediator, SubmitConfig};
use domain::input::{AgentBusyState, AgentLiveness, InputMediator, SubmitConfig};
use domain::mailbox::{AgentMailbox, PendingReply, Ticket, TicketId};
use domain::ports::{EventBus, PtyHandle, PtyPort};
@ -40,13 +40,34 @@ use crate::mailbox::InMemoryMailbox;
/// every path (explicit `mark_idle`, prompt-ready match) stays consistent.
struct BusyTracker {
busy: Mutex<HashMap<AgentId, AgentBusyState>>,
/// Per-agent **liveness** bookkeeping (lot 2) : dernier battement observé, seuil de
/// stagnation issu du profil, et état de vivacité courant pour n'émettre
/// `AgentLivenessChanged` qu'**une fois par transition** (pas de spam).
liveness: Mutex<HashMap<AgentId, LivenessState>>,
events: Option<Arc<dyn EventBus>>,
}
/// É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()),
events,
}
}
@ -57,6 +78,100 @@ impl BusyTracker {
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn lock_liveness(&self) -> std::sync::MutexGuard<'_, HashMap<AgentId, LivenessState>> {
self.liveness
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// Publie un `AgentLivenessChanged` (si un bus est câblé).
fn publish_liveness(&self, agent: AgentId, liveness: AgentLiveness) {
if let Some(events) = &self.events {
events.publish(DomainEvent::AgentLivenessChanged {
agent_id: agent,
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: AgentId, 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: AgentId, 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<AgentId> = {
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 {
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: AgentId) {
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: AgentId) -> AgentBusyState {
self.lock()
.get(&agent)
@ -94,6 +209,9 @@ impl BusyTracker {
});
}
}
// 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);
}
}
@ -143,6 +261,10 @@ pub struct MediatedInbox {
/// stashed at bind time (§20.3) and echoed on the `DelegationReady` event when a
/// turn starts. Absent ⇒ both `None` (the front applies its defaults).
submit: Mutex<HashMap<AgentId, SubmitConfig>>,
/// Per-agent stall threshold (`LivenessStrategy::stall_after_ms`, lot 2), stashed by
/// `set_stall_threshold` and consumed to arm a fresh liveness window on the enqueue
/// that starts a turn. Absent ⇒ `None` (no stall detection — legacy behaviour).
stall: Mutex<HashMap<AgentId, Option<u32>>>,
/// Agents whose prompt-ready watcher thread is already armed, so re-binding the same
/// handle does not spawn a duplicate watcher (lot C5). Shared (`Arc`) because each
/// watcher thread un-arms its own entry on exit.
@ -161,6 +283,7 @@ impl MediatedInbox {
pty: None,
handles: Mutex::new(HashMap::new()),
submit: Mutex::new(HashMap::new()),
stall: Mutex::new(HashMap::new()),
watched: Arc::new(Mutex::new(HashSet::new())),
}
}
@ -190,6 +313,7 @@ impl MediatedInbox {
pty: Some(pty),
handles: Mutex::new(HashMap::new()),
submit: Mutex::new(HashMap::new()),
stall: Mutex::new(HashMap::new()),
watched: Arc::new(Mutex::new(HashSet::new())),
}
}
@ -215,6 +339,22 @@ impl MediatedInbox {
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn stall(&self) -> std::sync::MutexGuard<'_, HashMap<AgentId, Option<u32>>> {
self.stall
.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> {
@ -305,13 +445,22 @@ impl InputMediator for MediatedInbox {
// 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: self.clock.now_ms(),
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
@ -391,10 +540,23 @@ impl InputMediator for MediatedInbox {
fn mark_idle(&self, agent: AgentId) {
// Single authority (also used by the prompt-ready watcher): real Busy→Idle only,
// publishing AgentBusyChanged{busy:false} once.
// 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 mark_alive(&self, agent: AgentId) {
// Un battement (delta / activité / heartbeat) rafraîchit `last_seen` et ramène
// l'agent à `Alive` s'il était `Stalled` (lot 2).
self.tracker.touch(agent, self.clock.now_ms());
}
fn set_stall_threshold(&self, agent: AgentId, stall_after_ms: Option<u32>) {
// Stashé par l'orchestrateur depuis le profil de la cible AVANT l'enqueue qui
// démarre le tour ; consommé par `arm_liveness` au start_turn (lot 2).
self.stall().insert(agent, stall_after_ms);
}
fn busy_state(&self, agent: AgentId) -> AgentBusyState {
self.tracker.busy_state(agent)
}
@ -985,4 +1147,205 @@ mod tests {
std::thread::sleep(std::time::Duration::from_millis(60));
assert!(inbox.busy_state(a).is_busy(), "no match ⇒ stays Busy");
}
// ====================================================================
// Lot 2 — détection de stagnation (last_seen / sweep_stalled / liveness)
// ====================================================================
use std::sync::atomic::{AtomicU64, Ordering};
use domain::input::AgentLiveness;
/// 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 = agent(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, AgentLiveness::Stalled)]);
// Idempotent : un second sweep ne ré-émet pas.
clock.set(1_000 + 60_000);
inbox.sweep_stalled();
assert_eq!(
bus.liveness_events(),
vec![(a, AgentLiveness::Stalled)],
"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 = agent(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 = agent(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, 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, AgentLiveness::Stalled), (a, 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, AgentLiveness::Stalled),
(a, AgentLiveness::Alive),
(a, 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 = agent(1);
inbox.set_stall_threshold(a, Some(5_000));
inbox.enqueue(a, ticket(10, "t"));
clock.set(5_001);
inbox.sweep_stalled();
assert_eq!(bus.liveness_events(), vec![(a, AgentLiveness::Stalled)]);
inbox.mark_idle(a); // fin de tour ⇒ reprise Alive + entrée retirée.
assert_eq!(
bus.liveness_events(),
vec![(a, AgentLiveness::Stalled), (a, AgentLiveness::Alive)]
);
// 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, AgentLiveness::Stalled), (a, 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 = agent(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 = agent(1);
inbox.mark_alive(a); // jamais de tour démarré.
inbox.sweep_stalled();
assert_eq!(bus.liveness_events(), vec![]);
}
}