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:
@ -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![]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -47,10 +47,10 @@ pub struct ParsedLine {
|
||||
/// Le flux est du **JSONL** (un objet JSON par ligne). Types réels :
|
||||
///
|
||||
/// - `{"type":"system","subtype":"init","session_id":"<uuid>","cwd":…,"tools":…,…}`
|
||||
/// ⇒ capture le `session_id` (= id de conversation pour la reprise), **aucun**
|
||||
/// événement émis.
|
||||
/// ⇒ capture le `session_id` (= id de conversation pour la reprise) **et** émet un
|
||||
/// [`ReplyEvent::Heartbeat`] (preuve de vivacité non terminale : la CLI a démarré).
|
||||
/// - `{"type":"rate_limit_event","rate_limit_info":{…},"session_id":"…"}`
|
||||
/// ⇒ **ignoré** (comme tout `type` inconnu).
|
||||
/// ⇒ [`ReplyEvent::Heartbeat`] (pas de contenu, mais le moteur est vivant).
|
||||
/// - `{"type":"assistant","message":{"role":"assistant","content":[
|
||||
/// {"type":"text","text":"…"} | {"type":"tool_use","name":"…", …}
|
||||
/// ], …},"session_id":"…","parent_tool_use_id":null}`
|
||||
@ -81,7 +81,12 @@ pub fn parse_event(line: &str) -> Result<ParsedLine, AgentSessionError> {
|
||||
.map(str::to_owned);
|
||||
|
||||
let events = match value.get("type").and_then(Value::as_str) {
|
||||
Some("system") => Vec::new(), // init/handshake : on ne capte que le session_id.
|
||||
// init/handshake : on capte le session_id ET on émet un battement de cœur
|
||||
// (preuve de vivacité non terminale : la CLI a démarré et répond).
|
||||
Some("system") => vec![ReplyEvent::Heartbeat],
|
||||
// Fenêtre de limite de débit : pas de contenu, mais le moteur est vivant ⇒
|
||||
// battement de cœur (readiness/heartbeat lot 1), plus ignoré.
|
||||
Some("rate_limit_event") => vec![ReplyEvent::Heartbeat],
|
||||
Some("assistant") => assistant_events(&value),
|
||||
Some("result") => value
|
||||
.get("result")
|
||||
@ -92,7 +97,7 @@ pub fn parse_event(line: &str) -> Result<ParsedLine, AgentSessionError> {
|
||||
}]
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
_ => Vec::new(), // type inconnu / non pertinent (rate_limit_event, …) : ignoré.
|
||||
_ => Vec::new(), // type inconnu / non pertinent : ignoré (robustesse).
|
||||
};
|
||||
|
||||
Ok(ParsedLine { events, session_id })
|
||||
@ -216,13 +221,22 @@ impl AgentSession for ClaudeSdkSession {
|
||||
|
||||
let mut events = Vec::new();
|
||||
let mut captured_id = None;
|
||||
for line in &raw_lines {
|
||||
'lines: for line in &raw_lines {
|
||||
let parsed = parse_event(line)?;
|
||||
if let Some(id) = parsed.session_id {
|
||||
captured_id = Some(id);
|
||||
}
|
||||
// Aplatit : une ligne `assistant` multi-blocs rend plusieurs événements.
|
||||
events.extend(parsed.events);
|
||||
// Le `Final` est **terminal** (contrat de port) : on arrête d'émettre dès
|
||||
// qu'on l'a vu, pour qu'aucun heartbeat de fin (`rate_limit_event` tardif…)
|
||||
// ne le suive dans le flux.
|
||||
for event in parsed.events {
|
||||
let is_final = matches!(event, ReplyEvent::Final { .. });
|
||||
events.push(event);
|
||||
if is_final {
|
||||
break 'lines;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Persiste le session_id capté (pivot de reprise) avant de rendre le flux.
|
||||
if let Some(id) = captured_id {
|
||||
|
||||
@ -43,13 +43,13 @@ pub struct ParsedLine {
|
||||
///
|
||||
/// - `{"type":"thread.started","thread_id":"<id>"}` ⇒ capte le `thread_id`
|
||||
/// (= id de conversation pour la reprise), **aucun** événement émis.
|
||||
/// - `{"type":"turn.started"}` ⇒ ignoré.
|
||||
/// - `{"type":"turn.started"}` ⇒ [`ReplyEvent::Heartbeat`] (vivacité non terminale).
|
||||
/// - `{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"…"}}`
|
||||
/// ⇒ si `item.type=="agent_message"` ⇒ [`ReplyEvent::Final`] (`content` = `item.text`,
|
||||
/// c'est la réponse) ; sinon (`reasoning`/`command`/autre) ⇒
|
||||
/// [`ReplyEvent::ToolActivity`] (`label` = `item.type`).
|
||||
/// - `{"type":"turn.completed","usage":{…}}` ⇒ ignoré (le `Final` vient de
|
||||
/// l'`agent_message`).
|
||||
/// - `{"type":"turn.completed","usage":{…}}` ⇒ [`ReplyEvent::Heartbeat`] (le `Final`
|
||||
/// vient de l'`agent_message`, pas de `turn.completed`).
|
||||
///
|
||||
/// Ligne vide ⇒ ignorée ; type inconnu ⇒ ignoré sans erreur ; JSON illisible ⇒
|
||||
/// [`AgentSessionError::Decode`] (jamais de JSON brut propagé).
|
||||
@ -75,6 +75,10 @@ pub fn parse_event(line: &str) -> Result<ParsedLine, AgentSessionError> {
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned);
|
||||
}
|
||||
// Début/fin de tour côté moteur : pas de contenu, mais preuve de vivacité ⇒
|
||||
// battement de cœur non terminal (readiness/heartbeat lot 1). Le `Final` vient
|
||||
// toujours de l'`agent_message`, jamais de `turn.completed`.
|
||||
Some("turn.started") | Some("turn.completed") => events.push(ReplyEvent::Heartbeat),
|
||||
Some("item.completed") => {
|
||||
if let Some(item) = value.get("item") {
|
||||
match item.get("type").and_then(Value::as_str) {
|
||||
@ -94,7 +98,7 @@ pub fn parse_event(line: &str) -> Result<ParsedLine, AgentSessionError> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// turn.started / turn.completed / type inconnu : ignoré (robustesse).
|
||||
// type inconnu : ignoré (robustesse).
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@ -192,12 +196,21 @@ impl AgentSession for CodexExecSession {
|
||||
|
||||
let mut events = Vec::new();
|
||||
let mut captured_id = None;
|
||||
for line in &raw_lines {
|
||||
'lines: for line in &raw_lines {
|
||||
let parsed = parse_event(line)?;
|
||||
if let Some(id) = parsed.conversation_id {
|
||||
captured_id = Some(id);
|
||||
}
|
||||
events.extend(parsed.events);
|
||||
// Le `Final` est **terminal** (contrat de port) : on arrête d'émettre dès
|
||||
// qu'on l'a vu, pour qu'aucun heartbeat de fin (`turn.completed` postérieur)
|
||||
// ne le suive dans le flux.
|
||||
for event in parsed.events {
|
||||
let is_final = matches!(event, ReplyEvent::Final { .. });
|
||||
events.push(event);
|
||||
if is_final {
|
||||
break 'lines;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(id) = captured_id {
|
||||
*self.conversation_id.lock().expect("mutex sain") = Some(id);
|
||||
|
||||
@ -208,14 +208,17 @@ pub(crate) mod harness {
|
||||
}
|
||||
other => panic!("le dernier événement doit être Final, vu: {other:?}"),
|
||||
}
|
||||
// Les événements avant le Final ne sont que des deltas / activités.
|
||||
// Les événements avant le Final ne sont que des deltas / activités / heartbeats
|
||||
// (tous **non terminaux** ; le heartbeat est une preuve de vivacité, lot 1).
|
||||
for e in &events[..events.len() - 1] {
|
||||
assert!(
|
||||
matches!(
|
||||
e,
|
||||
ReplyEvent::TextDelta { .. } | ReplyEvent::ToolActivity { .. }
|
||||
ReplyEvent::TextDelta { .. }
|
||||
| ReplyEvent::ToolActivity { .. }
|
||||
| ReplyEvent::Heartbeat
|
||||
),
|
||||
"avant le Final, seuls deltas/activités sont permis, vu: {e:?}"
|
||||
"avant le Final, seuls deltas/activités/heartbeats sont permis, vu: {e:?}"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -106,23 +106,24 @@ mod tests {
|
||||
// -- parse_event Claude (format RÉEL vérifié 2026-06-09) --------------
|
||||
|
||||
#[test]
|
||||
fn claude_parse_init_captures_session_id_without_event() {
|
||||
fn claude_parse_init_captures_session_id_and_heartbeats() {
|
||||
let parsed = claude::parse_event(
|
||||
r#"{"type":"system","subtype":"init","session_id":"conv-123","cwd":"/tmp","tools":[],"model":"claude-opus-4-8"}"#,
|
||||
)
|
||||
.expect("parse ok");
|
||||
assert_eq!(parsed.session_id.as_deref(), Some("conv-123"));
|
||||
assert!(parsed.events.is_empty());
|
||||
// L'init capte le session_id ET émet un heartbeat (vivacité non terminale, lot 1).
|
||||
assert_eq!(parsed.events, vec![ReplyEvent::Heartbeat]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_parse_rate_limit_event_is_ignored() {
|
||||
fn claude_parse_rate_limit_event_is_heartbeat() {
|
||||
let parsed = claude::parse_event(
|
||||
r#"{"type":"rate_limit_event","rate_limit_info":{"x":1},"session_id":"conv-123"}"#,
|
||||
)
|
||||
.expect("parse ok");
|
||||
// Type inconnu/non pertinent : ignoré (mais session_id tout de même capté).
|
||||
assert!(parsed.events.is_empty());
|
||||
// Plus ignoré : preuve de vivacité ⇒ heartbeat (mais session_id tout de même capté).
|
||||
assert_eq!(parsed.events, vec![ReplyEvent::Heartbeat]);
|
||||
assert_eq!(parsed.session_id.as_deref(), Some("conv-123"));
|
||||
}
|
||||
|
||||
@ -221,10 +222,15 @@ mod tests {
|
||||
let sess =
|
||||
codex::parse_event(r#"{"type":"thread.started","thread_id":"cx-9"}"#).expect("ok");
|
||||
assert_eq!(sess.conversation_id.as_deref(), Some("cx-9"));
|
||||
// Le handshake ne capte que le thread_id, sans événement (pas un heartbeat).
|
||||
assert!(sess.events.is_empty());
|
||||
|
||||
// turn.started / turn.completed ⇒ heartbeat (vivacité non terminale, lot 1).
|
||||
let started = codex::parse_event(r#"{"type":"turn.started"}"#).expect("ok");
|
||||
assert!(started.events.is_empty());
|
||||
assert_eq!(started.events, vec![ReplyEvent::Heartbeat]);
|
||||
let completed =
|
||||
codex::parse_event(r#"{"type":"turn.completed","usage":{}}"#).expect("ok");
|
||||
assert_eq!(completed.events, vec![ReplyEvent::Heartbeat]);
|
||||
|
||||
let msg = codex::parse_event(
|
||||
r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"fini"}}"#,
|
||||
@ -236,11 +242,6 @@ mod tests {
|
||||
content: "fini".to_owned()
|
||||
}]
|
||||
);
|
||||
|
||||
let completed =
|
||||
codex::parse_event(r#"{"type":"turn.completed","usage":{"input_tokens":10}}"#)
|
||||
.expect("ok");
|
||||
assert!(completed.events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -728,30 +729,26 @@ mod tests {
|
||||
assert!(p.events.is_empty());
|
||||
}
|
||||
|
||||
/// Ligne vide / type inconnu / turn.started / turn.completed ⇒ ignorés sans erreur.
|
||||
/// Ligne vide / type inconnu ⇒ ignorés sans erreur. (turn.started/completed sont
|
||||
/// désormais des heartbeats : couverts par `codex_parse_thread_started_message_and_final`.)
|
||||
#[test]
|
||||
fn codex_empty_and_unknown_ignored() {
|
||||
assert_eq!(codex::parse_event("").unwrap(), Default::default());
|
||||
assert!(codex::parse_event(r#"{"type":"heartbeat"}"#)
|
||||
// Un `type` inconnu reste ignoré (robustesse), pas un heartbeat.
|
||||
assert!(codex::parse_event(r#"{"type":"telemetry"}"#)
|
||||
.unwrap()
|
||||
.events
|
||||
.is_empty());
|
||||
assert!(codex::parse_event(r#"{"type":"turn.started"}"#)
|
||||
.unwrap()
|
||||
.events
|
||||
.is_empty());
|
||||
assert!(
|
||||
codex::parse_event(r#"{"type":"turn.completed","usage":{}}"#)
|
||||
.unwrap()
|
||||
.events
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Machinerie process via FakeCli ---------------------------------
|
||||
|
||||
/// Deltas PUIS Final : le flux ne contient rien après le `Final` (déjà couvert
|
||||
/// pour Claude ; ici on le prouve aussi pour Codex, substituabilité Liskov).
|
||||
/// Deltas PUIS Final : **exactement un** `Final`, et aucun autre `Final` après lui
|
||||
/// (substituabilité Liskov). Note (lot 1) : un `turn.completed` postérieur émet un
|
||||
/// `Heartbeat` non terminal — légitimement après le `Final` —, donc on ne teste plus
|
||||
/// « rien après le Final » mais « pas de second Final, et seul un heartbeat peut
|
||||
/// suivre ». Le rendez-vous synchrone (`drain_to_final`) s'arrête de toute façon au
|
||||
/// premier `Final`.
|
||||
#[tokio::test]
|
||||
async fn codex_stream_closed_after_final() {
|
||||
let fake = FakeCli::printing(&[
|
||||
@ -762,12 +759,19 @@ mod tests {
|
||||
]);
|
||||
let s = CodexExecSession::new(SessionId::new_random(), fake.command(), "/", None);
|
||||
let events: Vec<_> = s.send("x").await.expect("send").collect();
|
||||
let after = events
|
||||
let finals = events
|
||||
.iter()
|
||||
.filter(|e| matches!(e, ReplyEvent::Final { .. }))
|
||||
.count();
|
||||
assert_eq!(finals, 1, "exactement un Final");
|
||||
// Après le Final, seuls des événements non terminaux (heartbeat) peuvent suivre.
|
||||
let after_final_terminals = events
|
||||
.iter()
|
||||
.skip_while(|e| !matches!(e, ReplyEvent::Final { .. }))
|
||||
.skip(1)
|
||||
.filter(|e| matches!(e, ReplyEvent::Final { .. }))
|
||||
.count();
|
||||
assert_eq!(after, 0);
|
||||
assert_eq!(after_final_terminals, 0, "aucun second Final après le premier");
|
||||
}
|
||||
|
||||
/// LIMITE/ÉCART (à arbitrer) : un flux SANS `Final` ne provoque PAS d'erreur au
|
||||
@ -1097,6 +1101,8 @@ mod tests {
|
||||
assert_eq!(
|
||||
events,
|
||||
vec![
|
||||
// L'init `system` émet un heartbeat (vivacité non terminale, lot 1).
|
||||
ReplyEvent::Heartbeat,
|
||||
ReplyEvent::TextDelta { text: "a".into() },
|
||||
ReplyEvent::ToolActivity { label: "T".into() },
|
||||
ReplyEvent::TextDelta { text: "b".into() },
|
||||
@ -1104,7 +1110,7 @@ mod tests {
|
||||
content: "final-ok".into()
|
||||
},
|
||||
],
|
||||
"le flot complet doit aplatir les 3 blocs PUIS un seul Final"
|
||||
"heartbeat d'init, puis les 3 blocs aplatis, PUIS un seul Final"
|
||||
);
|
||||
// Un seul Final, en dernière position (redondant mais explicite).
|
||||
assert_eq!(
|
||||
|
||||
Reference in New Issue
Block a user