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:
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user