feat(orchestrator): backstop no-reply du rendez-vous inter-agents

Remédiation du wedge persistant après échec live du fix Finding A (77e62e5).
Détecte la fin de tour d'un agent sollicité qui n'a pas appelé idea_reply et
débloque l'agent demandeur au lieu de le laisser en attente indéfinie.

Ajoute le suivi de tour côté inspector Claude (claude_turn_watcher) et la
résolution des chemins de session (claude_paths), câblés dans le rendez-vous
idea_ask_agent ⇄ idea_reply.

Build workspace vert, suite complète verte, zéro warning.
Backstop NON prouvé levé en live : merge develop interdit tant que la levée
du wedge n'est pas validée en conditions réelles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 15:51:59 +02:00
parent 2ef5628c72
commit 744de20f4b
20 changed files with 867 additions and 1093 deletions

View File

@ -173,32 +173,22 @@ pub trait InputMediator: Send + Sync {
/// own the delivery write).
fn bind_handle(&self, _agent: AgentId, _handle: PtyHandle) {}
/// Like [`InputMediator::bind_handle`], but also arms **prompt-ready detection**
/// (cadrage §6, lot C5): `prompt_ready_pattern` is the agent profile's optional
/// literal marker (`AgentProfile::prompt_ready_pattern`). When `Some`, the mediator
/// watches the bound handle's output stream and calls [`InputMediator::mark_idle`]
/// the first time the marker appears (one of the two OR signals; the other is an
/// explicit `idea_reply`). When `None`, no pattern detection is armed — the agent
/// only returns `Idle` on the explicit signal or the per-turn timeout (fallback
/// «en cas de doute → reste Busy mais la file accepte»; never a false `Idle`).
///
/// It also records the target's [`SubmitConfig`] (ARCHITECTURE §20.3) so the
/// adapter can echo `submit_sequence`/`submit_delay_ms` on the
/// Like [`InputMediator::bind_handle`], but also records the target's
/// [`SubmitConfig`] (ARCHITECTURE §20.3) so the adapter can echo
/// `submit_sequence`/`submit_delay_ms` on the
/// [`crate::events::DomainEvent::DelegationReady`] published when a turn starts.
/// The bind is the natural carrier: both the prompt pattern and the submit config
/// are per-agent profile data the orchestrator resolves at the same time, so they
/// travel together (no extra ticket field, no second resolve).
/// The bind is the natural carrier: the submit config is per-agent profile data the
/// orchestrator resolves at bind time, so it travels with the handle (no extra
/// ticket field, no second resolve).
///
/// Default: delegates to [`InputMediator::bind_handle`], ignoring the pattern and
/// submit config (a mediator that does not observe the output stream). The infra
/// adapter overrides it to arm the watcher and stash the submit config.
fn bind_handle_with_prompt(
&self,
agent: AgentId,
handle: PtyHandle,
_prompt_ready_pattern: Option<String>,
_submit: SubmitConfig,
) {
/// Turn-end detection is **no longer** armed here: the dead PTY prompt-ready sniff
/// was replaced by the transcript [`crate::ports::TurnWatcher`] (armed at the
/// composition root, once per live session) which calls
/// [`InputMediator::turn_ended`].
///
/// Default: delegates to [`InputMediator::bind_handle`], ignoring the submit config.
/// The infra adapter overrides it to stash the submit config.
fn bind_handle_with_submit(&self, agent: AgentId, handle: PtyHandle, _submit: SubmitConfig) {
self.bind_handle(agent, handle);
}
@ -212,12 +202,12 @@ pub trait InputMediator: Send + Sync {
}
/// Déclare qu'`agent` vient d'être **lancé à froid** : la livraison de son tout
/// premier tour doit être *gatée* sur le prompt-ready (la `DelegationReady` est
/// différée jusqu'à l'apparition du prompt du CLI), pour éviter d'écrire la tâche
/// premier tour doit être *gatée* sur la readiness MCP (la `DelegationReady` est
/// différée jusqu'à la connexion du pont MCP du CLI), pour éviter d'écrire la tâche
/// avant que le CLI ait fini de booter (premier tour perdu sinon).
///
/// À n'appeler **que** lorsqu'un prompt-ready watcher sera effectivement armé (le
/// profil porte un `prompt_ready_pattern` non vide). Sans watcher pour le libérer,
/// À n'appeler **que** lorsqu'un signal de readiness le libérera (le profil porte un
/// pont MCP ⇒ [`InputMediator::release_cold_start`]). Sans signal pour le libérer,
/// gater le premier tour le bloquerait indéfiniment ; dans ce cas l'orchestrateur ne
/// doit **pas** appeler `mark_starting` et l'`enqueue` livre la tâche immédiatement
/// (chemin chaud, fallback sûr, zéro régression).
@ -229,10 +219,10 @@ pub trait InputMediator: Send + Sync {
/// connecter (son CLI est up et a chargé les outils `idea_*`). Si un premier tour
/// a été différé par [`InputMediator::mark_starting`] (démarrage à froid), c'est le
/// moment de le livrer ⇒ draine le `DelegationReady` retenu. **Contrairement à
/// `prompt_ready`, aucun repli `mark_idle`** : c'est un signal de DÉMARRAGE, pas de
/// fin de tour. Idempotent : un second appel (ou après que `prompt_ready` ait déjà
/// drainé) ne trouve rien et est un no-op. En OR avec le prompt-ready : le premier
/// arrivé draine, l'autre est no-op.
/// `turn_ended`, aucun repli `mark_idle`** : c'est un signal de DÉMARRAGE, pas de
/// fin de tour. Idempotent : un second appel ne trouve rien et est un no-op. C'est
/// désormais le **seul** drain du tour différé (le signal MCP-initialize), le
/// watcher prompt-ready PTY ayant été supprimé.
///
/// Default: no-op (médiateur qui ne gate pas les démarrages à froid).
fn release_cold_start(&self, _agent: AgentId) {}
@ -254,9 +244,24 @@ pub trait InputMediator: Send + Sync {
/// is **not** an enqueue and correlates **no** ticket.
fn preempt(&self, agent: AgentId);
/// Marks `agent` free (prompt-ready or explicit signal) so its FIFO advances.
/// Marks `agent` free (explicit signal) so its FIFO advances.
fn mark_idle(&self, agent: AgentId);
/// **End-of-turn** signal: the agent's transcript just recorded a completed turn
/// (the [`crate::ports::TurnWatcher`] fired), and **no** `idea_reply` carried a
/// result. This is the no-reply backstop trigger: the adapter captures the active
/// ticket, marks the agent `Idle` (advancing the FIFO), then arms a short **grace**
/// window — if no `idea_reply` correlates the ticket by then, it completes the turn
/// "without reply" (waking a parked caller with a typed error instead of leaving it
/// blocked until the long timeout). A late `idea_reply` within the grace wins.
///
/// Replaces the former prompt-ready watcher branch verbatim; only the **trigger**
/// changed (transcript `turn_duration` instead of a PTY prompt sigil). Default:
/// [`InputMediator::mark_idle`] (a mediator with no mailbox/grace just advances).
fn turn_ended(&self, agent: AgentId) {
self.mark_idle(agent);
}
/// Records a **proof of liveness** (« battement ») for `agent` — called on every
/// non-terminal turn event (text delta, tool activity, [`crate::ports::ReplyEvent::Heartbeat`])
/// by the drain loop. Refreshes the per-agent `last_seen` timestamp so the stall

View File

@ -1305,6 +1305,46 @@ pub trait SessionInspector: Send + Sync {
) -> Result<ConversationDetails, InspectError>;
}
/// Callback fired **once per detected turn end** by a [`TurnWatcher`].
///
/// Invoked from the watcher's own polling task (never while holding a lock), so the
/// composition root can safely route it to
/// [`crate::input::InputMediator::turn_ended`] for the given agent.
pub type OnTurnEnd = Arc<dyn Fn(AgentId) + Send + Sync>;
/// Opaque handle to a live [`TurnWatcher::watch`]. **Dropping it stops the watch**
/// (the adapter's polling task must observe the drop and cease firing, idempotently).
pub trait TurnWatchHandle: Send + Sync {}
/// Observes an agent's **end-of-turn** signal from its on-disk transcript, replacing
/// the dead PTY prompt-ready sniff as the turn-end authority (rendez-vous no-reply
/// backstop). Model-specific: an adapter watches the CLI's transcript shape (e.g. the
/// Claude `turn_duration` record) and fires [`OnTurnEnd`] each time a turn completes.
///
/// Pure port (no async, no I/O in the trait): the adapter owns its polling task. The
/// composition root arms one watch per **live agent session** (keyed on `cwd`, the
/// agent's isolated run dir) and drops the handle on close/exit.
pub trait TurnWatcher: Send + Sync {
/// Whether this watcher knows how to read turn ends for the given profile
/// (mirrors [`SessionInspector::supports`] — e.g. recognising `CLAUDE.md`).
fn supports(&self, profile: &AgentProfile) -> bool;
/// Arms a watch over `agent`'s transcript under `cwd` (its run dir). The adapter
/// captures a **baseline** of turn ends already present at arm time and fires
/// `on_turn_end(agent)` only on increments **above** that baseline (so a
/// pre-existing transcript — relaunch / background wake — never triggers a phantom
/// turn end). `conversation_id` is an optional diagnostic label only — it is **not**
/// the file key (the transcript stem is the engine session id, unknown at cold
/// start). Returns a [`TurnWatchHandle`]; dropping it stops the watch.
fn watch(
&self,
agent: AgentId,
conversation_id: Option<String>,
cwd: ProjectPath,
on_turn_end: OnTurnEnd,
) -> Box<dyn TurnWatchHandle>;
}
/// Publish/subscribe domain events. Synchronous, in-process.
pub trait EventBus: Send + Sync {
/// Publishes an event.

View File

@ -177,9 +177,9 @@ impl LivenessStrategy {
/// Motif déclaratif de détection d'une **limite de session/débit** pour un agent
/// **PTY/TUI sans adapter structuré** (ARCHITECTURE §21, niveau 2 de détection).
///
/// Donnée **pure** (pas de code par CLI — Open/Closed, §9), calquée sur la
/// philosophie de [`AgentProfile::prompt_ready_pattern`] mais **plus riche** : là où
/// le retour-de-prompt est une simple sous-chaîne littérale, la limite de session a
/// Donnée **pure** (pas de code par CLI — Open/Closed, §9), un motif déclaratif
/// littéral **plus riche** qu'une simple sous-chaîne : là où un sigil de prompt
/// serait une simple sous-chaîne littérale, la limite de session a
/// besoin d'**extraire une heure de reset** dans la sortie. Le domaine **ne stocke
/// que la donnée** (chaînes) ; le **moteur regex et le parsing d'heure vivent en
/// infrastructure** (composant `RateLimitParser`, dépendance `regex` ajoutée au seul
@ -578,37 +578,6 @@ pub struct AgentProfile {
/// sérialisation : un profil sans MCP sérialise exactement comme avant.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mcp: Option<McpCapability>,
/// Motif de **retour-de-prompt** (cadrage « conversation par paire » §6, lot C5).
///
/// Donnée **déclarative** (pas de code par CLI — Open/Closed) : un motif **littéral**
/// recherché par sous-chaîne dans le flux de sortie PTY du tour courant. Quand il
/// apparaît, IdeA considère l'agent **revenu au prompt** et le marque `Idle`
/// (`InputMediator::mark_idle`), ce qui fait avancer sa file. C'est l'un des deux
/// signaux du « double signal OR » (l'autre étant un `idea_reply` explicite) ; le
/// **premier** des deux qui arrive libère le tour.
///
/// **Choix tranché : littéral, pas regex.** Une sous-chaîne littérale est
/// déterministe, sans dépendance (`regex`), suffisante pour un sigil de prompt
/// stable (ex. `"\n> "`), et ne risque pas le faux-positif d'un motif regex mal
/// échappé présent dans la sortie. Un moteur regex pourra être ajouté plus tard
/// comme variante déclarative (Open/Closed) si le besoin se confirme.
///
/// **Rang (chantier readiness/heartbeat, lot 1) : signal de repli n°3.** Depuis
/// l'introduction de la fin-de-tour structurée ([`crate::ports::ReplyEvent::Final`]
/// ⇒ [`crate::readiness::ReadinessSignal::TurnEnded`], signal n°1) et du signal
/// explicite `idea_reply` (n°2), ce sniff littéral est **rétrogradé** au rang de
/// repli : il ne sert plus que pour les agents **TUI/PTY sans adapter structuré**.
/// Conservé tel quel pour la rétro-compat (jamais supprimé).
///
/// `None` (défaut, et valeur des profils existants) ⇒ **aucune** détection par
/// motif : l'agent ne repasse `Idle` que sur signal explicite (`idea_reply`) ou via
/// le garde-fou du timeout par tour. Conforme au fallback « en cas de doute → reste
/// `Busy` mais la file continue d'accepter » (jamais de faux `Idle`).
///
/// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de sérialisation :
/// un profil sans motif sérialise exactement comme avant.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_ready_pattern: Option<String>,
/// Réglages de **vivacité** (readiness/heartbeat, chantier lot 1). `None` (défaut,
/// et valeur des profils existants) ⇒ comportement actuel. **Lot 1** : champ
/// présent mais **non consommé** (place ménagée pour les seuils de stagnation /
@ -804,7 +773,6 @@ impl AgentProfile {
session,
structured_adapter: None,
mcp: None,
prompt_ready_pattern: None,
liveness: None,
rate_limit_pattern: None,
submit_sequence: None,
@ -831,15 +799,6 @@ impl AgentProfile {
self
}
/// Builder : fixe le motif de **retour-de-prompt** (§6, lot C5) et renvoie le
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
/// profils sans détection de prompt ne l'appellent simplement pas.
#[must_use]
pub fn with_prompt_ready_pattern(mut self, pattern: impl Into<String>) -> Self {
self.prompt_ready_pattern = Some(pattern.into());
self
}
/// Builder : fixe la [`LivenessStrategy`] (readiness/heartbeat, lot 1) et renvoie
/// le profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
/// profils sans réglage de vivacité ne l'appellent simplement pas.
@ -1117,25 +1076,14 @@ mod mcp_tests {
);
}
// -- Lot C5 : prompt_ready_pattern (détection retour-de-prompt) --------------
// -- Backstop no-reply : le champ `promptReadyPattern` a été retiré ----------
#[test]
fn profile_default_has_no_prompt_ready_pattern() {
// Existing profiles (built via `new`) carry no pattern ⇒ no false Idle.
assert!(profile_without_mcp().prompt_ready_pattern.is_none());
}
#[test]
fn profile_without_prompt_pattern_omits_key_in_json() {
let json = serde_json::to_string(&profile_without_mcp()).expect("serialise");
assert!(
!json.contains("promptReadyPattern"),
"a profile without a prompt pattern must NOT serialise the key (zero regression); got: {json}"
);
}
#[test]
fn legacy_json_without_prompt_pattern_deserialises_to_none() {
fn legacy_json_with_obsolete_prompt_ready_pattern_is_ignored() {
// Le watcher prompt-ready PTY est supprimé (remplacé par le TurnWatcher
// transcript). Un `profiles.json` antérieur portant encore la clé doit
// continuer à se désérialiser (pas de `deny_unknown_fields`) : la clé obsolète
// est simplement ignorée — zéro régression de lecture des profils existants.
let legacy = r#"{
"id": "00000000-0000-0000-0000-000000000000",
"name": "Dev",
@ -1143,22 +1091,11 @@ mod mcp_tests {
"args": [],
"contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" },
"detect": null,
"cwdTemplate": "{agentRunDir}"
"cwdTemplate": "{agentRunDir}",
"promptReadyPattern": "? for shortcuts"
}"#;
let profile: AgentProfile = serde_json::from_str(legacy).expect("legacy deserialise");
assert!(profile.prompt_ready_pattern.is_none());
}
#[test]
fn with_prompt_ready_pattern_sets_and_round_trips() {
let profile = profile_without_mcp().with_prompt_ready_pattern("\n> ");
assert_eq!(profile.prompt_ready_pattern.as_deref(), Some("\n> "));
let json = serde_json::to_string(&profile).expect("serialise");
assert!(json.contains("promptReadyPattern"), "key present: {json}");
let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
assert_eq!(profile, back);
assert_eq!(back.prompt_ready_pattern.as_deref(), Some("\n> "));
assert_eq!(profile.command, "claude");
}
// -- §20 : submit_sequence / submit_delay_ms (portail d'écriture) ------------

View File

@ -15,10 +15,11 @@
//! Déterministe, model-agnostique ⇒ classé [`ReadinessSignal::TurnEnded`].
//! 2. **Signal n°2 — `idea_reply` explicite** : l'agent appelle l'outil MCP
//! [`crate::ports`]/délégation. Premier arrivé gagne avec le n°1.
//! 3. **Signal n°3 — repli `prompt_ready_pattern`** : sniff littéral du sigil de
//! prompt dans la sortie PTY ([`crate::profile::AgentProfile::prompt_ready_pattern`]).
//! **Rétrogradé** au rang de repli depuis ce lot : il ne sert que pour les agents
//! TUI/PTY sans adapter structuré (rétro-compat, jamais supprimé).
//! 3. **Signal n°3 — fin de tour transcript** : l'agent a écrit une fin de tour dans
//! son transcript on-disk (Claude `turn_duration`), observée par le
//! [`crate::ports::TurnWatcher`] qui appelle [`crate::input::InputMediator::turn_ended`].
//! Backstop no-reply : remplace l'ancien sniff littéral du sigil de prompt PTY
//! (`prompt_ready_pattern`), supprimé car inopérant par tour.
//!
//! Les variantes [`ReadinessSignal::Stalled`]/[`ReadinessSignal::TimedOut`] sont la
//! place réservée au **lot 2** (détection de stagnation, remplacement des timeouts) :
@ -40,8 +41,9 @@ pub enum ReadinessSignal {
/// est porté par le chemin de délégation, présent ici pour compléter le
/// vocabulaire et le rendre explicite.
ExplicitReply,
/// Le sigil de prompt PTY (repli n°3) est apparu. Idem : non produit par
/// `classify`, présent pour nommer le signal de repli legacy.
/// Une fin de tour transcript (repli n°3, [`crate::ports::TurnWatcher`]) est
/// apparue. Non produit par `classify` (qui ne voit que des [`ReplyEvent`]) :
/// présent pour nommer le signal de fin-de-tour observé hors flux structuré.
PromptReady,
/// L'agent semble **bloqué** (aucune preuve de vivacité depuis un seuil). Place
/// réservée au **lot 2** — non produit dans ce lot.