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:
@ -75,12 +75,15 @@ fn submit_config_for_profile(profile: &AgentProfile) -> SubmitConfig {
|
||||
/// intentionally not yet config-exposed (it may become a per-project setting
|
||||
/// without changing the contract).
|
||||
///
|
||||
/// TEMPORAIRE (demande utilisateur 2026-06-13) : la borne de 300 s coupait des tours
|
||||
/// délégués légitimement très longs (gros refactors + tests). On la relève donc à 24 h
|
||||
/// (≈ « pas de limite ») le temps de concevoir un vrai signal de vivacité (savoir si
|
||||
/// l'agent travaille encore) qui remplacera ce timeout brut. NE PAS considérer comme
|
||||
/// définitif : à reconvertir en borne courte + heartbeat once that liveness signal exists.
|
||||
const ASK_AGENT_TIMEOUT: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
/// **Plancher universel FINI (backstop no-reply).** Le vrai signal de fin-de-tour
|
||||
/// existe désormais (`turn_duration` du transcript via [`domain::ports::TurnWatcher`]
|
||||
/// ⇒ [`domain::input::InputMediator::turn_ended`], qui réveille l'appelant en ≈ grâce),
|
||||
/// mais il n'est émis que par Claude. Ce timeout reste donc le **dernier recours**
|
||||
/// pour toute cible silencieuse qui n'émet pas `turn_duration` (Codex & co) ou qui
|
||||
/// hang : il ne doit JAMAIS être (quasi-)infini sous peine de wedger l'appelant. 600 s
|
||||
/// par défaut, **surchargeable par profil** via `turn_timeout_ms`
|
||||
/// ([`resolve_turn_timeout`]).
|
||||
const ASK_AGENT_TIMEOUT: Duration = Duration::from_secs(600);
|
||||
|
||||
/// Borne d'attente **en file** pour acquérir le verrou de tour d'un agent (A0,
|
||||
/// cadrage v5 §4).
|
||||
@ -96,10 +99,9 @@ const ASK_AGENT_TIMEOUT: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
/// affecté. Largeur = un tour complet + sa propre file ⇒ on autorise deux tours
|
||||
/// pleins d'attente.
|
||||
///
|
||||
/// TEMPORAIRE (cf. [`ASK_AGENT_TIMEOUT`]) : relevé à 48 h (≈ deux tours « sans limite »)
|
||||
/// en cohérence avec la levée temporaire de la borne de tour, le temps d'un vrai
|
||||
/// signal de vivacité.
|
||||
const ASK_QUEUE_WAIT_CAP: Duration = Duration::from_secs(48 * 60 * 60);
|
||||
/// FINI (cf. [`ASK_AGENT_TIMEOUT`]) : deux tours pleins d'attente ⇒ 2 × 600 s = 1200 s.
|
||||
/// Comme la borne de tour, ce plafond reste fini (jamais d'attente quasi-infinie en file).
|
||||
const ASK_QUEUE_WAIT_CAP: Duration = Duration::from_secs(1200);
|
||||
|
||||
/// Borne de tour effective (lot 2, timeouts pilotés par profil) : le
|
||||
/// `turn_timeout_ms` du profil de la cible **quand il existe**, sinon le défaut
|
||||
@ -1298,29 +1300,26 @@ impl OrchestratorService {
|
||||
let (handle, cold_launch) = self
|
||||
.ensure_live_pty(project, agent_id, conversation_id, &target)
|
||||
.await?;
|
||||
// Arm prompt-ready detection (C5) with the target profile's literal marker, so a
|
||||
// return-to-prompt frees the turn (the other OR signal being `idea_reply`).
|
||||
let (prompt_pattern, submit, has_mcp) =
|
||||
self.prompt_and_submit_for_agent(project, agent_id).await;
|
||||
// Gate cold-launch : un agent froid n'est pas encore à son prompt. On diffère le
|
||||
// 1er tour s'il existe un signal pour le libérer — soit le prompt-ready watcher
|
||||
// (pattern non vide), soit la connexion du pont MCP de l'agent
|
||||
// (`InputMediator::release_cold_start`, déclenchée par l'McpServer). Sans aucun
|
||||
// des deux ⇒ pas de gate (livraison immédiate, sinon blocage indéfini).
|
||||
let gate_cold_start =
|
||||
cold_launch && (prompt_pattern.as_ref().is_some_and(|p| !p.is_empty()) || has_mcp);
|
||||
// Résout la config de soumission du profil cible + s'il déclare un pont MCP.
|
||||
let (submit, has_mcp) = self.submit_and_mcp_for_agent(project, agent_id).await;
|
||||
// Gate cold-launch : un agent froid n'est pas encore prêt à recevoir son 1er tour.
|
||||
// On le diffère s'il existe un signal pour le libérer — la connexion du pont MCP
|
||||
// de l'agent (`InputMediator::release_cold_start`, déclenchée par l'McpServer sur
|
||||
// `initialize`). C'est désormais le **seul** signal de readiness (le watcher
|
||||
// prompt-ready PTY a été supprimé). Sans pont MCP ⇒ pas de gate (livraison
|
||||
// immédiate, sinon blocage indéfini).
|
||||
let gate_cold_start = cold_launch && has_mcp;
|
||||
// Diagnostics : décision de gate du premier tour. `gate_cold_start=false` sur une
|
||||
// cible froide SANS signal de libération (ni prompt-ready ni MCP) livrerait
|
||||
// immédiatement ; un `true` diffère la livraison jusqu'au signal.
|
||||
// cible froide SANS pont MCP livrerait immédiatement ; un `true` diffère la
|
||||
// livraison jusqu'au signal MCP-initialize.
|
||||
crate::diag!(
|
||||
"[rendezvous] gate decision: target={target} (agent {agent_id}) cold_launch={cold_launch} \
|
||||
has_prompt_pattern={} has_mcp={has_mcp} gate_cold_start={gate_cold_start}",
|
||||
prompt_pattern.as_ref().is_some_and(|p| !p.is_empty()),
|
||||
has_mcp={has_mcp} gate_cold_start={gate_cold_start}",
|
||||
);
|
||||
if gate_cold_start {
|
||||
input.mark_starting(agent_id);
|
||||
}
|
||||
input.bind_handle_with_prompt(agent_id, handle.clone(), prompt_pattern, submit);
|
||||
input.bind_handle_with_submit(agent_id, handle.clone(), submit);
|
||||
|
||||
// 2. Enregistrer le ticket (slot de réponse) + livrer le tour via le médiateur
|
||||
// (écriture sérialisée dans le PTY — plus d'écriture ad hoc ici). Le ticket
|
||||
@ -1694,19 +1693,17 @@ impl OrchestratorService {
|
||||
let (handle, cold_launch) = self
|
||||
.ensure_live_pty(project, agent_id, conversation_id, target)
|
||||
.await?;
|
||||
let (prompt_pattern, submit, has_mcp) =
|
||||
self.prompt_and_submit_for_agent(project, agent_id).await;
|
||||
// Gate cold-launch : un agent froid n'est pas encore à son prompt. On diffère le
|
||||
// 1er tour s'il existe un signal pour le libérer — soit le prompt-ready watcher
|
||||
// (pattern non vide), soit la connexion du pont MCP de l'agent
|
||||
// (`InputMediator::release_cold_start`, déclenchée par l'McpServer). Sans aucun
|
||||
// des deux ⇒ pas de gate (livraison immédiate, sinon blocage indéfini).
|
||||
let gate_cold_start =
|
||||
cold_launch && (prompt_pattern.as_ref().is_some_and(|p| !p.is_empty()) || has_mcp);
|
||||
let (submit, has_mcp) = self.submit_and_mcp_for_agent(project, agent_id).await;
|
||||
// Gate cold-launch : un agent froid n'est pas encore prêt. On diffère le 1er tour
|
||||
// s'il existe un signal pour le libérer — la connexion du pont MCP de l'agent
|
||||
// (`InputMediator::release_cold_start`, déclenchée par l'McpServer). Seul signal
|
||||
// de readiness restant (watcher prompt-ready PTY supprimé). Sans pont MCP ⇒ pas
|
||||
// de gate (livraison immédiate, sinon blocage indéfini).
|
||||
let gate_cold_start = cold_launch && has_mcp;
|
||||
if gate_cold_start {
|
||||
input.mark_starting(agent_id);
|
||||
}
|
||||
input.bind_handle_with_prompt(agent_id, handle, prompt_pattern, submit);
|
||||
input.bind_handle_with_submit(agent_id, handle, submit);
|
||||
|
||||
// Enqueue a human-sourced ticket in the SAME FIFO as delegations. Fire-and-
|
||||
// forget: we drop the PendingReply (the human reads the terminal). The
|
||||
@ -2311,19 +2308,19 @@ impl OrchestratorService {
|
||||
)))
|
||||
}
|
||||
|
||||
/// Resolves the target agent profile's **prompt-ready pattern** (§6, lot C5) **and**
|
||||
/// its **submit config** (`submit_sequence`/`submit_delay_ms`, ARCHITECTURE §20.3) in
|
||||
/// a single profile lookup. Both are carried into `bind_handle_with_prompt`: the
|
||||
/// pattern arms prompt detection, the submit config is echoed on the next
|
||||
/// `DelegationReady` so the frontend write-portal knows how to submit. Returns
|
||||
/// `(None, default)` when the agent, its profile, or the field is absent — the safe
|
||||
/// fallback (no pattern ⇒ Idle only via explicit signal/timeout; no submit ⇒ the
|
||||
/// front applies its own `"\r"`/~60 ms default).
|
||||
async fn prompt_and_submit_for_agent(
|
||||
/// Resolves the target agent profile's **submit config**
|
||||
/// (`submit_sequence`/`submit_delay_ms`, ARCHITECTURE §20.3) **and** whether it
|
||||
/// declares an **MCP bridge**, in a single profile lookup. The submit config is
|
||||
/// carried into `bind_handle_with_submit` (echoed on the next `DelegationReady` so
|
||||
/// the frontend write-portal knows how to submit); the MCP flag drives the
|
||||
/// cold-launch gate (its `initialize` connection releases a deferred first turn).
|
||||
/// Returns `(default, false)` when the agent or its profile is absent — the safe
|
||||
/// fallback (no submit ⇒ the front applies its own `"\r"`/~60 ms default; no gate).
|
||||
async fn submit_and_mcp_for_agent(
|
||||
&self,
|
||||
project: &Project,
|
||||
agent_id: AgentId,
|
||||
) -> (Option<String>, SubmitConfig, bool) {
|
||||
) -> (SubmitConfig, bool) {
|
||||
let Some(agent) = self
|
||||
.list_agents
|
||||
.execute(ListAgentsInput {
|
||||
@ -2333,7 +2330,7 @@ impl OrchestratorService {
|
||||
.ok()
|
||||
.and_then(|out| out.agents.into_iter().find(|a| a.id == agent_id))
|
||||
else {
|
||||
return (None, SubmitConfig::default(), false);
|
||||
return (SubmitConfig::default(), false);
|
||||
};
|
||||
let Some(profile) = self
|
||||
.profiles
|
||||
@ -2342,13 +2339,13 @@ impl OrchestratorService {
|
||||
.ok()
|
||||
.and_then(|ps| ps.into_iter().find(|p| p.id == agent.profile_id))
|
||||
else {
|
||||
return (None, SubmitConfig::default(), false);
|
||||
return (SubmitConfig::default(), false);
|
||||
};
|
||||
let submit = submit_config_for_profile(&profile);
|
||||
// 3e élément : le profil cible déclare-t-il un pont MCP ? Si oui, sa connexion
|
||||
// (initialize) servira de signal de readiness de démarrage pour libérer un 1er
|
||||
// tour différé — d'où le gate cold-launch même sans `prompt_ready_pattern`.
|
||||
(profile.prompt_ready_pattern, submit, profile.mcp.is_some())
|
||||
// 2e élément : le profil cible déclare-t-il un pont MCP ? Si oui, sa connexion
|
||||
// (`initialize`) sert de signal de readiness de démarrage pour libérer un 1er
|
||||
// tour différé (`release_cold_start`) — c'est ce qui arme le gate cold-launch.
|
||||
(submit, profile.mcp.is_some())
|
||||
}
|
||||
|
||||
/// Résout la [`domain::profile::LivenessStrategy`] du profil de la cible (lot 2) :
|
||||
|
||||
Reference in New Issue
Block a user