feat(orchestrator): durcir le rendez-vous inter-agents + backstop no-reply (Finding A)

Corrige le wedge survenant lorsqu'un agent délégué ne rappelle pas idea_reply :
le rendez-vous ask_agent ne reste plus bloqué indéfiniment.

Lot 1 — durcissement du préambule de délégation :
- input/mod.rs : delegation_preamble renforcé (obligation explicite idea_reply).
- agent/lifecycle.rs : préambule injecté conditionné à mcp_enabled.

Lot 2 — peuplement du prompt_ready_pattern :
- agent/catalogue.rs : .with_prompt_ready_pattern("? for shortcuts") sur le
  profil Claude + tests de seed, pour calibrer la détection de grâce.

Lot 3 — backstop timeout serveur :
- mcp/jsonrpc.rs : code d'erreur typé RENDEZVOUS_NO_REPLY (-32001).
- mcp/server.rs : mapping timeout → erreur typée, with_ask_rendezvous_timeout
  promu + resolve_ask_rendezvous_timeout (configurable).
- app-tauri/state.rs : lecture env IDEA_ASK_RENDEZVOUS_TIMEOUT_MS.
- tests/mcp_server.rs mis à jour, fix d'un test input flaky.

Propagation overlay (référence des profils) :
- agent/catalogue.rs : overlay_reference_defaults + reference_index + tests.
- store/profile.rs : ReferenceBackfillProfileStore + tests.
- app-tauri/state.rs : wrap au composition root.
- exports mod.rs / lib.rs (application + infrastructure).

Tests réels verts : application 494, app-tauri 235, infrastructure 248,
mcp_server 22/22, 0 échec. Validation e2e LIVE (rebuild AppImage) en attente
avant merge vers develop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 23:46:48 +02:00
parent 47b3806d32
commit c5e54935b8
13 changed files with 414 additions and 29 deletions

View File

@ -121,6 +121,15 @@ pub mod error_codes {
pub const INVALID_PARAMS: i32 = -32_602;
/// Internal server error (a dispatched IdeA command failed).
pub const INTERNAL_ERROR: i32 = -32_603;
/// **Server-defined** (JSON-RPC reserved range 32000..=32099): the synchronous
/// `idea_ask_agent` rendezvous expired against the server-side safety net without
/// the target ever rendering a reply. **Distinct** from [`INTERNAL_ERROR`] on
/// purpose — it is not an internal fault but a *typed, retryable* outcome
/// (semantics mirror [`application::AppError::TargetReturnedNoReply`]): the caller
/// may re-ask, reminding the target to answer via `idea_reply`. The accompanying
/// `data` carries `{ "code": "TARGET_RETURNED_NO_REPLY", "retryable": true }`.
pub const RENDEZVOUS_NO_REPLY: i32 = -32_001;
}
/// Errors a [`Transport`] may surface.

View File

@ -36,6 +36,6 @@ pub mod transport;
pub use jsonrpc::{
JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError, JSONRPC_VERSION,
};
pub use server::McpServer;
pub use server::{resolve_ask_rendezvous_timeout, McpServer};
pub use tools::{catalogue, map_tool_call, tool_returns_reply, ToolDef, ToolMapError};
pub use transport::{MemoryTransport, StdioTransport};

View File

@ -46,6 +46,20 @@ const MCP_PROTOCOL_VERSION: &str = "2024-11-05";
/// untouched — they don't rendezvous.
const ASK_RENDEZVOUS_TIMEOUT: Duration = Duration::from_secs(24 * 60 * 60);
/// Resolves the effective `idea_ask_agent` rendezvous bound from an optional
/// configured override in **milliseconds** (project/profile level), mirroring the
/// application's [`resolve_turn_timeout`](application) shape: `Some(ms)` ⇒ that bound,
/// `None`/absent ⇒ the generous [`ASK_RENDEZVOUS_TIMEOUT`] default (statu quo, zero
/// regression). `Some(0)` is treated as "no override" so a misconfigured zero can never
/// collapse the safety net to an instant expiry. Pure and unit-testable without wiring.
#[must_use]
pub fn resolve_ask_rendezvous_timeout(override_ms: Option<u32>) -> Duration {
match override_ms {
Some(ms) if ms > 0 => Duration::from_millis(u64::from(ms)),
_ => ASK_RENDEZVOUS_TIMEOUT,
}
}
/// The IdeA MCP server: an entry adapter over [`OrchestratorService::dispatch`].
///
/// Cheap to clone the dependencies it holds; one instance serves one project's
@ -138,11 +152,15 @@ impl McpServer {
}
}
/// **Test seam** (doc-hidden): shrinks the `idea_ask_agent` rendezvous bound
/// (see [`ASK_RENDEZVOUS_TIMEOUT`]) so a wedged-target test need not wait the
/// full production timeout. Doc-hidden so it does not widen the documented public
/// surface; production code never calls it.
#[doc(hidden)]
/// Overrides the `idea_ask_agent` rendezvous safety-net bound (default
/// [`ASK_RENDEZVOUS_TIMEOUT`]).
///
/// A genuine **configuration knob** (project/profile level, modelled on the
/// application's `turn_timeout_ms` / `resolve_turn_timeout`): the composition root
/// resolves an optional override with [`resolve_ask_rendezvous_timeout`] and applies
/// it here. Absent override ⇒ the generous 24 h default is preserved unchanged (zero
/// regression on legitimately long delegated turns). Tests also use it to shrink the
/// bound to milliseconds.
#[must_use]
pub fn with_ask_rendezvous_timeout(mut self, timeout: Duration) -> Self {
self.ask_rendezvous_timeout = timeout;
@ -401,16 +419,20 @@ impl McpServer {
match tokio::time::timeout(self.ask_rendezvous_timeout, dispatch).await {
Ok(result) => result,
Err(_elapsed) => {
// RISQUE #1 (Architect) : on `return` ici ⇒ le futur `dispatch` (déplacé
// dans `timeout`) est **droppé**, jamais détaché dans un `spawn`. C'est ce
// Drop qui libère le `BusyTurnGuard` RAII de `ask_agent` (cancel_head +
// mark_idle) et purge le `Busy`/ticket de la cible — aucune fuite `Busy`.
application::diag!(
"[mcp] ask EXPIRED requester={requester_label} target={arg_target} \
timeout_ms={} elapsed_ms={}",
self.ask_rendezvous_timeout.as_millis(),
started.elapsed().as_millis(),
);
return Err(JsonRpcError::new(
error_codes::INTERNAL_ERROR,
"idea_ask_agent : aucune réponse de la cible avant le timeout du rendezvous",
));
// Filet serveur : ne plus renvoyer un INTERNAL_ERROR opaque, mais la même
// sémantique typée et **retryable** que `AppError::TargetReturnedNoReply`,
// sous un code JSON-RPC DISTINCT (RENDEZVOUS_NO_REPLY) + `data` structuré.
return Err(rendezvous_no_reply_error(&arg_target));
}
}
} else {
@ -483,6 +505,33 @@ fn tool_result_text(text: &str, is_error: bool) -> Value {
})
}
/// Builds the JSON-RPC error returned when the `idea_ask_agent` rendezvous expires
/// against the server-side safety net (the `Elapsed` branch of the outer `timeout`).
///
/// Mirrors [`application::AppError::TargetReturnedNoReply`] one-for-one — same message
/// and same machine-readable `code` (`"TARGET_RETURNED_NO_REPLY"`), flagged `retryable`
/// — but as a JSON-RPC protocol error under the **distinct**
/// [`error_codes::RENDEZVOUS_NO_REPLY`] code (never the opaque `INTERNAL_ERROR`), since
/// at expiry no [`OrchestratorOutcome`](application::OrchestratorOutcome) exists to fold
/// into a tool result. The `data` object lets a caller branch without parsing the
/// message.
fn rendezvous_no_reply_error(target: &str) -> JsonRpcError {
let message = format!(
"agent {target} returned to its prompt without calling idea_reply (no answer was \
rendered) before the rendezvous timeout; retry the request and ensure the agent \
replies via idea_reply"
);
JsonRpcError {
code: error_codes::RENDEZVOUS_NO_REPLY,
message,
data: Some(json!({
"code": "TARGET_RETURNED_NO_REPLY",
"retryable": true,
"target": target,
})),
}
}
/// Maps a [`ToolMapError`] to the right JSON-RPC error code.
fn map_err_to_jsonrpc(err: ToolMapError) -> JsonRpcError {
match err {