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:
2026-06-23 15:51:59 +02:00
parent 2ef5628c72
commit 744de20f4b
20 changed files with 867 additions and 1093 deletions

View File

@ -58,7 +58,7 @@ use infrastructure::{
FsProjectStore, FsProviderSessionStore, FsSkillStore, FsTemplateStore, Git2Repository,
HeuristicHandoffSummarizer, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall,
OrchestratorWatchHandle, PortablePtyAdapter, ReferenceBackfillProfileStore, RwFileGuard,
OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard,
StructuredSessionFactory,
SystemClock, SystemMillisClock, TokioBroadcastEventBus, TokioScheduler, UuidGenerator,
VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
@ -564,6 +564,17 @@ pub struct AppState {
/// la commande `launch_agent` y dépose le `Project`/taille du dernier lancement pour
/// que la reprise auto puisse recomposer un `LaunchAgentInput` complet.
pub resume_contexts: ResumeContexts,
/// Observateur de **fin de tour** (backstop no-reply) : lit le transcript on-disk de
/// l'agent (Claude `turn_duration`) et appelle `InputMediator::turn_ended`. Remplace
/// le watcher prompt-ready PTY mort. Armé par agent supporté au lancement.
pub turn_watcher: Arc<dyn domain::ports::TurnWatcher>,
/// Médiateur d'entrée partagé, capturé pour câbler le callback `turn_ended` du
/// [`turn_watcher`](Self::turn_watcher) à l'armement.
pub turn_watch_input: Arc<dyn domain::input::InputMediator>,
/// Handles des watches de fin-de-tour vivants, par agent. (Re)lancer un agent
/// **remplace** son handle (l'ancien est droppé ⇒ polling arrêté) ; fermer/arrêter
/// l'agent le retire. `Mutex` car launch/stop y accèdent concurremment.
pub turn_watch_handles: Mutex<HashMap<AgentId, Box<dyn domain::ports::TurnWatchHandle>>>,
}
impl AppState {
@ -718,14 +729,8 @@ impl AppState {
Arc::clone(&fs_port),
app_data_dir.to_string_lossy().into_owned(),
));
// 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 profile_store_port: Arc<dyn ProfileStore> =
Arc::clone(&profile_store) as Arc<dyn ProfileStore>;
let detect_profiles = Arc::new(DetectProfiles::new(Arc::clone(&runtime_port)));
let list_profiles = Arc::new(ListProfiles::new(Arc::clone(&profile_store_port)));
@ -975,8 +980,14 @@ impl AppState {
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_default();
let inspectors: Vec<Arc<dyn domain::ports::SessionInspector>> = vec![Arc::new(
ClaudeTranscriptInspector::new(Arc::clone(&fs_port), home_dir),
ClaudeTranscriptInspector::new(Arc::clone(&fs_port), home_dir.clone()),
)];
// Backstop no-reply : observateur de fin de tour transcript (Claude `turn_duration`)
// — lit le même `<home>/.claude/projects/<encoded-run-dir>/` que l'inspecteur, via
// le même `FileSystem`. Armé par agent supporté au lancement (cf. `arm_turn_watch`).
let turn_watcher: Arc<dyn domain::ports::TurnWatcher> = Arc::new(
infrastructure::ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs_port), home_dir),
);
let inspect_conversation = Arc::new(InspectConversation::new(
Arc::clone(&contexts_port),
Arc::clone(&profile_store_port),
@ -1435,6 +1446,9 @@ impl AppState {
mcp_servers: Mutex::new(HashMap::new()),
session_limit_service,
resume_contexts,
turn_watcher,
turn_watch_input: Arc::clone(&input_mediator),
turn_watch_handles: Mutex::new(HashMap::new()),
move_tab,
}
}
@ -1519,9 +1533,10 @@ impl AppState {
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.
// Borne du rendez-vous `idea_ask_agent` (filet serveur, plancher universel FINI) :
// réglage projet optionnel via `IDEA_ASK_RENDEZVOUS_TIMEOUT_MS`. Absent / invalide /
// `0` ⇒ défaut fini (600 s) — jamais (quasi-)infini, sinon une cible silencieuse
// wedge l'appelant.
let ask_timeout = infrastructure::resolve_ask_rendezvous_timeout(
std::env::var("IDEA_ASK_RENDEZVOUS_TIMEOUT_MS")
.ok()
@ -1563,6 +1578,52 @@ impl AppState {
self.migrate_claude_run_dirs(project).await;
}
/// Arms the **end-of-turn watcher** (no-reply backstop) for `agent_id` if its
/// `profile` is supported (Claude). The watcher tails the agent's isolated run-dir
/// transcript folder (`<root>/.ideai/run/<agent>`) and routes each detected turn end
/// to [`InputMediator::turn_ended`](domain::input::InputMediator::turn_ended). Called
/// at launch; **idempotent by replacement** — a relaunch drops the previous handle
/// (stopping its polling task) and arms a fresh one. A non-supported profile is a
/// no-op. `conversation_id` is diagnostic only.
pub fn arm_turn_watch(
&self,
project_root: &domain::project::ProjectPath,
agent_id: AgentId,
profile: &AgentProfile,
conversation_id: Option<String>,
) {
if !self.turn_watcher.supports(profile) {
return;
}
// cwd = the agent's isolated run dir (matches the profile `{agentRunDir}` cwd that
// Claude runs in, hence the `<home>/.claude/projects/<encoded-run-dir>/` folder).
let run_dir = format!(
"{}/.ideai/run/{agent_id}",
project_root.as_str().trim_end_matches(['/', '\\'])
);
let Ok(cwd) = domain::project::ProjectPath::new(run_dir) else {
return;
};
let input = Arc::clone(&self.turn_watch_input);
// Callback invoked from the watcher's polling task (no lock held here).
let on_turn_end: domain::ports::OnTurnEnd = Arc::new(move |a| input.turn_ended(a));
let handle = self
.turn_watcher
.watch(agent_id, conversation_id, cwd, on_turn_end);
if let Ok(mut map) = self.turn_watch_handles.lock() {
// Insert replaces (and drops) any prior handle ⇒ its polling task stops.
map.insert(agent_id, handle);
}
}
/// Stops and removes the end-of-turn watcher of `agent_id` (close / stop). Dropping
/// the stored handle stops its polling task. No-op if none is armed.
pub fn stop_turn_watch(&self, agent_id: AgentId) {
if let Ok(mut map) = self.turn_watch_handles.lock() {
map.remove(&agent_id);
}
}
/// Stops and removes the orchestrator watcher for `project_id`, if any.
/// Called from `close_project` so a closed project stops consuming requests.
/// Symmetrically stops the project's MCP server (its twin) so both entry doors