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:
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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) ------------
|
||||
|
||||
@ -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.
|
||||
|
||||
Reference in New Issue
Block a user