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

@ -1103,6 +1103,9 @@ pub async fn stop_live_agent(
) -> Result<StopLiveAgentResponseDto, ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
let agent_id = parse_agent_id(&request.agent_id)?;
// Backstop no-reply : arrêter l'observateur de fin de tour de l'agent (le handle est
// droppé ⇒ polling stoppé) avant de démonter sa session.
state.stop_turn_watch(agent_id);
state
.stop_live_agent
.execute(StopLiveAgentInput { project, agent_id })
@ -1260,6 +1263,9 @@ pub async fn launch_agent(
// Lot LS6 — project root captured before `project` moves, for the off-hot-path log
// rotation triggered at thread (re)open below.
let rotation_root = project.root.clone();
// Backstop no-reply — project root captured before `project` moves, to arm the
// end-of-turn transcript watcher for the launched agent (cf. `AppState::arm_turn_watch`).
let watch_root = project.root.clone();
let output = state
.launch_agent
@ -1288,6 +1294,19 @@ pub async fn launch_agent(
);
}
// Backstop no-reply : armer l'observateur de fin de tour transcript pour la cible si
// son profil est supporté (Claude). Idempotent par remplacement à chaque (re)lancement
// effectif (profil résolu présent) ; no-op sur reattach (profil `None`) — le handle
// posé au 1er lancement persiste. cwd = run dir isolé de l'agent.
if let Some(profile) = output.profile.as_ref() {
state.arm_turn_watch(
&watch_root,
agent_id,
profile,
output.assigned_conversation_id.clone(),
);
}
// Lot LS6 — rotation **best-effort** du log, déclenchée à la (re)ouverture du fil et
// **hors chemin chaud** : détachée (`tokio::spawn`) pour n'ajouter aucune latence au
// launch, erreurs **avalées** (la rotation ne doit jamais casser une reprise). Un

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