feat(orchestrator): watchdog d'inactivité réarmable + plafond du rendez-vous inter-agents

Redessine la borne de fin de tour du rendez-vous `idea_ask_agent` ⇄ `idea_reply` :
au lieu d'un timeout plat (qui coupait à 600 s un seul long tour de la cible, cf.
T7), la borne devient une **fenêtre d'inactivité réarmée** à chaque progrès observé
de la cible, sous un **plafond absolu** (défaut 4 h, réglable via
`IDEA_ASK_RENDEZVOUS_CEILING_MS`).

- Sonde d'activité (`transcript_activity_token`, inspector) : jeton monotone =
  octets cumulés des `.jsonl` de la cible. Croît même pendant un seul long tour
  sans `turn_duration` ⇒ détecte « vivant et au travail » mi-tour. Best-effort,
  sans effet de bord ; folder absent/illisible ⇒ « pas de progrès ».
- Watchdog (`run_inactivity_watchdog`, nouveau module `orchestrator/rendezvous`) :
  fenêtre réarmable + plafond, fallback timeout plat si aucune sonde (zéro régression).
- Issue typée distincte `TargetCeilingActive` (code `RENDEZVOUS_CEILING_ACTIVE`) :
  une cible **active** stoppée au plafond n'est jamais confondue avec un
  `TargetReturnedNoReply` (silence) ; le message guide « ne pas retenter à l'aveugle ».
- Câblage composition-root (`state.rs`) : sonde résolue nom→AgentId→run-dir transcript,
  branchée sur le service et sur l'McpServer (`AskActivityProbe`, `with_ask_ceiling`).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 13:32:19 +02:00
parent 1efe2f11dc
commit 6050a6da5f
12 changed files with 1036 additions and 140 deletions

View File

@ -617,6 +617,13 @@ pub struct AppState {
/// **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>>>,
/// Port `FileSystem` partagé, conservé pour bâtir la **sonde d'activité** du rendez-vous
/// `idea_ask_agent` (octets cumulés des transcripts de la cible). Même port que
/// l'inspecteur et le turn-watcher.
pub fs_port: Arc<dyn FileSystem>,
/// Répertoire home (`$HOME`) servant à dériver `<home>/.claude/projects/<encoded-run-dir>`
/// pour la sonde d'activité du rendez-vous.
pub home_dir: String,
}
impl AppState {
@ -1028,7 +1035,7 @@ impl AppState {
// — 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),
infrastructure::ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs_port), home_dir.clone()),
);
let inspect_conversation = Arc::new(InspectConversation::new(
Arc::clone(&contexts_port),
@ -1383,7 +1390,39 @@ impl AppState {
// sa racine à la construction). Sans ça, l'outil renverrait « not configured ».
.with_live_state_read(Arc::new(AppLiveStateLeanProvider {
clock: Arc::clone(&clock) as Arc<dyn Clock>,
}) as Arc<dyn LiveStateReadProvider>),
}) as Arc<dyn LiveStateReadProvider>)
// Fenêtre d'inactivité réarmable (signe de vie) du rendez-vous délégué : la
// borne de tour dans le service n'est plus un timeout plat mais une fenêtre
// réarmée à chaque progrès observé de la cible (octets cumulés de son
// transcript Claude), sous le plafond ci-dessous. SANS cette sonde, la borne
// dégrade vers un timeout plat (fallback, zéro régression) et coupe un long
// tour unique à 600 s — c'est précisément la sonde qui rend le fix effectif.
// Keyée par (project_root, agent_id) : le run-dir transcript = `<root>/.ideai/
// run/<agent_id>`, encodé par Claude sous `<home>/.claude/projects/...`.
.with_ask_liveness_probe({
let fs = Arc::clone(&fs_port);
let home = home_dir.clone();
Arc::new(move |root: domain::project::ProjectPath, agent_id| {
let fs = Arc::clone(&fs);
let home = home.clone();
Box::pin(async move {
let run_dir = format!(
"{}/.ideai/run/{agent_id}",
root.as_str().trim_end_matches(['/', '\\'])
);
let cwd = domain::project::ProjectPath::new(run_dir).ok()?;
infrastructure::transcript_activity_token(fs.as_ref(), &home, &cwd).await
}) as std::pin::Pin<Box<dyn std::future::Future<Output = Option<u64>> + Send>>
}) as application::AskLivenessProbe
})
// Plafond absolu du rendez-vous délégué (réglage projet via
// `IDEA_ASK_RENDEZVOUS_CEILING_MS`, défaut 4 h) : la fenêtre réarmée ne parque
// jamais un `ask` au-delà, même contre une cible perpétuellement active.
.with_ask_ceiling(application::resolve_rendezvous_ceiling(
std::env::var("IDEA_ASK_RENDEZVOUS_CEILING_MS")
.ok()
.and_then(|v| v.trim().parse::<u32>().ok()),
)),
// NB (régression corrigée) : on ne câble PAS `.with_structured(...)` ici.
// Décision produit lot B-2 (« Option 1 Terminal + MCP », cf. construction
// de `LaunchAgent` plus haut) : la fabrique structurée est décâblée, donc
@ -1502,6 +1541,8 @@ impl AppState {
turn_watcher,
turn_watch_input: Arc::clone(&input_mediator),
turn_watch_handles: Mutex::new(HashMap::new()),
fs_port: Arc::clone(&fs_port),
home_dir,
move_tab,
}
}
@ -1595,11 +1636,50 @@ impl AppState {
.ok()
.and_then(|v| v.trim().parse::<u32>().ok()),
);
// Plafond absolu du rendez-vous (réglage projet optionnel via
// `IDEA_ASK_RENDEZVOUS_CEILING_MS`, défaut 4 h) : borne dure que la fenêtre
// d'inactivité réarmée ne dépasse jamais, même contre une cible perpétuellement active.
let ask_ceiling = infrastructure::resolve_ask_rendezvous_ceiling(
std::env::var("IDEA_ASK_RENDEZVOUS_CEILING_MS")
.ok()
.and_then(|v| v.trim().parse::<u32>().ok()),
);
// Sonde d'activité de la cible (signe de vie du rendez-vous) : l'McpServer (infra) ne
// connaît la cible que par son NOM ; la composition root est la seule à savoir
// résoudre nom→AgentId puis dériver le run-dir transcript Claude. La sonde renvoie un
// jeton monotone = octets cumulés des `.jsonl` de la cible (croît même pendant un seul
// long tour sans `turn_duration`). `None` ⇒ cible/transcript introuvable ⇒ traité comme
// « pas de progrès » par le watchdog.
let service_for_probe = Arc::clone(&self.orchestrator_service);
let fs_for_probe = Arc::clone(&self.fs_port);
let home_for_probe = self.home_dir.clone();
let project_for_probe = project.clone();
let activity_probe: infrastructure::AskActivityProbe = Arc::new(move |target: String| {
let service = Arc::clone(&service_for_probe);
let fs = Arc::clone(&fs_for_probe);
let home = home_for_probe.clone();
let project = project_for_probe.clone();
Box::pin(async move {
let agent_id = service
.resolve_agent_id_by_name(&project, &target)
.await
.ok()
.flatten()?;
let run_dir = format!(
"{}/.ideai/run/{agent_id}",
project.root.as_str().trim_end_matches(['/', '\\'])
);
let cwd = domain::project::ProjectPath::new(run_dir).ok()?;
infrastructure::transcript_activity_token(fs.as_ref(), &home, &cwd).await
})
});
let handle = McpServerHandle::start(
McpServer::new(Arc::clone(&self.orchestrator_service), project.clone())
.with_events(events)
.with_ready_sink(ready_sink)
.with_ask_rendezvous_timeout(ask_timeout),
.with_ask_rendezvous_timeout(ask_timeout)
.with_ask_rendezvous_ceiling(ask_ceiling)
.with_activity_probe(activity_probe),
endpoint,
listener,
project_id,