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