feat(diag): instrumente les blocages de délégation idea_ask_agent

Ajoute des logs diag! sans changement sémantique le long du canal de
délégation inter-agents (application/orchestrator, infrastructure
input, mailbox, mcp server & tools) pour diagnostiquer les blocages
récurrents de idea_ask_agent. Couvert par les tests existants étendus
(mcp_server, mailbox, input, tools, orchestrator_service).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 09:41:02 +02:00
parent 61c330a54e
commit e5e412bdf2
6 changed files with 426 additions and 43 deletions

View File

@ -18,7 +18,7 @@
//! duplicated here.
use std::sync::Arc;
use std::time::Duration;
use std::time::{Duration, Instant};
use application::OrchestratorService;
use domain::{DomainEvent, OrchestrationSource, Project};
@ -345,10 +345,43 @@ impl McpServer {
.to_owned();
let arguments = params.get("arguments").cloned().unwrap_or(json!({}));
// Diagnostics begin beacon (best-effort, jamais le corps task/result) : trace
// l'entrée d'un `tools/call`, son tool, le peer demandeur et la cible/longueurs.
// C'est l'amorce de la trace `[mcp]` begin → (armed) → (expired) → end qui
// permet de voir un `idea_ask_agent` entrer sans jamais ressortir (blocage).
let started = Instant::now();
let requester_label = if self.requester.is_empty() {
"mcp".to_owned()
} else {
self.requester.clone()
};
let arg_target = arguments
.get("target")
.and_then(Value::as_str)
.unwrap_or("-")
.to_owned();
let task_len = arguments
.get("task")
.and_then(Value::as_str)
.map_or(0, str::len);
application::diag!(
"[mcp] tools_call begin tool={name} requester={requester_label} target={arg_target} \
task_len={task_len}",
);
// `idea_reply` needs the connected peer's identity as `from`; every other tool
// ignores `requester`. The handshake-provided requester is the source of truth.
let command =
tools::map_tool_call(&name, &arguments, &self.requester).map_err(map_err_to_jsonrpc)?;
let command = match tools::map_tool_call(&name, &arguments, &self.requester) {
Ok(command) => command,
Err(e) => {
application::diag!(
"[mcp] tools_call end tool={name} requester={requester_label} mapped=err \
err={e} elapsed_ms={}",
started.elapsed().as_millis(),
);
return Err(map_err_to_jsonrpc(e));
}
};
// `idea_ask_agent` is the only tool that blocks on a synchronous rendezvous
// (the target's `idea_reply`). Bound *only* that path with a finite outer
@ -357,9 +390,23 @@ impl McpServer {
// other tool dispatches unbounded — they never rendezvous.
let dispatch = self.service.dispatch(&self.project, command);
let result = if name == "idea_ask_agent" {
// Armed beacon : le rendezvous synchrone est désormais borné par le filet
// de sécurité serveur. Si « end » n'apparaît jamais après ce « armed », la
// cible n'a ni appelé `idea_reply` ni atteint son prompt-ready dans le délai.
application::diag!(
"[mcp] ask armed requester={requester_label} target={arg_target} \
task_len={task_len} timeout_ms={}",
self.ask_rendezvous_timeout.as_millis(),
);
match tokio::time::timeout(self.ask_rendezvous_timeout, dispatch).await {
Ok(result) => result,
Err(_elapsed) => {
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",
@ -373,6 +420,8 @@ impl McpServer {
// twin of the file watcher's publish. `ok` mirrors the dispatch outcome; the
// action is the tool name. No-op when no sink is attached.
self.publish_processed(&name, result.is_ok());
// End beacon : longueurs uniquement (jamais le corps), ok/is_error et elapsed —
// ferme la trace ouverte par « begin ».
match result {
Ok(outcome) => {
// The text the agent sees: the reply for an `ask`, else the detail.
@ -380,12 +429,27 @@ impl McpServer {
.reply
.clone()
.unwrap_or_else(|| outcome.detail.clone());
application::diag!(
"[mcp] tools_call end tool={name} requester={requester_label} \
target={arg_target} ok=true is_error=false result_len={} elapsed_ms={}",
text.len(),
started.elapsed().as_millis(),
);
Ok(tool_result_text(&text, false))
}
// A failed IdeA command is reported as a tool execution error
// (`isError: true`) rather than a protocol error: the agent can read
// it and decide, and the connection stays healthy.
Err(e) => Ok(tool_result_text(&e.to_string(), true)),
Err(e) => {
let detail = e.to_string();
application::diag!(
"[mcp] tools_call end tool={name} requester={requester_label} \
target={arg_target} ok=false is_error=true result_len={} elapsed_ms={}",
detail.len(),
started.elapsed().as_millis(),
);
Ok(tool_result_text(&detail, true))
}
}
}

View File

@ -330,7 +330,33 @@ pub fn map_tool_call(
other => return Err(ToolMapError::UnknownTool(other.to_owned())),
};
Ok(request.validate()?)
let command = request.validate()?;
// Diagnostics mapping beacon (best-effort) pour les deux tools du rendezvous :
// `idea_ask_agent` et `idea_reply`. Longueurs et présence de ticket uniquement —
// jamais le corps `task`/`result` ni de secret. Permet de corréler un `[mcp]`
// begin avec la commande effectivement construite (kind/target/requester).
match name {
"idea_ask_agent" => application::diag!(
"[mcp] mapped kind=ask_agent target={} task_len={} requester={}",
s("target").as_deref().unwrap_or("-"),
s("task").map_or(0, |t| t.len()),
if requester.is_empty() { "-" } else { requester },
),
"idea_reply" => application::diag!(
"[mcp] mapped kind=reply result_len={} ticket={} requester={}",
s("result").map_or(0, |r| r.len()),
if s("ticket").is_some() {
"present"
} else {
"absent"
},
if requester.is_empty() { "-" } else { requester },
),
_ => {}
}
Ok(command)
}
/// An all-`None` request to spread over; keeps each arm above to just its fields.