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:
@ -80,8 +80,11 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
||||
.expect("codex reference profile is valid")
|
||||
.with_structured_adapter(StructuredAdapter::Codex)
|
||||
.with_mcp(McpCapability::new(
|
||||
McpConfigStrategy::config_file(".mcp.json")
|
||||
.expect(".mcp.json is a valid relative MCP config target"),
|
||||
// Codex lit ses serveurs MCP dans `$CODEX_HOME/config.toml`, pas `.mcp.json` :
|
||||
// IdeA écrit ce TOML DANS le run dir et pointe `CODEX_HOME` dessus pour
|
||||
// isoler l'agent du `~/.codex` global (miroir du `.mcp.json` de Claude).
|
||||
McpConfigStrategy::toml_config_home(".codex/config.toml", "CODEX_HOME")
|
||||
.expect(".codex/config.toml + CODEX_HOME is a valid MCP config target"),
|
||||
McpTransport::Stdio,
|
||||
)),
|
||||
AgentProfile::new(
|
||||
@ -157,18 +160,36 @@ mod mcp_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_and_codex_mcp_use_config_file_mcp_json() {
|
||||
for slug in ["claude", "codex"] {
|
||||
let mcp = profile(slug).mcp.expect("mcp present");
|
||||
assert_eq!(
|
||||
mcp.config,
|
||||
McpConfigStrategy::ConfigFile {
|
||||
target: ".mcp.json".to_owned()
|
||||
},
|
||||
"profile `{slug}` should declare `.mcp.json`"
|
||||
);
|
||||
assert_eq!(mcp.transport, McpTransport::Stdio);
|
||||
}
|
||||
fn claude_mcp_uses_config_file_mcp_json() {
|
||||
let mcp = profile("claude").mcp.expect("mcp present");
|
||||
assert_eq!(
|
||||
mcp.config,
|
||||
McpConfigStrategy::ConfigFile {
|
||||
target: ".mcp.json".to_owned()
|
||||
},
|
||||
"Claude should declare `.mcp.json`"
|
||||
);
|
||||
assert_eq!(mcp.transport, McpTransport::Stdio);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_mcp_uses_toml_config_home_codex() {
|
||||
// Codex lit `$CODEX_HOME/config.toml`, pas `.mcp.json` : le seed doit déclarer
|
||||
// la stratégie TOML isolée par `CODEX_HOME` (pendant Codex de Claude).
|
||||
let mcp = profile("codex").mcp.expect("mcp present");
|
||||
assert_eq!(
|
||||
mcp.config,
|
||||
McpConfigStrategy::TomlConfigHome {
|
||||
target: ".codex/config.toml".to_owned(),
|
||||
home_env: "CODEX_HOME".to_owned(),
|
||||
},
|
||||
"Codex should declare `.codex/config.toml` + CODEX_HOME"
|
||||
);
|
||||
assert_eq!(mcp.transport, McpTransport::Stdio);
|
||||
assert!(
|
||||
profile("codex").materializes_idea_bridge(),
|
||||
"the Codex seed must materialise the idea bridge"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -1746,6 +1746,36 @@ impl LaunchAgent {
|
||||
},
|
||||
}
|
||||
}
|
||||
domain::profile::McpConfigStrategy::TomlConfigHome { target, home_env } => {
|
||||
// Pendant Codex de `ConfigFile` : on écrit un `config.toml` (table
|
||||
// `[mcp_servers.idea]`, via l'encodeur TOML partagé D2) au chemin
|
||||
// `<run_dir>/<target>`, et on pousse `home_env` (ex. `CODEX_HOME`) vers
|
||||
// le **dossier parent** de ce fichier. Codex lit alors ses serveurs MCP
|
||||
// dans ce `config.toml` ISOLÉ au run dir, jamais le `~/.codex` global.
|
||||
let path = RemotePath::new(join(run_dir, target));
|
||||
let declaration = mcp_server_wiring(mcp.transport, runtime).to_config_toml();
|
||||
// Même régime clobber/non-clobber que `.mcp.json` : avec un runtime réel
|
||||
// (lancement app-tauri) on régénère/clobber à chaque (re)lancement (l'exe
|
||||
// `$APPIMAGE` et l'endpoint dérivent entre runs) ; sans runtime
|
||||
// (orchestrateur / hot-swap / tests) on reste non-clobbering pour ne pas
|
||||
// écraser une déclaration réelle par la minimale.
|
||||
match runtime {
|
||||
Some(_) => {
|
||||
let _ = self.fs.write(&path, declaration.as_bytes()).await;
|
||||
}
|
||||
None => match self.fs.exists(&path).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
let _ = self.fs.write(&path, declaration.as_bytes()).await;
|
||||
}
|
||||
Err(_) => {}
|
||||
},
|
||||
}
|
||||
// `home_env` pointe sur le DOSSIER PARENT de `target` (ex.
|
||||
// `{runDir}/.codex`), pas sur le fichier — Codex y cherche `config.toml`.
|
||||
let home_dir = parent_dir(run_dir, target);
|
||||
spec.env.push((home_env.clone(), home_dir));
|
||||
}
|
||||
domain::profile::McpConfigStrategy::Flag { flag } => {
|
||||
// Pass the server via a launch flag (e.g. `--mcp-config {path}`). The
|
||||
// config path is the run dir itself (the CLI's cwd), where the server
|
||||
@ -1858,58 +1888,40 @@ fn mcp_server_declaration(
|
||||
transport: domain::profile::McpTransport,
|
||||
runtime: Option<&McpRuntime>,
|
||||
) -> String {
|
||||
// Common `.mcp.json`-style shape (Claude Code et CLIs apparentées). The transport
|
||||
// is surfaced so a socket-based CLI can be wired later without changing this seam.
|
||||
let transport_label = match transport {
|
||||
domain::profile::McpTransport::Stdio => "stdio",
|
||||
domain::profile::McpTransport::Socket => "socket",
|
||||
};
|
||||
|
||||
// `command` + extra args depend on whether OS/runtime facts were injected.
|
||||
// Each `args` entry is emitted as an escaped JSON string so an exe path or
|
||||
// endpoint with spaces/backslashes/quotes stays valid JSON.
|
||||
let (command, extra_args) = match runtime {
|
||||
Some(rt) => {
|
||||
let arg = |label: &str, value: &str| {
|
||||
format!(
|
||||
",\n {},\n {}",
|
||||
json_string(label),
|
||||
json_string(value)
|
||||
)
|
||||
};
|
||||
let extra = format!(
|
||||
"{}{}{}",
|
||||
arg("--endpoint", &rt.endpoint),
|
||||
arg("--project", &rt.project_id),
|
||||
arg("--requester", &rt.requester),
|
||||
);
|
||||
(rt.exe.as_str(), extra)
|
||||
}
|
||||
None => ("idea", String::new()),
|
||||
};
|
||||
let command = json_string(command);
|
||||
|
||||
format!(
|
||||
r#"{{
|
||||
"mcpServers": {{
|
||||
"idea": {{
|
||||
"command": {command},
|
||||
"args": [
|
||||
"mcp-server"{extra_args}
|
||||
],
|
||||
"transport": "{transport_label}"
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
"#
|
||||
)
|
||||
mcp_server_wiring(transport, runtime).to_mcp_json()
|
||||
}
|
||||
|
||||
/// Wraps a string as a JSON string literal (quotes + escaping), reusing the path
|
||||
/// escaper so an exe path / endpoint with spaces, backslashes or quotes stays valid
|
||||
/// JSON in the generated `.mcp.json`.
|
||||
fn json_string(s: &str) -> String {
|
||||
format!("\"{}\"", json_escape(s))
|
||||
/// Builds the IdeA MCP server **wiring** (`command` + `args` + transport) shared by
|
||||
/// every materialisation format (cadrage Codex D2). It is the **single source of
|
||||
/// truth** for *what* the bridge is launched as; the concrete bytes (`.mcp.json`
|
||||
/// JSON for Claude, `config.toml` TOML for Codex) are produced by the domain
|
||||
/// encoders [`domain::McpServerWiring::to_mcp_json`] /
|
||||
/// [`domain::McpServerWiring::to_config_toml`], so the two sites can never drift.
|
||||
///
|
||||
/// `Some(runtime)` ⇒ the **real** wiring (this IdeA exe + the project's loopback
|
||||
/// endpoint/project/requester); `None` ⇒ the coherent **minimal** `idea mcp-server`
|
||||
/// fallback (orchestrator / hot-swap / tests).
|
||||
#[must_use]
|
||||
fn mcp_server_wiring(
|
||||
transport: domain::profile::McpTransport,
|
||||
runtime: Option<&McpRuntime>,
|
||||
) -> domain::McpServerWiring {
|
||||
let (command, args) = match runtime {
|
||||
Some(rt) => (
|
||||
rt.exe.clone(),
|
||||
vec![
|
||||
"mcp-server".to_owned(),
|
||||
"--endpoint".to_owned(),
|
||||
rt.endpoint.clone(),
|
||||
"--project".to_owned(),
|
||||
rt.project_id.clone(),
|
||||
"--requester".to_owned(),
|
||||
rt.requester.clone(),
|
||||
],
|
||||
),
|
||||
None => ("idea".to_owned(), vec!["mcp-server".to_owned()]),
|
||||
};
|
||||
domain::McpServerWiring::new(command, args, transport)
|
||||
}
|
||||
|
||||
/// Builds an absolute path string by joining a [`ProjectPath`] with a relative
|
||||
@ -1919,6 +1931,18 @@ fn join(base: &ProjectPath, rel: &str) -> String {
|
||||
format!("{b}/{rel}")
|
||||
}
|
||||
|
||||
/// Resolves the **parent directory** (absolute) of `<base>/<rel>` — used to point a
|
||||
/// CLI's `home_env` (e.g. `CODEX_HOME`) at the directory *containing* the materialised
|
||||
/// config file (`config.toml`), not the file itself. When `rel` has no separator
|
||||
/// (file directly in the run dir), the parent is the run dir.
|
||||
fn parent_dir(base: &ProjectPath, rel: &str) -> String {
|
||||
let full = join(base, rel);
|
||||
match full.rsplit_once(['/', '\\']) {
|
||||
Some((parent, _)) if !parent.is_empty() => parent.to_owned(),
|
||||
_ => base.as_str().trim_end_matches(['/', '\\']).to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes an agent's isolated run directory `<root>/.ideai/run/<agent-id>/`
|
||||
/// (ARCHITECTURE §14.1). This is the PTY cwd for the agent — never the project
|
||||
/// root — guaranteeing that two distinct agents on the same project root get two
|
||||
|
||||
@ -16,7 +16,7 @@ mod usecases;
|
||||
pub(crate) use lifecycle::unique_md_path;
|
||||
pub(crate) use lifecycle::ReattachDecision;
|
||||
|
||||
pub use structured::send_blocking;
|
||||
pub use structured::{drain_with_readiness, send_blocking};
|
||||
|
||||
pub use catalogue::{reference_profile_id, reference_profiles, selectable_reference_profiles};
|
||||
pub use inspect::{InspectConversation, InspectConversationInput, InspectConversationOutput};
|
||||
|
||||
@ -14,7 +14,10 @@
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use domain::input::InputMediator;
|
||||
use domain::ids::AgentId;
|
||||
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent};
|
||||
use domain::readiness::{ReadinessPolicy, ReadinessSignal};
|
||||
|
||||
/// Envoie `prompt` à la session vivante puis **draine le flux de réponse jusqu'au
|
||||
/// [`ReplyEvent::Final`]**, et retourne son contenu agrégé.
|
||||
@ -39,14 +42,83 @@ pub async fn send_blocking(
|
||||
session: &dyn AgentSession,
|
||||
prompt: &str,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<String, AgentSessionError> {
|
||||
drain_bounded_events(session, prompt, timeout, |_event| {}, |_signal| {}).await
|
||||
}
|
||||
|
||||
/// Comme [`send_blocking`], mais **branche la readiness** : à chaque événement du
|
||||
/// tour, [`ReadinessPolicy::classify`] est consulté et, dès qu'il renvoie
|
||||
/// [`ReadinessSignal::TurnEnded`] (le `Final`), le médiateur d'entrée est notifié
|
||||
/// (`mark_idle(agent)`) pour que la FIFO de l'agent avance — **sans** dépendre d'un
|
||||
/// `idea_reply` explicite ni du sniff de prompt PTY (chantier readiness/heartbeat,
|
||||
/// lot 1, fix de la cause racine du blocage `Busy`).
|
||||
///
|
||||
/// DRY : **un seul** chemin de lecture du flux (la boucle de [`drain_bounded`]) ;
|
||||
/// cette fonction n'est que `send_blocking` muni d'un *sink* de readiness. Le `Final`
|
||||
/// réveille donc à la fois le `pending` (via la valeur de retour) **et** la FIFO (via
|
||||
/// `mark_idle`). `idea_reply` reste un signal alternatif (premier arrivé gagne) côté
|
||||
/// orchestrateur.
|
||||
///
|
||||
/// # Errors
|
||||
/// Identiques à [`send_blocking`] (échec `send`/décodage, flux clos sans `Final`,
|
||||
/// timeout).
|
||||
pub async fn drain_with_readiness(
|
||||
session: &dyn AgentSession,
|
||||
prompt: &str,
|
||||
timeout: Option<Duration>,
|
||||
mediator: &dyn InputMediator,
|
||||
agent: AgentId,
|
||||
) -> Result<String, AgentSessionError> {
|
||||
// `on_signal` ne reçoit QUE les événements terminaux (le `Final` ⇒ `TurnEnded`) :
|
||||
// la readiness ne classe pas les non-terminaux. Pour le **battement** de vivacité
|
||||
// (lot 2) on a besoin de notifier le médiateur à CHAQUE événement non terminal
|
||||
// (delta / activité / heartbeat) ⇒ on passe un sink d'événement bruts `on_event`.
|
||||
drain_bounded_events(
|
||||
session,
|
||||
prompt,
|
||||
timeout,
|
||||
|event| {
|
||||
// Tout événement **non terminal** prouve la vivacité ⇒ un battement.
|
||||
if !matches!(event, ReplyEvent::Final { .. }) {
|
||||
mediator.mark_alive(agent);
|
||||
}
|
||||
},
|
||||
|signal| {
|
||||
if signal == ReadinessSignal::TurnEnded {
|
||||
mediator.mark_idle(agent);
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Ouvre le flux du tour (`send`) et le **draine jusqu'au `Final`**, en appliquant
|
||||
/// la borne temporelle `timeout`, en notifiant `on_event` à **chaque** événement brut
|
||||
/// (pour le battement de vivacité, lot 2) et `on_signal` à chaque [`ReadinessSignal`]
|
||||
/// dérivé par [`ReadinessPolicy`] (le `Final` ⇒ `TurnEnded`).
|
||||
///
|
||||
/// **Chemin de lecture unique** (DRY) : `send_blocking` et `drain_with_readiness`
|
||||
/// passent tous deux par ici, en différant seulement par leurs *sinks*. La session
|
||||
/// **reste vivante** sur timeout (on ne `shutdown` rien ici, §17.1).
|
||||
async fn drain_bounded_events(
|
||||
session: &dyn AgentSession,
|
||||
prompt: &str,
|
||||
timeout: Option<Duration>,
|
||||
on_event: impl FnMut(&ReplyEvent),
|
||||
on_signal: impl FnMut(ReadinessSignal),
|
||||
) -> Result<String, AgentSessionError> {
|
||||
match timeout {
|
||||
Some(dur) => match tokio::time::timeout(dur, drain_to_final(session, prompt)).await {
|
||||
Some(dur) => match tokio::time::timeout(
|
||||
dur,
|
||||
drain_to_final(session, prompt, on_event, on_signal),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
// La session **reste vivante** : on ne `shutdown` rien ici (§17.1).
|
||||
Err(_elapsed) => Err(AgentSessionError::Timeout),
|
||||
},
|
||||
None => drain_to_final(session, prompt).await,
|
||||
None => drain_to_final(session, prompt, on_event, on_signal).await,
|
||||
}
|
||||
}
|
||||
|
||||
@ -56,18 +128,124 @@ pub async fn send_blocking(
|
||||
/// après le `Final` il ne produit plus rien. On le parcourt donc simplement
|
||||
/// jusqu'à rencontrer le `Final` (et on retourne son contenu) ; si le flux
|
||||
/// s'épuise avant, c'est un tour interrompu → erreur [`AgentSessionError::Io`].
|
||||
///
|
||||
/// Chaque événement est classé par [`ReadinessPolicy`] et le signal éventuel est
|
||||
/// remonté à `on_signal` (le `Final` ⇒ [`ReadinessSignal::TurnEnded`]). Deltas,
|
||||
/// activités et heartbeats sont non terminaux ⇒ ignorés par le rendez-vous synchrone.
|
||||
async fn drain_to_final(
|
||||
session: &dyn AgentSession,
|
||||
prompt: &str,
|
||||
mut on_event: impl FnMut(&ReplyEvent),
|
||||
mut on_signal: impl FnMut(ReadinessSignal),
|
||||
) -> Result<String, AgentSessionError> {
|
||||
let stream = session.send(prompt).await?;
|
||||
for event in stream {
|
||||
// Battement de vivacité (lot 2) : notifié pour CHAQUE événement brut, avant le
|
||||
// classement readiness. Le sink décide (les non-terminaux prouvent la vivacité).
|
||||
on_event(&event);
|
||||
if let Some(signal) = ReadinessPolicy::classify(&event) {
|
||||
on_signal(signal);
|
||||
}
|
||||
if let ReplyEvent::Final { content } = event {
|
||||
return Ok(content);
|
||||
}
|
||||
// TextDelta / ToolActivity : ignorés par le rendez-vous synchrone.
|
||||
// TextDelta / ToolActivity / Heartbeat : non terminaux, ignorés ici.
|
||||
}
|
||||
Err(AgentSessionError::Io(
|
||||
"le flux de réponse s'est terminé sans événement Final".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use domain::ids::SessionId;
|
||||
use domain::input::{AgentBusyState, SubmitConfig};
|
||||
use domain::mailbox::{PendingReply, Ticket};
|
||||
use domain::ports::PtyHandle;
|
||||
|
||||
fn agent(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
/// Session factice : `send` rejoue une liste fixe d'événements (terminée par un
|
||||
/// `Final`).
|
||||
struct FakeSession {
|
||||
events: Vec<ReplyEvent>,
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl AgentSession for FakeSession {
|
||||
fn id(&self) -> SessionId {
|
||||
SessionId::from_uuid(uuid::Uuid::from_u128(1))
|
||||
}
|
||||
fn conversation_id(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
async fn send(
|
||||
&self,
|
||||
_prompt: &str,
|
||||
) -> Result<domain::ports::ReplyStream, AgentSessionError> {
|
||||
Ok(Box::new(self.events.clone().into_iter()))
|
||||
}
|
||||
async fn shutdown(&self) -> Result<(), AgentSessionError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Médiateur factice qui enregistre l'ordre des `mark_alive` / `mark_idle`.
|
||||
#[derive(Default)]
|
||||
struct RecordingMediator {
|
||||
calls: Mutex<Vec<&'static str>>,
|
||||
}
|
||||
impl InputMediator for RecordingMediator {
|
||||
fn enqueue(&self, _agent: AgentId, _ticket: Ticket) -> PendingReply {
|
||||
unreachable!("non utilisé par drain_with_readiness")
|
||||
}
|
||||
fn preempt(&self, _agent: AgentId) {}
|
||||
fn mark_idle(&self, _agent: AgentId) {
|
||||
self.calls.lock().unwrap().push("idle");
|
||||
}
|
||||
fn mark_alive(&self, _agent: AgentId) {
|
||||
self.calls.lock().unwrap().push("alive");
|
||||
}
|
||||
fn busy_state(&self, _agent: AgentId) -> AgentBusyState {
|
||||
AgentBusyState::Idle
|
||||
}
|
||||
fn bind_handle(&self, _agent: AgentId, _handle: PtyHandle) {}
|
||||
fn bind_handle_with_prompt(
|
||||
&self,
|
||||
_agent: AgentId,
|
||||
_handle: PtyHandle,
|
||||
_pattern: Option<String>,
|
||||
_submit: SubmitConfig,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drain_marks_alive_on_each_non_terminal_then_idle_on_final() {
|
||||
let session = FakeSession {
|
||||
events: vec![
|
||||
ReplyEvent::TextDelta { text: "a".into() },
|
||||
ReplyEvent::ToolActivity { label: "lit".into() },
|
||||
ReplyEvent::Heartbeat,
|
||||
ReplyEvent::Final {
|
||||
content: "fini".into(),
|
||||
},
|
||||
],
|
||||
};
|
||||
let mediator = RecordingMediator::default();
|
||||
let out = drain_with_readiness(&session, "go", None, &mediator, agent(1))
|
||||
.await
|
||||
.expect("drain ok");
|
||||
assert_eq!(out, "fini");
|
||||
// Trois battements (delta, activité, heartbeat) PUIS l'idle sur le Final.
|
||||
assert_eq!(
|
||||
*mediator.calls.lock().unwrap(),
|
||||
vec!["alive", "alive", "alive", "idle"],
|
||||
"un battement par événement non terminal, idle au Final (pas de battement sur le Final)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,8 +28,8 @@ pub mod terminal;
|
||||
pub mod window;
|
||||
|
||||
pub use agent::{
|
||||
reference_profile_id, reference_profiles, selectable_reference_profiles, send_blocking,
|
||||
ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, ConfigureProfiles,
|
||||
drain_with_readiness, reference_profile_id, reference_profiles, selectable_reference_profiles,
|
||||
send_blocking, ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, ConfigureProfiles,
|
||||
ConfigureProfilesInput, ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput,
|
||||
CreateAgentOutput, DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput,
|
||||
DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput,
|
||||
|
||||
@ -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