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:
@ -39,6 +39,7 @@ use crate::agent::{
|
||||
UpdateAgentContextInput, CODEX_SUBMIT_DELAY_MS,
|
||||
};
|
||||
use crate::error::AppError;
|
||||
use crate::orchestrator::rendezvous::{run_inactivity_watchdog, WatchdogOutcome};
|
||||
use crate::orchestrator::{
|
||||
ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput, ReadMemory,
|
||||
ReadMemoryInput, WriteMemory, WriteMemoryInput,
|
||||
@ -85,6 +86,35 @@ fn submit_config_for_profile(profile: &AgentProfile) -> SubmitConfig {
|
||||
/// ([`resolve_turn_timeout`]).
|
||||
const ASK_AGENT_TIMEOUT: Duration = Duration::from_secs(600);
|
||||
|
||||
/// **Sonde de vivacité** d'une cible de délégation, keyée par son [`AgentId`] : renvoie
|
||||
/// un **jeton d'activité** monotone non-décroissant (en pratique la taille cumulée du
|
||||
/// transcript de la cible). Un jeton strictement plus grand entre deux sondes ⇒ la cible
|
||||
/// **progresse** (vivante et au travail, **même dans un seul long tour** qui n'émet aucun
|
||||
/// événement intermédiaire — cf. [`rendezvous`](crate::orchestrator::rendezvous)).
|
||||
/// `None` ⇒ vivacité non observable (cible/transcript introuvable) ⇒ traité comme
|
||||
/// « pas de progrès ».
|
||||
///
|
||||
/// **Pourquoi un port et pas le flux d'événements** : le flux de
|
||||
/// [`domain::ports::AgentSession::send`] est **batché en fin de tour** (les `ReplyEvent`
|
||||
/// arrivent tous à la fin, pas en continu), donc inutilisable comme signe de vie *pendant*
|
||||
/// un long tour. La seule preuve de vie disponible est cette sonde externe (transcript).
|
||||
/// Closure abstraite (frontière hexagonale) construite à la composition root — la seule
|
||||
/// couche qui sait dériver le run-dir transcript d'un `AgentId` ; l'application reste pure.
|
||||
pub type AskLivenessProbe = Arc<
|
||||
dyn Fn(
|
||||
domain::project::ProjectPath,
|
||||
AgentId,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Option<u64>> + Send>>
|
||||
+ Send
|
||||
+ Sync,
|
||||
>;
|
||||
|
||||
/// Plafond **absolu** par défaut du rendez-vous délégué : la fenêtre d'inactivité
|
||||
/// réarmée ne parque jamais un `ask` au-delà, même contre une cible perpétuellement
|
||||
/// active. Généreux (4 h) pour qu'un tour unique lourd mais réel finisse bien avant.
|
||||
/// Surchargeable via `IDEA_ASK_RENDEZVOUS_CEILING_MS` à la composition root.
|
||||
const ASK_AGENT_CEILING: Duration = crate::orchestrator::rendezvous::DEFAULT_RENDEZVOUS_CEILING;
|
||||
|
||||
/// Borne d'attente **en file** pour acquérir le verrou de tour d'un agent (A0,
|
||||
/// cadrage v5 §4).
|
||||
///
|
||||
@ -362,6 +392,17 @@ pub struct OrchestratorService {
|
||||
/// Injecté via [`Self::with_live_state_read`] ; `None` ⇒ l'outil renvoie une erreur
|
||||
/// typée « not configured » (call sites/tests legacy restent verts).
|
||||
live_state_read: Option<Arc<dyn LiveStateReadProvider>>,
|
||||
/// **Sonde de vivacité** de la cible (signe de vie) pilotant la fenêtre d'inactivité
|
||||
/// du rendez-vous délégué (cf. [`AskLivenessProbe`] et
|
||||
/// [`rendezvous`](crate::orchestrator::rendezvous)). Injectée via
|
||||
/// [`Self::with_ask_liveness_probe`]. `None` ⇒ la borne de tour reste un **timeout
|
||||
/// plat** (fallback fenêtre plate = comportement historique, zéro régression).
|
||||
ask_liveness_probe: Option<AskLivenessProbe>,
|
||||
/// Plafond **absolu** du rendez-vous délégué (cf. [`ASK_AGENT_CEILING`]) : la fenêtre
|
||||
/// d'inactivité réarmée ne parque jamais un `ask` au-delà. Surchargeable via
|
||||
/// [`Self::with_ask_ceiling`] (la composition root résout l'override d'env) ; les
|
||||
/// tests le rétrécissent à quelques ms. Défaut = 4 h.
|
||||
ask_ceiling: Duration,
|
||||
}
|
||||
|
||||
/// Bundle des quatre use cases C7 sous [`domain::fileguard::FileGuard`], injectés
|
||||
@ -429,9 +470,32 @@ impl OrchestratorService {
|
||||
read_skill: None,
|
||||
live_state: None,
|
||||
live_state_read: None,
|
||||
ask_liveness_probe: None,
|
||||
ask_ceiling: ASK_AGENT_CEILING,
|
||||
}
|
||||
}
|
||||
|
||||
/// Branche la **sonde de vivacité** (signe de vie) du rendez-vous délégué : la borne
|
||||
/// de tour devient une **fenêtre d'inactivité** réarmée à chaque progrès observé,
|
||||
/// sous le plafond [`Self::with_ask_ceiling`]. Builder additif (signature de
|
||||
/// [`Self::new`] inchangée) ; sans sonde, la borne reste un timeout plat (fallback,
|
||||
/// zéro régression). Cf. [`AskLivenessProbe`].
|
||||
#[must_use]
|
||||
pub fn with_ask_liveness_probe(mut self, probe: AskLivenessProbe) -> Self {
|
||||
self.ask_liveness_probe = Some(probe);
|
||||
self
|
||||
}
|
||||
|
||||
/// Surcharge le **plafond absolu** du rendez-vous délégué (défaut [`ASK_AGENT_CEILING`]
|
||||
/// = 4 h) au-delà duquel la fenêtre d'inactivité réarmée ne parque jamais un `ask`.
|
||||
/// La composition root y applique l'override d'env ; les tests le rétrécissent.
|
||||
/// Builder additif.
|
||||
#[must_use]
|
||||
pub fn with_ask_ceiling(mut self, ceiling: Duration) -> Self {
|
||||
self.ask_ceiling = ceiling;
|
||||
self
|
||||
}
|
||||
|
||||
/// Branche le use case [`ReadSkill`] (`skill.read`/`idea_skill_read`). Builder
|
||||
/// additif (signature de [`Self::new`] inchangée) ; `None` ⇒ la commande
|
||||
/// renvoie une erreur typée « not configured ».
|
||||
@ -1382,10 +1446,16 @@ impl OrchestratorService {
|
||||
// turn into the bound handle). The service no longer writes the PTY directly —
|
||||
// no ad-hoc `[IdeA · tâche …]` line here, no `\r` band-aid (cadrage C3 §5.1).
|
||||
|
||||
// 3. Attendre la réponse, bornée. Timeout/canal fermé ⇒ le garde retire le ticket
|
||||
// (cible laissée vivante) et la ramène `Idle` au Drop ; renvoie une erreur typée.
|
||||
match tokio::time::timeout(turn_timeout, pending).await {
|
||||
Ok(Ok(TurnResolution::Replied(result))) => {
|
||||
// 3. Attendre la réponse, bornée par la **fenêtre d'inactivité réarmable**
|
||||
// (signe de vie = sonde de transcript) au lieu d'un timeout plat : un long
|
||||
// tour unique qui progresse n'est plus coupé à `turn_timeout`. Vrai silence
|
||||
// ⇒ timeout typé (comme avant) ; plafond atteint malgré progrès ⇒ erreur
|
||||
// typée distincte. Canal fermé ⇒ le garde retire le ticket au Drop.
|
||||
let verdict = self
|
||||
.run_ask_with_watchdog(pending, turn_timeout, &project.root, agent_id, &target, started)
|
||||
.await;
|
||||
match verdict {
|
||||
WatchdogOutcome::Resolved(Ok(TurnResolution::Replied(result))) => {
|
||||
// Checkpoint Response (P6b, best-effort) : persister la réponse AVANT de
|
||||
// déplacer `result` dans `reply_outcome`. Source = la **cible** (c'est
|
||||
// elle qui a rendu le tour) ; même conversation que le Prompt.
|
||||
@ -1424,9 +1494,14 @@ impl OrchestratorService {
|
||||
// déjà retiré (`complete_without_reply`) et la cible déjà `Idle` (prompt-ready
|
||||
// `mark_idle`) ⇒ on **désarme** le garde (pas de `cancel_head`/`mark_idle`
|
||||
// redondant ni de beacon « busy-guard freed » trompeur) et on renvoie une
|
||||
// erreur typée claire/retryable, en ~G au lieu du timeout long.
|
||||
Ok(Ok(TurnResolution::ReturnedToPromptNoReply)) => {
|
||||
// erreur typée claire/retryable, en ~G au lieu du timeout long. On repasse
|
||||
// aussi la live-state de la cible à `Done` (best-effort) : le tour est conclu
|
||||
// sans réponse, donc `idea_workstate_read` ne doit pas la voir `Working`
|
||||
// (busy fantôme) comme sur la branche succès.
|
||||
WatchdogOutcome::Resolved(Ok(TurnResolution::ReturnedToPromptNoReply)) => {
|
||||
busy_guard.disarm();
|
||||
self.mark_target_done_best_effort(&project.root, agent_id, ticket_id)
|
||||
.await;
|
||||
crate::diag!(
|
||||
"[rendezvous] ask returned-to-prompt-no-reply: target={target} \
|
||||
(agent {agent_id}) ticket={ticket_id} after_ms={}",
|
||||
@ -1434,9 +1509,23 @@ impl OrchestratorService {
|
||||
);
|
||||
Err(AppError::TargetReturnedNoReply(target))
|
||||
}
|
||||
// Plafond absolu atteint alors que la cible **progresse encore** (sonde de vie
|
||||
// croissante) : verdict DISTINCT, non un faux timeout muet. Le garde fait
|
||||
// `cancel_head` + `mark_idle` au Drop ; on réconcilie aussi la live-state à
|
||||
// `Done` pour qu'un abandon ne laisse pas un busy fantôme.
|
||||
WatchdogOutcome::CeilingActive => {
|
||||
self.mark_target_done_best_effort(&project.root, agent_id, ticket_id)
|
||||
.await;
|
||||
crate::diag!(
|
||||
"[rendezvous] ask CEILING (still active): target={target} \
|
||||
(agent {agent_id}) ticket={ticket_id} after_ms={}",
|
||||
started.elapsed().as_millis(),
|
||||
);
|
||||
Err(AppError::TargetCeilingActive(target))
|
||||
}
|
||||
// Erreur / timeout : on laisse le garde faire `cancel_head` + `mark_idle` au
|
||||
// Drop (retrait des `cancel_head` redondants — `cancel_head` reste idempotent).
|
||||
Ok(Err(_cancelled)) => {
|
||||
WatchdogOutcome::Resolved(Err(_cancelled)) => {
|
||||
crate::diag!(
|
||||
"[rendezvous] ask channel-closed: target={target} (agent {agent_id}) \
|
||||
ticket={ticket_id} after_ms={}",
|
||||
@ -1446,11 +1535,17 @@ impl OrchestratorService {
|
||||
"agent {target} : canal de réponse fermé avant un résultat"
|
||||
)))
|
||||
}
|
||||
Err(_elapsed) => {
|
||||
WatchdogOutcome::NoReply => {
|
||||
// Vrai silence (aucun progrès sur une fenêtre) : sémantique identique à
|
||||
// l'ancien timeout plat. On réconcilie la live-state à `Done` pour qu'un
|
||||
// abandon ne laisse pas la cible `Working` (busy fantôme) ; le garde fait
|
||||
// `cancel_head` + `mark_idle` au Drop.
|
||||
self.mark_target_done_best_effort(&project.root, agent_id, ticket_id)
|
||||
.await;
|
||||
crate::diag!(
|
||||
"[rendezvous] ask TIMEOUT: target={target} (agent {agent_id}) \
|
||||
ticket={ticket_id} after_ms={} (la cible n'a jamais appelé idea_reply \
|
||||
ni atteint son prompt-ready dans le turn_timeout)",
|
||||
ni atteint son prompt-ready, et aucun progrès observé sur la fenêtre)",
|
||||
started.elapsed().as_millis(),
|
||||
);
|
||||
Err(AppError::from(domain::ports::AgentSessionError::Timeout))
|
||||
@ -1552,74 +1647,90 @@ impl OrchestratorService {
|
||||
turn_timeout.as_millis(),
|
||||
);
|
||||
|
||||
// Drainer le tour structuré (le `Final` ⇒ contenu + `mark_idle`), borné par le
|
||||
// **même** garde-fou que le chemin PTY (profil ou défaut). On attend la
|
||||
// **première** issue : le tour structuré OU un `idea_reply` explicite.
|
||||
let drain =
|
||||
drain_with_readiness(session, &task, Some(turn_timeout), input.as_ref(), agent_id);
|
||||
// Drainer le tour structuré (le `Final` ⇒ contenu + `mark_idle`). Le drain lui-même
|
||||
// est **non borné** (`None`) : la borne de tour est désormais portée par la
|
||||
// **fenêtre d'inactivité réarmable** autour de l'attente (signe de vie = sonde de
|
||||
// transcript), pas par un timeout plat interne — un long tour unique qui progresse
|
||||
// n'est plus coupé à `turn_timeout`. On attend la **première** issue : le tour
|
||||
// structuré OU un `idea_reply` explicite.
|
||||
let drain = drain_with_readiness(session, &task, None, input.as_ref(), agent_id);
|
||||
|
||||
let result = tokio::select! {
|
||||
biased;
|
||||
// Issue déterministe : la session a rendu son `Final`.
|
||||
drained = drain => match drained {
|
||||
Ok(content) => {
|
||||
crate::diag!(
|
||||
"[rendezvous] ask resolved (structured/final): target={target} \
|
||||
(agent {agent_id}) ticket={ticket_id} after_ms={} reply_len={}",
|
||||
started.elapsed().as_millis(),
|
||||
content.len(),
|
||||
);
|
||||
content
|
||||
}
|
||||
// Erreur de drain : le garde fait `cancel_head` + `mark_idle` au Drop.
|
||||
Err(err) => {
|
||||
crate::diag!(
|
||||
"[rendezvous] ask drain-error (structured): target={target} \
|
||||
(agent {agent_id}) ticket={ticket_id} after_ms={} err={err}",
|
||||
started.elapsed().as_millis(),
|
||||
);
|
||||
return Err(AppError::from(err));
|
||||
}
|
||||
},
|
||||
// Issue alternative : un `idea_reply` explicite a résolu le ticket d'abord.
|
||||
replied = pending => match replied {
|
||||
Ok(TurnResolution::Replied(content)) => {
|
||||
// La session draine encore en arrière-plan ; on s'assure que la FIFO
|
||||
// avance même si le `Final` n'est pas encore observé.
|
||||
input.mark_idle(agent_id);
|
||||
crate::diag!(
|
||||
"[rendezvous] ask resolved (structured/reply): target={target} \
|
||||
(agent {agent_id}) ticket={ticket_id} after_ms={} reply_len={}",
|
||||
started.elapsed().as_millis(),
|
||||
content.len(),
|
||||
);
|
||||
content
|
||||
}
|
||||
// Retour au prompt sans `idea_reply` : très rare sur le chemin structuré
|
||||
// (la cible n'a pas de PTY/watcher), mais on traite la résolution comme
|
||||
// sur le chemin PTY — erreur typée claire/retryable, garde désarmé.
|
||||
Ok(TurnResolution::ReturnedToPromptNoReply) => {
|
||||
input.mark_idle(agent_id);
|
||||
busy_guard.disarm();
|
||||
crate::diag!(
|
||||
"[rendezvous] ask returned-to-prompt-no-reply (structured): \
|
||||
target={target} (agent {agent_id}) ticket={ticket_id} after_ms={}",
|
||||
started.elapsed().as_millis(),
|
||||
);
|
||||
return Err(AppError::TargetReturnedNoReply(target.to_owned()));
|
||||
}
|
||||
// Canal fermé : le garde fait `cancel_head` + `mark_idle` au Drop.
|
||||
Err(_cancelled) => {
|
||||
crate::diag!(
|
||||
"[rendezvous] ask channel-closed (structured): target={target} \
|
||||
(agent {agent_id}) ticket={ticket_id} after_ms={}",
|
||||
started.elapsed().as_millis(),
|
||||
);
|
||||
return Err(AppError::Process(format!(
|
||||
// L'attente du rendez-vous (drain OU reply), rendue comme `Result<String, AppError>`
|
||||
// pour être enveloppée par le watchdog (au lieu de `return` directs dans le `select!`).
|
||||
let wait = async {
|
||||
tokio::select! {
|
||||
biased;
|
||||
drained = drain => match drained {
|
||||
Ok(content) => Ok(content),
|
||||
Err(err) => Err(AppError::from(err)),
|
||||
},
|
||||
replied = pending => match replied {
|
||||
Ok(TurnResolution::Replied(content)) => {
|
||||
// La session draine encore en arrière-plan ; on fait avancer la FIFO.
|
||||
input.mark_idle(agent_id);
|
||||
Ok(content)
|
||||
}
|
||||
Ok(TurnResolution::ReturnedToPromptNoReply) => {
|
||||
input.mark_idle(agent_id);
|
||||
Err(AppError::TargetReturnedNoReply(target.to_owned()))
|
||||
}
|
||||
Err(_cancelled) => Err(AppError::Process(format!(
|
||||
"agent {target} : canal de réponse fermé avant un résultat"
|
||||
)));
|
||||
}
|
||||
},
|
||||
))),
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
// Borne par la fenêtre d'inactivité (réarmée sur signe de vie) sous plafond absolu.
|
||||
let result = match self
|
||||
.run_ask_with_watchdog(wait, turn_timeout, &project.root, agent_id, target, started)
|
||||
.await
|
||||
{
|
||||
WatchdogOutcome::Resolved(Ok(content)) => {
|
||||
crate::diag!(
|
||||
"[rendezvous] ask resolved (structured): target={target} \
|
||||
(agent {agent_id}) ticket={ticket_id} after_ms={} reply_len={}",
|
||||
started.elapsed().as_millis(),
|
||||
content.len(),
|
||||
);
|
||||
content
|
||||
}
|
||||
// Erreur de tour (drain-error, no-reply, canal fermé) : le garde fait
|
||||
// `cancel_head` + `mark_idle` au Drop. On réconcilie la live-state à `Done`
|
||||
// pour qu'un abandon ne laisse pas un busy fantôme.
|
||||
WatchdogOutcome::Resolved(Err(err)) => {
|
||||
self.mark_target_done_best_effort(&project.root, agent_id, ticket_id)
|
||||
.await;
|
||||
crate::diag!(
|
||||
"[rendezvous] ask error (structured): target={target} (agent {agent_id}) \
|
||||
ticket={ticket_id} after_ms={} err={err}",
|
||||
started.elapsed().as_millis(),
|
||||
);
|
||||
return Err(err);
|
||||
}
|
||||
// Plafond absolu atteint alors que la cible progresse encore : verdict distinct.
|
||||
WatchdogOutcome::CeilingActive => {
|
||||
self.mark_target_done_best_effort(&project.root, agent_id, ticket_id)
|
||||
.await;
|
||||
crate::diag!(
|
||||
"[rendezvous] ask CEILING (structured, still active): target={target} \
|
||||
(agent {agent_id}) ticket={ticket_id} after_ms={}",
|
||||
started.elapsed().as_millis(),
|
||||
);
|
||||
return Err(AppError::TargetCeilingActive(target.to_owned()));
|
||||
}
|
||||
// Vrai silence (aucun progrès sur la fenêtre) : timeout typé, identique à
|
||||
// l'ancienne borne plate. Live-state réconciliée à `Done`.
|
||||
WatchdogOutcome::NoReply => {
|
||||
self.mark_target_done_best_effort(&project.root, agent_id, ticket_id)
|
||||
.await;
|
||||
crate::diag!(
|
||||
"[rendezvous] ask TIMEOUT (structured): target={target} (agent {agent_id}) \
|
||||
ticket={ticket_id} after_ms={} (aucun progrès observé sur la fenêtre)",
|
||||
started.elapsed().as_millis(),
|
||||
);
|
||||
return Err(AppError::from(domain::ports::AgentSessionError::Timeout));
|
||||
}
|
||||
};
|
||||
|
||||
// Succès : désarmer le garde (le `mark_idle` propre est déjà porté par le `Final`
|
||||
@ -1793,6 +1904,22 @@ impl OrchestratorService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Résout l'`AgentId` d'un agent par son **nom d'affichage** (insensible à la casse)
|
||||
/// dans le manifeste du projet, pour la composition root (qui ne connaît la cible d'un
|
||||
/// `idea_ask_agent` que par son nom). Expose la résolution privée `find_agent_id_by_name`
|
||||
/// afin que le filet serveur MCP puisse dériver le run-dir transcript de la cible (sonde
|
||||
/// d'activité du rendez-vous). `None` si aucun agent ne porte ce nom.
|
||||
///
|
||||
/// # Errors
|
||||
/// Propage l'[`AppError`] du listing des agents (lecture du manifeste).
|
||||
pub async fn resolve_agent_id_by_name(
|
||||
&self,
|
||||
project: &Project,
|
||||
name: &str,
|
||||
) -> Result<Option<domain::AgentId>, AppError> {
|
||||
self.find_agent_id_by_name(project, name).await
|
||||
}
|
||||
|
||||
/// Déclare si une **cellule terminal frontend** est montée pour `agent` (write-portal
|
||||
/// actif). Pont entre le write-portal (adapter UI) et le médiateur : un agent avec
|
||||
/// cellule reçoit ses tours via l'événement `DelegationReady` (le front écrit) ; un
|
||||
@ -2396,6 +2523,77 @@ impl OrchestratorService {
|
||||
resolve_turn_timeout(turn_timeout_ms)
|
||||
}
|
||||
|
||||
/// Borne un `wait` (l'attente synchrone du rendez-vous délégué) par la **fenêtre
|
||||
/// d'inactivité réarmable** (cf. [`rendezvous`](crate::orchestrator::rendezvous)),
|
||||
/// au lieu d'un timeout plat. La fenêtre vaut `window` (profil sinon défaut, la
|
||||
/// **même** valeur que l'ancien timeout plat) ; à chaque expiration de fenêtre, la
|
||||
/// sonde de vivacité de la cible est consultée : progrès ⇒ réarmement jusqu'au
|
||||
/// plafond absolu [`Self::ask_ceiling`]. Verdict :
|
||||
/// - [`WatchdogOutcome::Resolved`] : le `wait` a résolu (succès/erreur de tour) ⇒ rendu tel quel ;
|
||||
/// - [`WatchdogOutcome::NoReply`] : vrai silence (aucun progrès sur une fenêtre) **ou**
|
||||
/// fallback fenêtre plate (pas de sonde câblée) — sémantique **identique** à l'ancien
|
||||
/// timeout de tour (l'appelant mappe vers le timeout typé) ;
|
||||
/// - [`WatchdogOutcome::CeilingActive`] : progrès observé mais plafond atteint ⇒ verdict
|
||||
/// distinct (l'appelant mappe vers [`AppError::TargetCeilingActive`]).
|
||||
async fn run_ask_with_watchdog<R>(
|
||||
&self,
|
||||
wait: impl std::future::Future<Output = R>,
|
||||
window: Duration,
|
||||
project_root: &domain::project::ProjectPath,
|
||||
agent_id: AgentId,
|
||||
target: &str,
|
||||
started: Instant,
|
||||
) -> WatchdogOutcome<R> {
|
||||
let has_probe = self.ask_liveness_probe.is_some();
|
||||
let probe = self.ask_liveness_probe.clone();
|
||||
let root = project_root.clone();
|
||||
let probe_fn = move || {
|
||||
let probe = probe.clone();
|
||||
let root = root.clone();
|
||||
async move {
|
||||
match &probe {
|
||||
Some(p) => p(root, agent_id).await,
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
};
|
||||
let ext_target = target.to_owned();
|
||||
let exp_target = target.to_owned();
|
||||
let ceil_target = target.to_owned();
|
||||
let window_ms = window.as_millis();
|
||||
let ceiling_ms = self.ask_ceiling.as_millis();
|
||||
run_inactivity_watchdog(
|
||||
wait,
|
||||
window,
|
||||
self.ask_ceiling,
|
||||
started,
|
||||
has_probe,
|
||||
probe_fn,
|
||||
move |elapsed| {
|
||||
crate::diag!(
|
||||
"[rendezvous] ask window extended (signe de vie): target={ext_target} \
|
||||
(agent {agent_id}) window_ms={window_ms} elapsed_ms={}",
|
||||
elapsed.as_millis(),
|
||||
);
|
||||
},
|
||||
move |elapsed| {
|
||||
crate::diag!(
|
||||
"[rendezvous] ask EXPIRED (no progress): target={exp_target} \
|
||||
(agent {agent_id}) window_ms={window_ms} elapsed_ms={}",
|
||||
elapsed.as_millis(),
|
||||
);
|
||||
},
|
||||
move |elapsed| {
|
||||
crate::diag!(
|
||||
"[rendezvous] ask CEILING active: target={ceil_target} (agent {agent_id}) \
|
||||
ceiling_ms={ceiling_ms} elapsed_ms={}",
|
||||
elapsed.as_millis(),
|
||||
);
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Resolves a human-friendly profile reference (slug like `claude-code`,
|
||||
/// command like `claude`, or display name like `Claude Code`) to a configured
|
||||
/// [`ProfileId`]. Matching is universal — never hard-coded to one AI — by
|
||||
|
||||
Reference in New Issue
Block a user