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

@ -875,10 +875,21 @@ impl MediatedInbox {
/// The `[IdeA · tâche de <requester> · ticket <id>]` header is the protocol signal
/// (cadrage B-5, agent context) that marks the message as an IdeA delegation the
/// target must answer via `idea_reply` — echoing `ticket` for multi-thread
/// correlation — rather than as a free-text human prompt. The raw `task` follows on
/// the next line so the agent reads the request unchanged.
/// correlation — rather than as a free-text human prompt.
///
/// A one-line **imperative reminder** follows the header (durcissement
/// comportemental, finding A) : the single most common wedge is a target that ends
/// its turn with a *prose* answer and never calls `idea_reply`, leaving the asker
/// parked. Reminding it inline — on every delegated task, even trivial ones — attacks
/// that behavioural cause at the central point where the turn is composed (no per-agent
/// duplication). The raw `task` then follows so the agent reads the request unchanged.
fn delegation_preamble(requester: &str, ticket: TicketId, task: &str) -> String {
format!("[IdeA · tâche de {requester} · ticket {ticket}]\n{task}")
format!(
"[IdeA · tâche de {requester} · ticket {ticket}]\n\
⚠️ Termine IMPÉRATIVEMENT ce tour par un appel `idea_reply(ticket=\"{ticket}\", \
result=…)`, même pour une réponse triviale. Ne réponds JAMAIS uniquement en prose \
dans le terminal : sans `idea_reply`, l'agent demandeur reste bloqué.\n{task}"
)
}
fn write_delegation_chunks(
@ -1425,8 +1436,14 @@ mod tests {
inbox.enqueue(a, ticket(10, &task));
// Attendre le SUBMIT final (`\r`, écrit après le délai), pas un simple seuil de
// writes : le nombre de chunks de contenu dépend de la taille du payload (préambule
// + tâche), donc seul le `\r` terminal est un point d'arrêt stable.
assert!(
wait_until(|| pty.writes.lock().unwrap().len() >= 4),
wait_until(|| {
let w = pty.writes.lock().unwrap();
w.len() >= 4 && w.last().map(Vec::as_slice) == Some(b"\r".as_slice())
}),
"long headless delivery writes multiple chunks followed by submit"
);
let writes = pty.writes.lock().unwrap().clone();

View File

@ -50,7 +50,9 @@ pub use id::UuidGenerator;
pub use input::{MediatedInbox, MillisClock, SystemMillisClock};
pub use inspector::ClaudeTranscriptInspector;
pub use mailbox::InMemoryMailbox;
pub use orchestrator::mcp::{McpServer, MemoryTransport, StdioTransport};
pub use orchestrator::mcp::{
resolve_ask_rendezvous_timeout, McpServer, MemoryTransport, StdioTransport,
};
pub use orchestrator::{
process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle,
REQUESTS_SUBDIR,
@ -75,6 +77,7 @@ pub use store::{
AdaptiveMemoryRecall, EmbedderEnvProbe, FsEmbedderProfileStore, FsEmbedderPromptStore,
FsLiveStateStore, FsMemoryStore, FsPermissionStore, FsProfileStore, FsProjectStore,
FsSkillStore, FsTemplateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall,
OnnxModelInfo, StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
OnnxModelInfo, ReferenceBackfillProfileStore, StubEmbedder, VectorMemoryRecall,
DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
};

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 {

View File

@ -28,7 +28,7 @@ pub use embedder::{
pub use live_state::FsLiveStateStore;
pub use memory::{index_token_size, FsMemoryStore, NaiveMemoryRecall};
pub use permission::FsPermissionStore;
pub use profile::{FsEmbedderProfileStore, FsProfileStore};
pub use profile::{FsEmbedderProfileStore, FsProfileStore, ReferenceBackfillProfileStore};
pub use project::FsProjectStore;
pub use skill::FsSkillStore;
pub use template::FsTemplateStore;

View File

@ -148,6 +148,59 @@ impl ProfileStore for FsProfileStore {
}
}
/// **Merge-on-read decorator** over any [`ProfileStore`]: backfills *internal*
/// reference defaults that a stored profile is missing, without ever persisting
/// anything (finding A — propagate the measured `prompt_ready_pattern` to profiles
/// saved before that field existed).
///
/// Only [`list`](ProfileStore::list) is transformed — each returned profile is run
/// through [`application::overlay_reference_defaults`] (pure, allowlisted, never
/// clobbers a user value, idempotent). `save`/`delete`/`is_configured`/
/// `mark_configured` **delegate verbatim** so persistence and first-run detection are
/// byte-for-byte unchanged. Wrap the concrete [`FsProfileStore`] once at the
/// composition root, before injecting the store into use cases and the orchestrator,
/// so every reader (including direct `.list()` calls) sees the overlaid reads.
pub struct ReferenceBackfillProfileStore {
inner: Arc<dyn ProfileStore>,
}
impl ReferenceBackfillProfileStore {
/// Wraps an inner [`ProfileStore`]; reads are overlaid, writes pass through.
#[must_use]
pub fn new(inner: Arc<dyn ProfileStore>) -> Self {
Self { inner }
}
}
#[async_trait]
impl ProfileStore for ReferenceBackfillProfileStore {
async fn list(&self) -> Result<Vec<AgentProfile>, StoreError> {
Ok(self
.inner
.list()
.await?
.into_iter()
.map(|p| application::overlay_reference_defaults(&p))
.collect())
}
async fn save(&self, profile: &AgentProfile) -> Result<(), StoreError> {
self.inner.save(profile).await
}
async fn delete(&self, id: ProfileId) -> Result<(), StoreError> {
self.inner.delete(id).await
}
async fn is_configured(&self) -> Result<bool, StoreError> {
self.inner.is_configured().await
}
async fn mark_configured(&self) -> Result<(), StoreError> {
self.inner.mark_configured().await
}
}
// ---------------------------------------------------------------------------
// FsEmbedderProfileStore — declarative embedder profiles (`embedder.json`, LOT C).
// ---------------------------------------------------------------------------
@ -286,3 +339,88 @@ impl EmbedderProfileStore for FsEmbedderProfileStore {
FsEmbedderProfileStore::delete(self, id).await
}
}
#[cfg(test)]
mod backfill_tests {
use super::*;
use std::sync::Mutex;
/// In-memory [`ProfileStore`] fake recording mutating/query calls, so we can prove
/// the decorator transforms ONLY `list` and delegates the rest verbatim.
#[derive(Default)]
struct FakeStore {
profiles: Mutex<Vec<AgentProfile>>,
saved: Mutex<Vec<AgentProfile>>,
deleted: Mutex<Vec<ProfileId>>,
is_configured_calls: Mutex<u32>,
mark_configured_calls: Mutex<u32>,
}
#[async_trait]
impl ProfileStore for FakeStore {
async fn list(&self) -> Result<Vec<AgentProfile>, StoreError> {
Ok(self.profiles.lock().unwrap().clone())
}
async fn save(&self, profile: &AgentProfile) -> Result<(), StoreError> {
self.saved.lock().unwrap().push(profile.clone());
Ok(())
}
async fn delete(&self, id: ProfileId) -> Result<(), StoreError> {
self.deleted.lock().unwrap().push(id);
Ok(())
}
async fn is_configured(&self) -> Result<bool, StoreError> {
*self.is_configured_calls.lock().unwrap() += 1;
Ok(true)
}
async fn mark_configured(&self) -> Result<(), StoreError> {
*self.mark_configured_calls.lock().unwrap() += 1;
Ok(())
}
}
/// Claude reference profile with the internal field cleared — the persisted-before
/// shape the overlay backfills.
fn claude_without_pattern() -> AgentProfile {
let id = application::reference_profile_id("claude");
let mut p = application::reference_profiles()
.into_iter()
.find(|p| p.id == id)
.expect("claude reference exists");
p.prompt_ready_pattern = None;
p
}
#[tokio::test]
async fn list_overlays_reference_defaults() {
let fake = Arc::new(FakeStore::default());
fake.profiles.lock().unwrap().push(claude_without_pattern());
let store = ReferenceBackfillProfileStore::new(Arc::clone(&fake) as Arc<dyn ProfileStore>);
let listed = store.list().await.expect("list ok");
assert_eq!(listed.len(), 1);
assert_eq!(
listed[0].prompt_ready_pattern.as_deref(),
Some("? for shortcuts"),
"a stored Claude profile WITHOUT the pattern is backfilled on read"
);
}
#[tokio::test]
async fn save_delete_and_config_delegate_verbatim() {
let fake = Arc::new(FakeStore::default());
let store = ReferenceBackfillProfileStore::new(Arc::clone(&fake) as Arc<dyn ProfileStore>);
let p = claude_without_pattern();
store.save(&p).await.expect("save ok");
store.delete(p.id).await.expect("delete ok");
assert!(store.is_configured().await.expect("is_configured ok"));
store.mark_configured().await.expect("mark_configured ok");
// save passes the profile through UNCHANGED (no overlay on the write path).
assert_eq!(*fake.saved.lock().unwrap(), vec![p.clone()]);
assert_eq!(*fake.deleted.lock().unwrap(), vec![p.id]);
assert_eq!(*fake.is_configured_calls.lock().unwrap(), 1);
assert_eq!(*fake.mark_configured_calls.lock().unwrap(), 1);
}
}