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:
@ -69,6 +69,21 @@ pub enum AppError {
|
||||
)]
|
||||
TargetReturnedNoReply(String),
|
||||
|
||||
/// The delegation target is **still actively working** (its transcript kept
|
||||
/// growing across liveness probes) but the rendezvous **absolute ceiling** was
|
||||
/// reached, so the synchronous wait was abandoned. **Distinct** from
|
||||
/// [`Self::TargetReturnedNoReply`]: the target is *not* silent, so a blind retry
|
||||
/// would stack a second heavy turn on a target already mid-task. **Not** a mute
|
||||
/// timeout — the caller should check the target's in-progress work/branch (it may
|
||||
/// still finish and call `idea_reply`) before re-soliciting lightly. Carries the
|
||||
/// target's display name.
|
||||
#[error(
|
||||
"target {0} is still actively working but the rendezvous ceiling was reached; \
|
||||
do not retry blindly — check the target's in-progress work/branch (it may still \
|
||||
finish and call idea_reply), then re-solicit lightly if needed"
|
||||
)]
|
||||
TargetCeilingActive(String),
|
||||
|
||||
/// An unexpected internal error.
|
||||
#[error("internal error: {0}")]
|
||||
Internal(String),
|
||||
@ -89,6 +104,7 @@ impl AppError {
|
||||
Self::Remote(_) => "REMOTE",
|
||||
Self::AgentAlreadyRunning { .. } => "AGENT_ALREADY_RUNNING",
|
||||
Self::TargetReturnedNoReply(_) => "TARGET_RETURNED_NO_REPLY",
|
||||
Self::TargetCeilingActive(_) => "RENDEZVOUS_CEILING_ACTIVE",
|
||||
Self::Internal(_) => "INTERNAL",
|
||||
}
|
||||
}
|
||||
@ -173,3 +189,31 @@ impl From<AgentSessionError> for AppError {
|
||||
Self::Process(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// La cible-active-au-plafond porte un code **stable et distinct** du no-reply
|
||||
/// (l'appelant peut brancher sans parser le message) et un message « ne retente
|
||||
/// pas à l'aveugle » différent du retour-au-prompt-sans-reply.
|
||||
#[test]
|
||||
fn ceiling_active_code_is_stable_and_distinct() {
|
||||
let ceiling = AppError::TargetCeilingActive("architect".to_owned());
|
||||
assert_eq!(ceiling.code(), "RENDEZVOUS_CEILING_ACTIVE");
|
||||
|
||||
let no_reply = AppError::TargetReturnedNoReply("architect".to_owned());
|
||||
assert_eq!(no_reply.code(), "TARGET_RETURNED_NO_REPLY");
|
||||
|
||||
// Deux issues TYPÉES distinctes (le cœur du cadrage Architect) : un plafond
|
||||
// atteint sur cible active n'est jamais confondu avec un silence/no-reply.
|
||||
assert_ne!(ceiling.code(), no_reply.code());
|
||||
// Et distinct d'un timeout muet (`Process` via AgentSessionError::Timeout).
|
||||
assert_ne!(
|
||||
ceiling.code(),
|
||||
AppError::from(AgentSessionError::Timeout).code()
|
||||
);
|
||||
// Le message guide explicitement vers « ne pas retenter à l'aveugle ».
|
||||
assert!(ceiling.to_string().contains("do not retry blindly"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user