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

@ -17,6 +17,9 @@
//! 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::{
@ -67,6 +70,14 @@ 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")
@ -145,6 +156,106 @@ 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::*;
@ -157,6 +268,28 @@ 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

@ -2750,9 +2750,12 @@ pub(crate) fn compose_convention_file(
out.push_str(
"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 \
**impérativement** l'outil `idea_reply(result=…)` pour rendre ton \
résultat. Ne réponds **jamais** uniquement en texte : seul `idea_reply` \
débloque l'agent qui t'a sollicité.\n\n",
**impérativement** l'outil `idea_reply(ticket=…, result=…)` pour rendre ton \
résultat — **même pour une réponse triviale** (un simple `pong`). Ne réponds \
**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,
// 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::{
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 lifecycle::{