merge(orchestrator): intègre le durcissement rendez-vous inter-agents + backstop no-reply (Finding A)

Validation e2e live VERTE sur AppImage 0.3.0 :
- chemin positif (délégation pong → idea_reply → reprise, live-state working→done, aucun Busy fantôme, aucun wedge)
- test adversarial (consigne de ne pas répondre refusée par l'agent ; durcissement Lot 1 robuste).
Filet Lot 2/3 = défense-en-profondeur non exercée. Tests réels verts (application 494, app-tauri 235, infrastructure 248, mcp_server 22/22).

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

View File

@ -58,7 +58,8 @@ use infrastructure::{
FsProjectStore, FsProviderSessionStore, FsSkillStore, FsTemplateStore, Git2Repository, FsProjectStore, FsProviderSessionStore, FsSkillStore, FsTemplateStore, Git2Repository,
HeuristicHandoffSummarizer, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, HeuristicHandoffSummarizer, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall, LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall,
OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, StructuredSessionFactory, OrchestratorWatchHandle, PortablePtyAdapter, ReferenceBackfillProfileStore, RwFileGuard,
StructuredSessionFactory,
SystemClock, SystemMillisClock, TokioBroadcastEventBus, TokioScheduler, UuidGenerator, SystemClock, SystemMillisClock, TokioBroadcastEventBus, TokioScheduler, UuidGenerator,
VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
@ -717,7 +718,14 @@ impl AppState {
Arc::clone(&fs_port), Arc::clone(&fs_port),
app_data_dir.to_string_lossy().into_owned(), app_data_dir.to_string_lossy().into_owned(),
)); ));
let profile_store_port = Arc::clone(&profile_store) as Arc<dyn ProfileStore>; // Merge-on-read overlay (finding A) : envelopper le store concret UNE SEULE FOIS
// ici, avant toute injection, pour que chaque lecteur (use cases + `.list()` directs
// de l'orchestrateur) voie les défauts de référence backfillés (`prompt_ready_pattern`
// mesuré). `save`/`delete`/`is_configured` délèguent verbatim ⇒ persistance et
// détection first-run inchangées.
let profile_store_port: Arc<dyn ProfileStore> = Arc::new(
ReferenceBackfillProfileStore::new(Arc::clone(&profile_store) as Arc<dyn ProfileStore>),
);
let detect_profiles = Arc::new(DetectProfiles::new(Arc::clone(&runtime_port))); let detect_profiles = Arc::new(DetectProfiles::new(Arc::clone(&runtime_port)));
let list_profiles = Arc::new(ListProfiles::new(Arc::clone(&profile_store_port))); let list_profiles = Arc::new(ListProfiles::new(Arc::clone(&profile_store_port)));
@ -1511,10 +1519,19 @@ impl AppState {
service_for_ready.release_agent_cold_start(AgentId::from_uuid(uuid)); service_for_ready.release_agent_cold_start(AgentId::from_uuid(uuid));
} }
}); });
// Borne du rendez-vous `idea_ask_agent` (filet serveur) : réglage projet
// optionnel via `IDEA_ASK_RENDEZVOUS_TIMEOUT_MS`. Absent / invalide / `0` ⇒
// défaut généreux inchangé (24 h), zéro régression sur les tours longs légitimes.
let ask_timeout = infrastructure::resolve_ask_rendezvous_timeout(
std::env::var("IDEA_ASK_RENDEZVOUS_TIMEOUT_MS")
.ok()
.and_then(|v| v.trim().parse::<u32>().ok()),
);
let handle = McpServerHandle::start( let handle = McpServerHandle::start(
McpServer::new(Arc::clone(&self.orchestrator_service), project.clone()) McpServer::new(Arc::clone(&self.orchestrator_service), project.clone())
.with_events(events) .with_events(events)
.with_ready_sink(ready_sink), .with_ready_sink(ready_sink)
.with_ask_rendezvous_timeout(ask_timeout),
endpoint, endpoint,
listener, listener,
project_id, project_id,

View File

@ -17,6 +17,9 @@
//! so re-deriving the catalogue yields the same id for "claude" every time, //! so re-deriving the catalogue yields the same id for "claude" every time,
//! making the reference profiles addressable across runs without a registry. //! making the reference profiles addressable across runs without a registry.
use std::collections::HashMap;
use std::sync::OnceLock;
use domain::ids::ProfileId; use domain::ids::ProfileId;
use domain::permission::ProjectorKey; use domain::permission::ProjectorKey;
use domain::profile::{ use domain::profile::{
@ -67,6 +70,14 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
) )
.expect("claude reference profile is valid") .expect("claude reference profile is valid")
.with_structured_adapter(StructuredAdapter::Claude) .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_projector(ProjectorKey::Claude)
.with_mcp(McpCapability::new( .with_mcp(McpCapability::new(
McpConfigStrategy::config_file(".mcp.json") McpConfigStrategy::config_file(".mcp.json")
@ -145,6 +156,106 @@ pub fn selectable_reference_profiles() -> Vec<AgentProfile> {
.collect() .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)] #[cfg(test)]
mod mcp_tests { mod mcp_tests {
use super::*; use super::*;
@ -157,6 +268,28 @@ mod mcp_tests {
.unwrap_or_else(|| panic!("reference profile `{slug}` exists")) .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] #[test]
fn claude_and_codex_expose_mcp_capability() { fn claude_and_codex_expose_mcp_capability() {
for slug in ["claude", "codex"] { for slug in ["claude", "codex"] {

View File

@ -2750,9 +2750,12 @@ pub(crate) fn compose_convention_file(
out.push_str( out.push_str(
"Quand tu reçois une tâche déléguée par IdeA (un message préfixé \ "Quand tu reçois une tâche déléguée par IdeA (un message préfixé \
`[IdeA · tâche de … · ticket …]`), traite-la puis appelle \ `[IdeA · tâche de … · ticket …]`), traite-la puis appelle \
**impérativement** l'outil `idea_reply(result=…)` pour rendre ton \ **impérativement** l'outil `idea_reply(ticket=…, result=…)` pour rendre ton \
résultat. Ne réponds **jamais** uniquement en texte : seul `idea_reply` \ résultat — **même pour une réponse triviale** (un simple `pong`). Ne réponds \
débloque l'agent qui t'a sollicité.\n\n", **JAMAIS** uniquement en texte/prose dans le terminal : tant que tu n'as pas \
appelé `idea_reply`, l'agent qui t'a sollicité reste **bloqué** et sa réponse \
ne lui parviendra pas. Terminer ton tour sans `idea_reply` est un échec du \
protocole, pas une réponse.\n\n",
); );
// Capacités IdeA (feature skill-awareness) : briefing à HAUTE ALTITUDE, // Capacités IdeA (feature skill-awareness) : briefing à HAUTE ALTITUDE,
// télégraphique, injecté à CHAQUE lancement (coût token → pas d'exemples ni de // télégraphique, injecté à CHAQUE lancement (coût token → pas d'exemples ni de

View File

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

View File

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

View File

@ -875,10 +875,21 @@ impl MediatedInbox {
/// The `[IdeA · tâche de <requester> · ticket <id>]` header is the protocol signal /// 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 /// (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 /// 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 /// correlation — rather than as a free-text human prompt.
/// the next line so the agent reads the request unchanged. ///
/// 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 { 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( fn write_delegation_chunks(
@ -1425,8 +1436,14 @@ mod tests {
inbox.enqueue(a, ticket(10, &task)); 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!( 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" "long headless delivery writes multiple chunks followed by submit"
); );
let writes = pty.writes.lock().unwrap().clone(); 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 input::{MediatedInbox, MillisClock, SystemMillisClock};
pub use inspector::ClaudeTranscriptInspector; pub use inspector::ClaudeTranscriptInspector;
pub use mailbox::InMemoryMailbox; 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::{ pub use orchestrator::{
process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle, process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle,
REQUESTS_SUBDIR, REQUESTS_SUBDIR,
@ -75,6 +77,7 @@ pub use store::{
AdaptiveMemoryRecall, EmbedderEnvProbe, FsEmbedderProfileStore, FsEmbedderPromptStore, AdaptiveMemoryRecall, EmbedderEnvProbe, FsEmbedderProfileStore, FsEmbedderPromptStore,
FsLiveStateStore, FsMemoryStore, FsPermissionStore, FsProfileStore, FsProjectStore, FsLiveStateStore, FsMemoryStore, FsPermissionStore, FsProfileStore, FsProjectStore,
FsSkillStore, FsTemplateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall, 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, 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; pub const INVALID_PARAMS: i32 = -32_602;
/// Internal server error (a dispatched IdeA command failed). /// Internal server error (a dispatched IdeA command failed).
pub const INTERNAL_ERROR: i32 = -32_603; 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. /// Errors a [`Transport`] may surface.

View File

@ -36,6 +36,6 @@ pub mod transport;
pub use jsonrpc::{ pub use jsonrpc::{
JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError, JSONRPC_VERSION, 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 tools::{catalogue, map_tool_call, tool_returns_reply, ToolDef, ToolMapError};
pub use transport::{MemoryTransport, StdioTransport}; pub use transport::{MemoryTransport, StdioTransport};

View File

@ -46,6 +46,20 @@ const MCP_PROTOCOL_VERSION: &str = "2024-11-05";
/// untouched — they don't rendezvous. /// untouched — they don't rendezvous.
const ASK_RENDEZVOUS_TIMEOUT: Duration = Duration::from_secs(24 * 60 * 60); 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`]. /// The IdeA MCP server: an entry adapter over [`OrchestratorService::dispatch`].
/// ///
/// Cheap to clone the dependencies it holds; one instance serves one project's /// 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 /// Overrides the `idea_ask_agent` rendezvous safety-net bound (default
/// (see [`ASK_RENDEZVOUS_TIMEOUT`]) so a wedged-target test need not wait the /// [`ASK_RENDEZVOUS_TIMEOUT`]).
/// full production timeout. Doc-hidden so it does not widen the documented public ///
/// surface; production code never calls it. /// A genuine **configuration knob** (project/profile level, modelled on the
#[doc(hidden)] /// 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] #[must_use]
pub fn with_ask_rendezvous_timeout(mut self, timeout: Duration) -> Self { pub fn with_ask_rendezvous_timeout(mut self, timeout: Duration) -> Self {
self.ask_rendezvous_timeout = timeout; self.ask_rendezvous_timeout = timeout;
@ -401,16 +419,20 @@ impl McpServer {
match tokio::time::timeout(self.ask_rendezvous_timeout, dispatch).await { match tokio::time::timeout(self.ask_rendezvous_timeout, dispatch).await {
Ok(result) => result, Ok(result) => result,
Err(_elapsed) => { 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!( application::diag!(
"[mcp] ask EXPIRED requester={requester_label} target={arg_target} \ "[mcp] ask EXPIRED requester={requester_label} target={arg_target} \
timeout_ms={} elapsed_ms={}", timeout_ms={} elapsed_ms={}",
self.ask_rendezvous_timeout.as_millis(), self.ask_rendezvous_timeout.as_millis(),
started.elapsed().as_millis(), started.elapsed().as_millis(),
); );
return Err(JsonRpcError::new( // Filet serveur : ne plus renvoyer un INTERNAL_ERROR opaque, mais la même
error_codes::INTERNAL_ERROR, // sémantique typée et **retryable** que `AppError::TargetReturnedNoReply`,
"idea_ask_agent : aucune réponse de la cible avant le timeout du rendezvous", // sous un code JSON-RPC DISTINCT (RENDEZVOUS_NO_REPLY) + `data` structuré.
)); return Err(rendezvous_no_reply_error(&arg_target));
} }
} }
} else { } 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. /// Maps a [`ToolMapError`] to the right JSON-RPC error code.
fn map_err_to_jsonrpc(err: ToolMapError) -> JsonRpcError { fn map_err_to_jsonrpc(err: ToolMapError) -> JsonRpcError {
match err { match err {

View File

@ -28,7 +28,7 @@ pub use embedder::{
pub use live_state::FsLiveStateStore; pub use live_state::FsLiveStateStore;
pub use memory::{index_token_size, FsMemoryStore, NaiveMemoryRecall}; pub use memory::{index_token_size, FsMemoryStore, NaiveMemoryRecall};
pub use permission::FsPermissionStore; pub use permission::FsPermissionStore;
pub use profile::{FsEmbedderProfileStore, FsProfileStore}; pub use profile::{FsEmbedderProfileStore, FsProfileStore, ReferenceBackfillProfileStore};
pub use project::FsProjectStore; pub use project::FsProjectStore;
pub use skill::FsSkillStore; pub use skill::FsSkillStore;
pub use template::FsTemplateStore; 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). // FsEmbedderProfileStore — declarative embedder profiles (`embedder.json`, LOT C).
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -286,3 +339,88 @@ impl EmbedderProfileStore for FsEmbedderProfileStore {
FsEmbedderProfileStore::delete(self, id).await 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);
}
}

View File

@ -1247,11 +1247,25 @@ async fn ask_agent_rendezvous_times_out_with_a_jsonrpc_error() {
.expect("reply owed"); .expect("reply owed");
let error = response.error.expect("a JSON-RPC error on timeout"); let error = response.error.expect("a JSON-RPC error on timeout");
assert_eq!(error.code, error_codes::INTERNAL_ERROR, "got {error:?}"); // Filet serveur : code DISTINCT de l'INTERNAL_ERROR opaque, sémantique typée et
assert!( // retryable (miroir de `AppError::TargetReturnedNoReply`).
error.message.contains("timeout"), assert_eq!(
"explicit timeout message, got {error:?}" error.code,
error_codes::RENDEZVOUS_NO_REPLY,
"rendezvous timeout maps to the distinct typed code, got {error:?}"
); );
assert_ne!(
error.code,
error_codes::INTERNAL_ERROR,
"must NOT be the opaque internal error"
);
assert!(
error.message.contains("idea_reply"),
"message must steer the caller back to idea_reply, got {error:?}"
);
let data = error.data.expect("typed data on the rendezvous error");
assert_eq!(data["code"], "TARGET_RETURNED_NO_REPLY", "got {data:?}");
assert_eq!(data["retryable"], true, "got {data:?}");
assert!(response.result.is_none(), "error responses carry no result"); assert!(response.result.is_none(), "error responses carry no result");
} }