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

@ -17,9 +17,6 @@
//! so re-deriving the catalogue yields the same id for "claude" every time,
//! making the reference profiles addressable across runs without a registry.
use std::collections::HashMap;
use std::sync::OnceLock;
use domain::ids::ProfileId;
use domain::permission::ProjectorKey;
use domain::profile::{
@ -70,14 +67,6 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
)
.expect("claude reference profile is valid")
.with_structured_adapter(StructuredAdapter::Claude)
// Marqueur de **retour au prompt idle** (signal de repli n°3, agents PTY/TUI ;
// arme le watcher dans `MediatedInbox::bind_handle_with_prompt`). Mesuré en réel
// (capture PTY live, finding A) : le footer idle de Claude Code rend la sous-chaîne
// contiguë `? for shortcuts` à chaque retour au prompt (et au démarrage idle).
// Discriminant : pendant un tour, le footer devient `esc to interrupt` (+ spinner),
// donc cette chaîne n'apparaît JAMAIS en milieu de tour ⇒ pas de faux positif. La
// recherche est littérale sur octets bruts : l'ASCII suffit (pas d'ANSI à embarquer).
.with_prompt_ready_pattern("? for shortcuts")
.with_projector(ProjectorKey::Claude)
.with_mcp(McpCapability::new(
McpConfigStrategy::config_file(".mcp.json")
@ -156,106 +145,6 @@ pub fn selectable_reference_profiles() -> Vec<AgentProfile> {
.collect()
}
/// Lazy index `ProfileId -> reference profile`, built once from
/// [`reference_profiles`]. Keyed by the deterministic [`reference_id`], so a stored
/// profile created from a reference is addressable across runs without a registry.
fn reference_index() -> &'static HashMap<ProfileId, AgentProfile> {
static INDEX: OnceLock<HashMap<ProfileId, AgentProfile>> = OnceLock::new();
INDEX.get_or_init(|| reference_profiles().into_iter().map(|p| (p.id, p)).collect())
}
/// **Merge-on-read overlay** (pure, no I/O): backfills *internal* reference fields
/// that a stored profile is missing, without ever persisting anything.
///
/// Motivation: profiles persisted before a reference field existed (e.g. the measured
/// `prompt_ready_pattern`, finding A) are the launch-time source of truth, but the
/// catalogue is never re-merged into the store. This overlay lets every reader see the
/// up-to-date reference defaults while the on-disk `profiles.json` stays untouched.
///
/// Invariants:
/// - **Match on the deterministic [`reference_id`] only** — a custom profile (random id)
/// is returned **unchanged** (its id is absent from [`reference_index`]).
/// - **Never clobbers a user value** — a field is filled **iff** it is `None`/absent on
/// the stored profile; a present value always wins.
/// - **Strict allowlist** — only *internal, non-wizard-editable* reference fields are
/// overlaid. Today that is **`prompt_ready_pattern` alone** (an internal measured
/// marker, never surfaced in the wizard). User-editable fields are never touched.
/// - **Idempotent**: `overlay(overlay(p)) == overlay(p)` (a filled field is then
/// present, so the second pass is a no-op).
/// - **Pure**: no I/O, safe to call on every `list()`.
#[must_use]
pub fn overlay_reference_defaults(stored: &AgentProfile) -> AgentProfile {
let Some(reference) = reference_index().get(&stored.id) else {
return stored.clone();
};
let mut merged = stored.clone();
// Allowlist STRICTE — un champ par ligne, rempli uniquement s'il est absent.
if merged.prompt_ready_pattern.is_none() {
merged.prompt_ready_pattern = reference.prompt_ready_pattern.clone();
}
merged
}
#[cfg(test)]
mod overlay_tests {
use super::*;
/// Claude reference profile with `prompt_ready_pattern` cleared — stands in for a
/// profile persisted before the field existed (the case this overlay fixes).
fn claude_without_pattern() -> AgentProfile {
let id = reference_id("claude");
let mut p = reference_profiles()
.into_iter()
.find(|p| p.id == id)
.expect("claude reference exists");
p.prompt_ready_pattern = None;
p
}
#[test]
fn reference_id_with_missing_field_is_backfilled() {
let merged = overlay_reference_defaults(&claude_without_pattern());
assert_eq!(
merged.prompt_ready_pattern.as_deref(),
Some("? for shortcuts"),
"a reference profile missing the internal field gets it backfilled"
);
}
#[test]
fn reference_id_never_clobbers_a_present_user_value() {
let mut stored = claude_without_pattern();
stored.prompt_ready_pattern = Some("USER OVERRIDE".to_owned());
let merged = overlay_reference_defaults(&stored);
assert_eq!(
merged.prompt_ready_pattern.as_deref(),
Some("USER OVERRIDE"),
"a present value always wins — never clobbered"
);
}
#[test]
fn non_reference_id_is_returned_unchanged() {
// A custom profile (random id absent from the reference index) is untouched,
// even when it is missing the field.
let mut custom = claude_without_pattern();
custom.id = ProfileId::from_uuid(uuid::Uuid::from_u128(0xC0FFEE));
let merged = overlay_reference_defaults(&custom);
assert_eq!(
merged.prompt_ready_pattern, None,
"a custom profile is never backfilled (id not a reference id)"
);
assert_eq!(merged, custom, "custom profile returned byte-for-byte unchanged");
}
#[test]
fn overlay_is_idempotent() {
let once = overlay_reference_defaults(&claude_without_pattern());
let twice = overlay_reference_defaults(&once);
assert_eq!(once, twice, "overlay(overlay(p)) == overlay(p)");
}
}
#[cfg(test)]
mod mcp_tests {
use super::*;
@ -268,28 +157,6 @@ mod mcp_tests {
.unwrap_or_else(|| panic!("reference profile `{slug}` exists"))
}
#[test]
fn claude_seed_carries_the_measured_prompt_ready_pattern() {
// Marqueur de retour-au-prompt idle mesuré en réel (capture PTY live, finding A).
// Recherche littérale d'octets côté watcher ⇒ l'ASCII exact suffit. Non vide ⇒ le
// watcher s'arme (`MediatedInbox::bind_handle_with_prompt`).
assert_eq!(
profile("claude").prompt_ready_pattern.as_deref(),
Some("? for shortcuts"),
"Claude seed must carry the measured idle-prompt marker"
);
}
#[test]
fn codex_seed_leaves_prompt_ready_pattern_empty_for_now() {
// Capture Codex non concluante (sandbox) + Codex non utilisé en pratique : pattern
// laissé VIDE (le backstop serveur Lot 3 couvre l'absence de détection). Follow-up.
assert!(
profile("codex").prompt_ready_pattern.is_none(),
"Codex prompt_ready_pattern stays empty until a clean live capture"
);
}
#[test]
fn claude_and_codex_expose_mcp_capability() {
for slug in ["claude", "codex"] {

View File

@ -23,7 +23,7 @@ pub use structured::{
};
pub use catalogue::{
overlay_reference_defaults, reference_profile_id, reference_profiles,
reference_profile_id, reference_profiles,
selectable_reference_profiles, CODEX_SUBMIT_DELAY_MS,
};
pub use inspect::{InspectConversation, InspectConversationInput, InspectConversationOutput};

View File

@ -301,11 +301,10 @@ mod tests {
AgentBusyState::Idle
}
fn bind_handle(&self, _agent: AgentId, _handle: PtyHandle) {}
fn bind_handle_with_prompt(
fn bind_handle_with_submit(
&self,
_agent: AgentId,
_handle: PtyHandle,
_pattern: Option<String>,
_submit: SubmitConfig,
) {
}

View File

@ -31,7 +31,7 @@ pub mod window;
pub mod workstate;
pub use agent::{
drain_with_readiness, drain_with_readiness_outcome, overlay_reference_defaults,
drain_with_readiness, drain_with_readiness_outcome,
reference_profile_id, reference_profiles,
selectable_reference_profiles, send_blocking, AgentResumer, ChangeAgentProfile,
ChangeAgentProfileInput, ChangeAgentProfileOutput, ConfigureProfiles, ConfigureProfilesInput,

View File

@ -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) :