feat(orchestrator): backstop no-reply du rendez-vous inter-agents
Remédiation du wedge persistant après échec live du fix Finding A (77e62e5).
Détecte la fin de tour d'un agent sollicité qui n'a pas appelé idea_reply et
débloque l'agent demandeur au lieu de le laisser en attente indéfinie.
Ajoute le suivi de tour côté inspector Claude (claude_turn_watcher) et la
résolution des chemins de session (claude_paths), câblés dans le rendez-vous
idea_ask_agent ⇄ idea_reply.
Build workspace vert, suite complète verte, zéro warning.
Backstop NON prouvé levé en live : merge develop interdit tant que la levée
du wedge n'est pas validée en conditions réelles.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -17,9 +17,6 @@
|
||||
//! 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::{
|
||||
@ -70,14 +67,6 @@ 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")
|
||||
@ -156,106 +145,6 @@ 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::*;
|
||||
@ -268,28 +157,6 @@ 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"] {
|
||||
|
||||
@ -23,7 +23,7 @@ pub use structured::{
|
||||
};
|
||||
|
||||
pub use catalogue::{
|
||||
overlay_reference_defaults, reference_profile_id, reference_profiles,
|
||||
reference_profile_id, reference_profiles,
|
||||
selectable_reference_profiles, CODEX_SUBMIT_DELAY_MS,
|
||||
};
|
||||
pub use inspect::{InspectConversation, InspectConversationInput, InspectConversationOutput};
|
||||
|
||||
@ -301,11 +301,10 @@ mod tests {
|
||||
AgentBusyState::Idle
|
||||
}
|
||||
fn bind_handle(&self, _agent: AgentId, _handle: PtyHandle) {}
|
||||
fn bind_handle_with_prompt(
|
||||
fn bind_handle_with_submit(
|
||||
&self,
|
||||
_agent: AgentId,
|
||||
_handle: PtyHandle,
|
||||
_pattern: Option<String>,
|
||||
_submit: SubmitConfig,
|
||||
) {
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user