feat(agents): pont Codex inter-agents + readiness/heartbeat lot 1
Deux chantiers livrés au vert (workspace entier : domain+application+
infrastructure 42 + app-tauri --lib 128, 0 échec).
## Codex inter-agents
- domaine: McpConfigStrategy::TomlConfigHome { target, home_env } +
toml_config_home(...); AgentProfile::materializes_idea_bridge()
(whitelist Claude/ConfigFile + Codex/TomlConfigHome); McpServerWiring
+ encodeur TOML.
- application: lifecycle apply_mcp_config bras TomlConfigHome (écrit
{runDir}/<target>, pousse (home_env, parent) dans spec.env);
guard_mcp_bridge_supported ré-exprimée via materializes_idea_bridge();
catalogue Codex porte toml_config_home(".codex/config.toml","CODEX_HOME").
- app-tauri: is_codex_mcp_profile, migrate_codex_run_dir,
mcp_server_entry_toml.
- tests: matrice domaine TomlConfigHome + round-trip dual Claude/Codex
sur loopback réel (fakes, zéro token).
## Readiness/heartbeat lot 1
- domaine: readiness.rs — ReadinessPolicy::classify (Final => TurnEnded),
variantes ReplyEvent::Heartbeat / ToolActivity.
- application: drain_with_readiness consulte la policy et appelle
mark_idle sur le signal déterministe; branché dans ask_agent.
Corrige la cause racine: une cible qui ne renvoie qu'un Final (sans
idea_reply) débloque désormais sa file Busy.
- infrastructure: adapters de session émettent Heartbeat/ToolActivity.
- tests: drain_with_readiness_lot1 (points QA 5 & 6) verts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -32,8 +32,9 @@ use domain::{
|
||||
use crate::conversation::RecordTurn;
|
||||
|
||||
use crate::agent::{
|
||||
CreateAgentFromScratch, CreateAgentInput, LaunchAgent, LaunchAgentInput, ListAgents,
|
||||
ListAgentsInput, McpRuntime, ReattachDecision, UpdateAgentContext, UpdateAgentContextInput,
|
||||
drain_with_readiness, CreateAgentFromScratch, CreateAgentInput, LaunchAgent, LaunchAgentInput,
|
||||
ListAgents, ListAgentsInput, McpRuntime, ReattachDecision, UpdateAgentContext,
|
||||
UpdateAgentContextInput,
|
||||
};
|
||||
use crate::error::AppError;
|
||||
use crate::orchestrator::{
|
||||
@ -41,7 +42,7 @@ use crate::orchestrator::{
|
||||
ReadMemoryInput, WriteMemory, WriteMemoryInput,
|
||||
};
|
||||
use crate::skill::{CreateSkill, CreateSkillInput};
|
||||
use crate::terminal::{CloseTerminal, CloseTerminalInput, TerminalSessions};
|
||||
use crate::terminal::{CloseTerminal, CloseTerminalInput, StructuredSessions, TerminalSessions};
|
||||
|
||||
/// Default terminal geometry for an orchestrator-launched agent cell. The UI
|
||||
/// resizes the PTY to the real cell size on attach; these are sane starting rows
|
||||
@ -57,7 +58,13 @@ const DEFAULT_COLS: u16 = 80;
|
||||
/// **without killing the session**, so the requester can retry. Internal and
|
||||
/// intentionally not yet config-exposed (it may become a per-project setting
|
||||
/// without changing the contract).
|
||||
const ASK_AGENT_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
///
|
||||
/// TEMPORAIRE (demande utilisateur 2026-06-13) : la borne de 300 s coupait des tours
|
||||
/// délégués légitimement très longs (gros refactors + tests). On la relève donc à 24 h
|
||||
/// (≈ « pas de limite ») le temps de concevoir un vrai signal de vivacité (savoir si
|
||||
/// l'agent travaille encore) qui remplacera ce timeout brut. NE PAS considérer comme
|
||||
/// définitif : à reconvertir en borne courte + heartbeat once that liveness signal exists.
|
||||
const ASK_AGENT_TIMEOUT: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
|
||||
/// Borne d'attente **en file** pour acquérir le verrou de tour d'un agent (A0,
|
||||
/// cadrage v5 §4).
|
||||
@ -72,7 +79,20 @@ const ASK_AGENT_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
/// [`domain::ports::AgentSessionError::Timeout`]) ; le tour en cours n'est pas
|
||||
/// affecté. Largeur = un tour complet + sa propre file ⇒ on autorise deux tours
|
||||
/// pleins d'attente.
|
||||
const ASK_QUEUE_WAIT_CAP: Duration = Duration::from_secs(600);
|
||||
///
|
||||
/// TEMPORAIRE (cf. [`ASK_AGENT_TIMEOUT`]) : relevé à 48 h (≈ deux tours « sans limite »)
|
||||
/// en cohérence avec la levée temporaire de la borne de tour, le temps d'un vrai
|
||||
/// signal de vivacité.
|
||||
const ASK_QUEUE_WAIT_CAP: Duration = Duration::from_secs(48 * 60 * 60);
|
||||
|
||||
/// Borne de tour effective (lot 2, timeouts pilotés par profil) : le
|
||||
/// `turn_timeout_ms` du profil de la cible **quand il existe**, sinon le défaut
|
||||
/// historique [`ASK_AGENT_TIMEOUT`] (zéro régression pour un profil sans `liveness` /
|
||||
/// sans `turn_timeout_ms`). Pure et testable sans wiring de service.
|
||||
#[must_use]
|
||||
fn resolve_turn_timeout(turn_timeout_ms: Option<u32>) -> Duration {
|
||||
turn_timeout_ms.map_or(ASK_AGENT_TIMEOUT, |ms| Duration::from_millis(u64::from(ms)))
|
||||
}
|
||||
|
||||
/// Fournit les faits OS/runtime (exe + endpoint projet) pour écrire la déclaration MCP
|
||||
/// réelle quand l'orchestrateur (re)lance une cible sur le chemin `ask`. Implémenté dans
|
||||
@ -182,6 +202,15 @@ pub struct OrchestratorService {
|
||||
/// injectée avec [`Self::record_turn`]. La couche `application` reste **pure** : pas
|
||||
/// de `SystemTime::now()` brut ici, le temps vient du port injecté au composition root.
|
||||
clock: Option<Arc<dyn Clock>>,
|
||||
/// Registre des **sessions IA structurées** vivantes (§17.5), pour router un `ask`
|
||||
/// vers une cible à `structured_adapter` directement sur sa session
|
||||
/// ([`drain_with_readiness`](crate::agent::drain_with_readiness)) — le `Final`
|
||||
/// déterministe réveille le `pending` **et** marque l'agent `Idle` (chantier
|
||||
/// readiness/heartbeat, lot 1, fix du blocage `Busy`). Injecté via
|
||||
/// [`Self::with_structured`] ; `None` ⇒ on conserve **uniquement** le chemin
|
||||
/// PTY/mailbox legacy (zéro régression pour les call sites/tests qui ne le branchent
|
||||
/// pas).
|
||||
structured: Option<Arc<StructuredSessions>>,
|
||||
}
|
||||
|
||||
/// Bundle des quatre use cases C7 sous [`domain::fileguard::FileGuard`], injectés
|
||||
@ -244,9 +273,19 @@ impl OrchestratorService {
|
||||
context_guard: None,
|
||||
record_turn: None,
|
||||
clock: None,
|
||||
structured: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Branche le registre des [`StructuredSessions`] (§17.5) pour router un `ask`
|
||||
/// vers une cible **structurée** sur sa propre session. Builder additif (signature
|
||||
/// de [`Self::new`] inchangée) ; `None` ⇒ chemin PTY/mailbox legacy uniquement.
|
||||
#[must_use]
|
||||
pub fn with_structured(mut self, structured: Arc<StructuredSessions>) -> Self {
|
||||
self.structured = Some(structured);
|
||||
self
|
||||
}
|
||||
|
||||
/// Branche les use cases C7 (`context.*`/`memory.*`) sous
|
||||
/// [`domain::fileguard::FileGuard`]. Builder additif (signature de [`Self::new`]
|
||||
/// inchangée).
|
||||
@ -750,6 +789,42 @@ impl OrchestratorService {
|
||||
// Poser l'arête d'attente A→B (retirée en fin de tour par le RAII `_edge`).
|
||||
let _edge = requester.map(|from| WaitEdgeGuard::new(self, from, agent_id));
|
||||
|
||||
// ── Chemin **structuré** (readiness/heartbeat lot 1) ──────────────────────
|
||||
// Une cible à `structured_adapter` n'a **pas** de PTY : `ensure_live_pty`
|
||||
// échouerait, et le tour ne pourrait se débloquer que par un `idea_reply`
|
||||
// explicite (cause racine du blocage `Busy`). Quand le registre structuré est
|
||||
// câblé et que la cible a une session vivante, on draine son tour via
|
||||
// `drain_with_readiness` : le `Final` déterministe réveille le `pending` (valeur
|
||||
// de retour) **et** marque l'agent `Idle` (`mark_idle`). On enregistre tout de
|
||||
// même un ticket dans la FIFO pour la comptabilité busy et pour préserver
|
||||
// `idea_reply` comme signal **alternatif** (premier arrivé gagne).
|
||||
if let Some(structured) = self.structured.as_ref() {
|
||||
// Lot 1b — auto-lancement d'une cible **froide** structurée : si le registre
|
||||
// est câblé, qu'aucune session ne vit encore pour la cible, mais que son
|
||||
// profil porte un `structured_adapter`, on **démarre** sa session via le
|
||||
// launcher (qui route §17.4 vers `launch_structured` et l'insère dans CE
|
||||
// même registre, avec la conf MCP matérialisée) plutôt que de tomber dans
|
||||
// `ensure_live_pty` — chemin PTY qui échouerait pour une cible sans PTY.
|
||||
// Une cible SANS `structured_adapter` (agent PTY/TUI legacy) conserve le
|
||||
// chemin `ensure_live_pty` ci-dessous (zéro régression).
|
||||
if let Some(session) = self
|
||||
.ensure_structured_session(project, agent_id, &agent.profile_id, structured)
|
||||
.await?
|
||||
{
|
||||
return self
|
||||
.ask_structured(
|
||||
project,
|
||||
agent_id,
|
||||
&target,
|
||||
conversation_id,
|
||||
requester,
|
||||
task,
|
||||
session.as_ref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Garantir la cible vivante en PTY pour CE fil ; lier sa session à la
|
||||
// conversation, et brancher son handle d'entrée sur le médiateur (livraison).
|
||||
let handle = self
|
||||
@ -786,6 +861,10 @@ impl OrchestratorService {
|
||||
}
|
||||
None => Ticket::from_human(ticket_id, conversation_id, requester_label, task),
|
||||
};
|
||||
// Timeout de tour piloté par profil (lot 2) : `turn_timeout_ms` de la cible si
|
||||
// défini, sinon le défaut [`ASK_AGENT_TIMEOUT`]. Arme aussi le seuil de stall sur
|
||||
// le médiateur AVANT l'enqueue (consommé au start_turn).
|
||||
let turn_timeout = self.turn_timeout_for(project, agent_id).await;
|
||||
let pending = input.enqueue(agent_id, ticket);
|
||||
// Delivery is the mediator's responsibility (`InputMediator::enqueue` writes the
|
||||
// turn into the bound handle). The service no longer writes the PTY directly —
|
||||
@ -793,7 +872,7 @@ impl OrchestratorService {
|
||||
|
||||
// 3. Attendre la réponse, bornée. Timeout/canal fermé ⇒ retirer le ticket
|
||||
// (cible laissée vivante) et renvoyer une erreur typée.
|
||||
match tokio::time::timeout(ASK_AGENT_TIMEOUT, pending).await {
|
||||
match tokio::time::timeout(turn_timeout, pending).await {
|
||||
Ok(Ok(result)) => {
|
||||
// Checkpoint Response (P6b, best-effort) : persister la réponse AVANT de
|
||||
// déplacer `result` dans `reply_outcome`. Source = la **cible** (c'est
|
||||
@ -821,6 +900,120 @@ impl OrchestratorService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Chemin `ask` **structuré** (readiness/heartbeat lot 1) : la cible a un
|
||||
/// `structured_adapter` et une session vivante ([`StructuredSessions`]). On pilote
|
||||
/// son tour **directement** sur le port [`domain::ports::AgentSession`] et on le
|
||||
/// draine via [`drain_with_readiness`] : le [`domain::ports::ReplyEvent::Final`]
|
||||
/// déterministe rend le contenu (réveille le `pending`) **et** marque l'agent `Idle`
|
||||
/// (`mark_idle`), sans dépendre d'un `idea_reply` explicite ni d'un PTY (que la cible
|
||||
/// n'a pas). C'est le fix de la cause racine du blocage `Busy`.
|
||||
///
|
||||
/// On enregistre tout de même un ticket dans la FIFO (`enqueue`) pour la comptabilité
|
||||
/// busy et pour **préserver `idea_reply` comme signal alternatif** : on attend la
|
||||
/// **première** des deux issues (réponse de la session OU résolution du `pending`).
|
||||
/// Le checkpoint Prompt/Response best-effort est conservé à l'identique du chemin PTY.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn ask_structured(
|
||||
&self,
|
||||
project: &Project,
|
||||
agent_id: AgentId,
|
||||
target: &str,
|
||||
conversation_id: domain::conversation::ConversationId,
|
||||
requester: Option<AgentId>,
|
||||
task: String,
|
||||
session: &dyn domain::ports::AgentSession,
|
||||
) -> Result<OrchestratorOutcome, AppError> {
|
||||
let (input, mailbox) = match (&self.input, &self.mailbox) {
|
||||
(Some(i), Some(m)) => (i, m),
|
||||
_ => return Err(AppError::Invalid(
|
||||
"la messagerie inter-agents (idea_ask_agent) n'est pas disponible : \
|
||||
médiateur d'entrée non câblé"
|
||||
.to_owned(),
|
||||
)),
|
||||
};
|
||||
|
||||
// Checkpoint Prompt (best-effort), AVANT de déplacer `task` dans le ticket.
|
||||
let prompt_source = match requester {
|
||||
Some(from) => InputSource::agent(from),
|
||||
None => InputSource::Human,
|
||||
};
|
||||
self.record_turn_best_effort(
|
||||
&project.root,
|
||||
conversation_id,
|
||||
prompt_source,
|
||||
TurnRole::Prompt,
|
||||
task.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Ticket dans la FIFO : comptabilité busy + `idea_reply` comme signal alternatif.
|
||||
let requester_label = self.requester_label(project, requester).await;
|
||||
let ticket_id = TicketId::new_random();
|
||||
let ticket = match requester {
|
||||
Some(from) => Ticket::from_agent(
|
||||
ticket_id,
|
||||
from,
|
||||
conversation_id,
|
||||
requester_label,
|
||||
task.clone(),
|
||||
),
|
||||
None => Ticket::from_human(ticket_id, conversation_id, requester_label, task.clone()),
|
||||
};
|
||||
// Timeout de tour piloté par profil (lot 2) + armement du seuil de stall, AVANT
|
||||
// l'enqueue qui démarre le tour (le médiateur arme alors sa fenêtre de vivacité).
|
||||
let turn_timeout = self.turn_timeout_for(project, agent_id).await;
|
||||
let pending = input.enqueue(agent_id, ticket);
|
||||
|
||||
// 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,
|
||||
);
|
||||
|
||||
let result = tokio::select! {
|
||||
biased;
|
||||
// Issue déterministe : la session a rendu son `Final`.
|
||||
drained = drain => match drained {
|
||||
Ok(content) => content,
|
||||
Err(err) => {
|
||||
mailbox.cancel_head(agent_id, ticket_id);
|
||||
return Err(AppError::from(err));
|
||||
}
|
||||
},
|
||||
// Issue alternative : un `idea_reply` explicite a résolu le ticket d'abord.
|
||||
replied = pending => match replied {
|
||||
Ok(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);
|
||||
content
|
||||
}
|
||||
Err(_cancelled) => {
|
||||
mailbox.cancel_head(agent_id, ticket_id);
|
||||
return Err(AppError::Process(format!(
|
||||
"agent {target} : canal de réponse fermé avant un résultat"
|
||||
)));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Checkpoint Response (best-effort), AVANT de déplacer `result`.
|
||||
self.record_turn_best_effort(
|
||||
&project.root,
|
||||
conversation_id,
|
||||
InputSource::agent(agent_id),
|
||||
TurnRole::Response,
|
||||
result.clone(),
|
||||
)
|
||||
.await;
|
||||
Ok(self.reply_outcome(agent_id, target, result))
|
||||
}
|
||||
|
||||
/// `SubmitHumanInput` (cadrage C4 §5.3) — the **human** Envoyer path.
|
||||
///
|
||||
/// The operator types into IdeA's mediated input; this resolves the `User↔Agent`
|
||||
@ -1083,6 +1276,74 @@ impl OrchestratorService {
|
||||
})
|
||||
}
|
||||
|
||||
/// Lot 1b — garantit une **session structurée vivante** pour la cible d'un `ask`.
|
||||
///
|
||||
/// Retourne :
|
||||
/// - `Ok(Some(session))` si la cible a déjà une session vivante, **ou** si son
|
||||
/// profil porte un `structured_adapter` et qu'on vient de la (re)lancer ;
|
||||
/// - `Ok(None)` si la cible n'est **pas** structurée (profil sans `structured_adapter`,
|
||||
/// ou profil introuvable) ⇒ l'appelant retombe sur le chemin PTY `ensure_live_pty`.
|
||||
///
|
||||
/// L'auto-lancement réutilise le **même** [`LaunchAgent`] que le chemin PTV
|
||||
/// ([`Self::ensure_live_pty`]) avec le **même** `mcp_runtime` matérialisé : pour un
|
||||
/// profil structuré, le launcher route §17.4 vers `launch_structured`, démarre la
|
||||
/// session via la fabrique et l'insère dans le registre [`StructuredSessions`]
|
||||
/// **partagé** (le même `Arc` que `self.structured`, câblé au composition root).
|
||||
/// La conf MCP est donc matérialisée comme pour une cellule chat lancée à la main,
|
||||
/// si bien que la cible voit les outils `idea_*` pour répondre.
|
||||
async fn ensure_structured_session(
|
||||
&self,
|
||||
project: &Project,
|
||||
agent_id: AgentId,
|
||||
profile_id: &ProfileId,
|
||||
structured: &Arc<StructuredSessions>,
|
||||
) -> Result<Option<Arc<dyn domain::ports::AgentSession>>, AppError> {
|
||||
// Cible déjà chaude : route directe (aucun lancement).
|
||||
if let Some(session) = structured.session_for_agent(&agent_id) {
|
||||
return Ok(Some(session));
|
||||
}
|
||||
|
||||
// Cible froide : ne (re)lancer que si le profil sait être piloté en mode
|
||||
// structuré. Sinon (agent PTY/TUI legacy, ou profil introuvable) ⇒ chemin PTY.
|
||||
let is_structured = self
|
||||
.profiles
|
||||
.list()
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|p| &p.id == profile_id)
|
||||
.is_some_and(|p| p.is_selectable());
|
||||
if !is_structured {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Démarrer la session via le launcher (route §17.4 → `launch_structured`,
|
||||
// insère dans CE registre). Mêmes faits MCP que le chemin PTY pour que le pont
|
||||
// `idea_*` de la cible se branche. `conversation_id: None` ⇒ le launcher dérive
|
||||
// l'id de paire (User↔agent) ou réutilise celui de la cellule (P8a), comme pour
|
||||
// un lancement direct utilisateur.
|
||||
self.launch_agent
|
||||
.execute(LaunchAgentInput {
|
||||
project: project.clone(),
|
||||
agent_id,
|
||||
rows: DEFAULT_ROWS,
|
||||
cols: DEFAULT_COLS,
|
||||
node_id: None,
|
||||
conversation_id: None,
|
||||
mcp_runtime: self
|
||||
.mcp_runtime_provider
|
||||
.as_ref()
|
||||
.and_then(|p| p.runtime_for(project, agent_id)),
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Le launcher a inséré la session dans le registre partagé : la relire.
|
||||
structured.session_for_agent(&agent_id).map(Some).ok_or_else(|| {
|
||||
AppError::Process(format!(
|
||||
"agent {agent_id} : aucune session structurée vivante après lancement"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Binds `conversation` to `session` in both the terminal registry (fast
|
||||
/// `session_for`) and the [`ConversationRegistry`] (domain thread state), when the
|
||||
/// latter is wired. Idempotent.
|
||||
@ -1315,8 +1576,6 @@ impl OrchestratorService {
|
||||
profile_id: &ProfileId,
|
||||
target: &str,
|
||||
) -> Result<(), AppError> {
|
||||
use domain::profile::{McpConfigStrategy, StructuredAdapter};
|
||||
|
||||
let Some(profile) = self
|
||||
.profiles
|
||||
.list()
|
||||
@ -1327,21 +1586,20 @@ impl OrchestratorService {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let honours_mcp_json = matches!(
|
||||
profile.mcp.as_ref().map(|c| &c.config),
|
||||
Some(McpConfigStrategy::ConfigFile { target }) if target == ".mcp.json"
|
||||
);
|
||||
let is_claude = profile.structured_adapter == Some(StructuredAdapter::Claude);
|
||||
|
||||
if honours_mcp_json && is_claude {
|
||||
// Source de vérité UNIQUE (domaine) : un profil supporte le pont `idea_*` ssi
|
||||
// IdeA matérialise réellement sa config MCP pour la CLI qu'il pilote — Claude
|
||||
// via `.mcp.json`, Codex via `config.toml`/`CODEX_HOME`. Tout autre couple
|
||||
// (y compris MCP absent) ⇒ repli fichier, pont non branché.
|
||||
if profile.materializes_idea_bridge() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(AppError::Invalid(format!(
|
||||
"la cible '{target}' (profil '{}', adaptateur {:?}) ne supporte pas encore le \
|
||||
pont idea_* : la délégation inter-agents passe par un serveur MCP déclaré en \
|
||||
.mcp.json, que seul un profil Claude consomme aujourd'hui. Cible un agent au \
|
||||
profil Claude.",
|
||||
pont idea_* : la délégation inter-agents passe par un serveur MCP qu'IdeA \
|
||||
matérialise pour la CLI cible, et ce profil ne déclare pas un couple \
|
||||
(adaptateur × stratégie MCP) pris en charge. Cible un agent dont le profil \
|
||||
expose le pont MCP (Claude ou Codex).",
|
||||
profile.name, profile.structured_adapter
|
||||
)))
|
||||
}
|
||||
@ -1383,6 +1641,54 @@ impl OrchestratorService {
|
||||
(profile.prompt_ready_pattern, submit)
|
||||
}
|
||||
|
||||
/// Résout la [`domain::profile::LivenessStrategy`] du profil de la cible (lot 2) :
|
||||
/// le seuil de stagnation (`stall_after_ms`) et le timeout de tour
|
||||
/// (`turn_timeout_ms`). `None`/absent ⇒ `(None, None)` : pas de détection de stall et
|
||||
/// repli sur les bornes par défaut codées en dur (zéro régression pour un profil sans
|
||||
/// bloc `liveness`).
|
||||
async fn liveness_for_agent(
|
||||
&self,
|
||||
project: &Project,
|
||||
agent_id: AgentId,
|
||||
) -> (Option<u32>, Option<u32>) {
|
||||
let Some(agent) = self
|
||||
.list_agents
|
||||
.execute(ListAgentsInput {
|
||||
project: project.clone(),
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|out| out.agents.into_iter().find(|a| a.id == agent_id))
|
||||
else {
|
||||
return (None, None);
|
||||
};
|
||||
let Some(profile) = self
|
||||
.profiles
|
||||
.list()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|ps| ps.into_iter().find(|p| p.id == agent.profile_id))
|
||||
else {
|
||||
return (None, None);
|
||||
};
|
||||
match profile.liveness {
|
||||
Some(l) => (l.stall_after_ms, l.turn_timeout_ms),
|
||||
None => (None, None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Borne de tour effective pour un `ask` : le `turn_timeout_ms` du profil de la cible
|
||||
/// **quand il existe**, sinon le défaut historique [`ASK_AGENT_TIMEOUT`] (lot 2,
|
||||
/// timeouts pilotés par profil). Arme aussi le seuil de stagnation sur le médiateur
|
||||
/// avant l'enqueue (battement/`sweep_stalled`).
|
||||
async fn turn_timeout_for(&self, project: &Project, agent_id: AgentId) -> Duration {
|
||||
let (stall_after_ms, turn_timeout_ms) = self.liveness_for_agent(project, agent_id).await;
|
||||
if let Some(input) = &self.input {
|
||||
input.set_stall_threshold(agent_id, stall_after_ms);
|
||||
}
|
||||
resolve_turn_timeout(turn_timeout_ms)
|
||||
}
|
||||
|
||||
/// 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
|
||||
@ -1459,6 +1765,18 @@ mod tests {
|
||||
use domain::profile::{AgentProfile, ContextInjection};
|
||||
use domain::ProfileId;
|
||||
|
||||
// (c) — timeouts pilotés par profil (lot 2) : `turn_timeout_ms` prime sur le défaut.
|
||||
#[test]
|
||||
fn profile_turn_timeout_overrides_default() {
|
||||
// Seuil de profil défini ⇒ il prime sur ASK_AGENT_TIMEOUT.
|
||||
assert_eq!(
|
||||
resolve_turn_timeout(Some(120_000)),
|
||||
Duration::from_millis(120_000)
|
||||
);
|
||||
// Absent (profil sans liveness / sans turn_timeout_ms) ⇒ repli sur le défaut.
|
||||
assert_eq!(resolve_turn_timeout(None), ASK_AGENT_TIMEOUT);
|
||||
}
|
||||
|
||||
fn profile(id: u128, name: &str, command: &str) -> AgentProfile {
|
||||
AgentProfile::new(
|
||||
ProfileId::from_uuid(uuid::Uuid::from_u128(id)),
|
||||
|
||||
Reference in New Issue
Block a user