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:
@ -175,11 +175,17 @@ impl ChatBridge {
|
|||||||
/// Maps a domain [`ReplyEvent`] to its wire [`ReplyChunk`]. Pure translation, no
|
/// Maps a domain [`ReplyEvent`] to its wire [`ReplyChunk`]. Pure translation, no
|
||||||
/// I/O — the single point where the typed turn event becomes a serialisable chunk
|
/// I/O — the single point where the typed turn event becomes a serialisable chunk
|
||||||
/// (kept here so the pump and any test share one mapping).
|
/// (kept here so the pump and any test share one mapping).
|
||||||
|
///
|
||||||
|
/// Returns `None` for [`ReplyEvent::Heartbeat`]: a heartbeat is a non-terminal
|
||||||
|
/// liveness proof (readiness/heartbeat lot 1) with **no chat content**, so it maps
|
||||||
|
/// to no wire chunk — the pump simply skips it. Every content-bearing event still
|
||||||
|
/// maps to exactly one chunk.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn chunk_from_event(event: ReplyEvent) -> ReplyChunk {
|
pub fn chunk_from_event(event: ReplyEvent) -> Option<ReplyChunk> {
|
||||||
match event {
|
match event {
|
||||||
ReplyEvent::TextDelta { text } => ReplyChunk::TextDelta { text },
|
ReplyEvent::TextDelta { text } => Some(ReplyChunk::TextDelta { text }),
|
||||||
ReplyEvent::ToolActivity { label } => ReplyChunk::ToolActivity { label },
|
ReplyEvent::ToolActivity { label } => Some(ReplyChunk::ToolActivity { label }),
|
||||||
ReplyEvent::Final { content } => ReplyChunk::Final { content },
|
ReplyEvent::Final { content } => Some(ReplyChunk::Final { content }),
|
||||||
|
ReplyEvent::Heartbeat => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1196,7 +1196,11 @@ pub async fn agent_send(
|
|||||||
let bridge = std::sync::Arc::clone(&state.chat_bridge);
|
let bridge = std::sync::Arc::clone(&state.chat_bridge);
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
for event in stream {
|
for event in stream {
|
||||||
let chunk = crate::chat::chunk_from_event(event);
|
// Heartbeats carry no chat content (readiness/heartbeat lot 1) ⇒ no wire
|
||||||
|
// chunk; skip them while still draining so the turn runs to its `Final`.
|
||||||
|
let Some(chunk) = crate::chat::chunk_from_event(event) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
// `send_output` always records into the conversation scrollback; the
|
// `send_output` always records into the conversation scrollback; the
|
||||||
// boolean only reflects live delivery. If the view navigated away
|
// boolean only reflects live delivery. If the view navigated away
|
||||||
// (no channel at this generation) we keep draining so the turn still
|
// (no channel at this generation) we keep draining so the turn still
|
||||||
|
|||||||
@ -13,6 +13,7 @@ use serde::Serialize;
|
|||||||
use tauri::{AppHandle, Emitter};
|
use tauri::{AppHandle, Emitter};
|
||||||
|
|
||||||
use domain::events::{DomainEvent, OrchestrationSource};
|
use domain::events::{DomainEvent, OrchestrationSource};
|
||||||
|
use domain::input::AgentLiveness;
|
||||||
use infrastructure::TokioBroadcastEventBus;
|
use infrastructure::TokioBroadcastEventBus;
|
||||||
|
|
||||||
/// Name of the Tauri event carrying relayed [`DomainEvent`]s.
|
/// Name of the Tauri event carrying relayed [`DomainEvent`]s.
|
||||||
@ -84,6 +85,17 @@ pub enum DomainEventDto {
|
|||||||
/// Reply length in bytes (metric, not the payload).
|
/// Reply length in bytes (metric, not the payload).
|
||||||
reply_len: usize,
|
reply_len: usize,
|
||||||
},
|
},
|
||||||
|
/// An agent's liveness (alive/stalled) changed (lot 2, readiness/heartbeat). The
|
||||||
|
/// frontend can badge a frozen agent; `"stalled"` while no proof of liveness arrived
|
||||||
|
/// for longer than the profile's `stallAfterMs`, back to `"alive"` on a late
|
||||||
|
/// battement or when the turn ends.
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
AgentLivenessChanged {
|
||||||
|
/// Agent id (UUID string).
|
||||||
|
agent_id: String,
|
||||||
|
/// New liveness, as a lowercase string (`"alive"` / `"stalled"`).
|
||||||
|
liveness: AgentLivenessDto,
|
||||||
|
},
|
||||||
/// An agent's runtime profile was changed (hot-swap of the AI engine).
|
/// An agent's runtime profile was changed (hot-swap of the AI engine).
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
AgentProfileChanged {
|
AgentProfileChanged {
|
||||||
@ -215,6 +227,26 @@ pub enum OrchestrationSourceDto {
|
|||||||
Mcp,
|
Mcp,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wire mirror of [`AgentLiveness`]: the alive/stalled liveness of an agent (lot 2),
|
||||||
|
/// serialised as a lowercase string (`"alive"` / `"stalled"`) the frontend badges.
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum AgentLivenessDto {
|
||||||
|
/// The agent shows proof of liveness (or is idle).
|
||||||
|
Alive,
|
||||||
|
/// No proof of liveness past the profile's `stallAfterMs` threshold.
|
||||||
|
Stalled,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AgentLiveness> for AgentLivenessDto {
|
||||||
|
fn from(liveness: AgentLiveness) -> Self {
|
||||||
|
match liveness {
|
||||||
|
AgentLiveness::Alive => Self::Alive,
|
||||||
|
AgentLiveness::Stalled => Self::Stalled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<OrchestrationSource> for OrchestrationSourceDto {
|
impl From<OrchestrationSource> for OrchestrationSourceDto {
|
||||||
fn from(source: OrchestrationSource) -> Self {
|
fn from(source: OrchestrationSource) -> Self {
|
||||||
match source {
|
match source {
|
||||||
@ -265,6 +297,12 @@ impl From<&DomainEvent> for DomainEventDto {
|
|||||||
agent_id: agent_id.to_string(),
|
agent_id: agent_id.to_string(),
|
||||||
reply_len: *reply_len,
|
reply_len: *reply_len,
|
||||||
},
|
},
|
||||||
|
DomainEvent::AgentLivenessChanged { agent_id, liveness } => {
|
||||||
|
Self::AgentLivenessChanged {
|
||||||
|
agent_id: agent_id.to_string(),
|
||||||
|
liveness: (*liveness).into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
DomainEvent::AgentProfileChanged {
|
DomainEvent::AgentProfileChanged {
|
||||||
agent_id,
|
agent_id,
|
||||||
profile_id,
|
profile_id,
|
||||||
@ -376,3 +414,40 @@ pub fn spawn_relay(app: AppHandle, bus: &TokioBroadcastEventBus) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use domain::ids::AgentId;
|
||||||
|
|
||||||
|
fn agent(n: u128) -> AgentId {
|
||||||
|
AgentId::from_uuid(uuid::Uuid::from_u128(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lot 2 : un `AgentLivenessChanged{Stalled}` du domaine se relaie en DTO
|
||||||
|
/// `Stalled` portant le même agent, et se sérialise en `"stalled"` (le mot que
|
||||||
|
/// le front badge). Garantit le câblage présentation de la détection de stall.
|
||||||
|
#[test]
|
||||||
|
fn liveness_changed_stalled_relays_to_dto_and_wire() {
|
||||||
|
let dto = DomainEventDto::from(&DomainEvent::AgentLivenessChanged {
|
||||||
|
agent_id: agent(7),
|
||||||
|
liveness: AgentLiveness::Stalled,
|
||||||
|
});
|
||||||
|
let json = serde_json::to_value(&dto).expect("serialisable");
|
||||||
|
assert_eq!(json["type"], "agentLivenessChanged");
|
||||||
|
assert_eq!(json["agentId"], agent(7).to_string());
|
||||||
|
assert_eq!(json["liveness"], "stalled");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// La reprise `Stalled→Alive` se relaie en DTO `Alive` ⇒ wire `"alive"`.
|
||||||
|
#[test]
|
||||||
|
fn liveness_changed_alive_relays_to_dto_and_wire() {
|
||||||
|
let dto = DomainEventDto::from(&DomainEvent::AgentLivenessChanged {
|
||||||
|
agent_id: agent(3),
|
||||||
|
liveness: AgentLiveness::Alive,
|
||||||
|
});
|
||||||
|
let json = serde_json::to_value(&dto).expect("serialisable");
|
||||||
|
assert_eq!(json["type"], "agentLivenessChanged");
|
||||||
|
assert_eq!(json["liveness"], "alive");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -827,7 +827,7 @@ impl AppState {
|
|||||||
// partagé pour `resolve`/`resolve_ticket`/`cancel_head` côté orchestrateur.
|
// partagé pour `resolve`/`resolve_ticket`/`cancel_head` côté orchestrateur.
|
||||||
let inmemory_mailbox = Arc::new(InMemoryMailbox::new());
|
let inmemory_mailbox = Arc::new(InMemoryMailbox::new());
|
||||||
let mailbox = Arc::clone(&inmemory_mailbox) as Arc<dyn domain::mailbox::AgentMailbox>;
|
let mailbox = Arc::clone(&inmemory_mailbox) as Arc<dyn domain::mailbox::AgentMailbox>;
|
||||||
let input_mediator = Arc::new(
|
let mediated_inbox = Arc::new(
|
||||||
MediatedInbox::with_pty(
|
MediatedInbox::with_pty(
|
||||||
Arc::clone(&inmemory_mailbox),
|
Arc::clone(&inmemory_mailbox),
|
||||||
Arc::new(SystemMillisClock),
|
Arc::new(SystemMillisClock),
|
||||||
@ -836,7 +836,28 @@ impl AppState {
|
|||||||
// Émet `AgentBusyChanged` à la source (Busy à l'enqueue qui démarre un
|
// Émet `AgentBusyChanged` à la source (Busy à l'enqueue qui démarre un
|
||||||
// tour, Idle au mark_idle) ⇒ relayé au front en event Tauri (cadrage C4).
|
// tour, Idle au mark_idle) ⇒ relayé au front en event Tauri (cadrage C4).
|
||||||
.with_events(Arc::clone(&events_port)),
|
.with_events(Arc::clone(&events_port)),
|
||||||
) as Arc<dyn domain::input::InputMediator>;
|
);
|
||||||
|
// Détection de stagnation (lot 2, readiness/heartbeat) : une tâche périodique
|
||||||
|
// détenue au composition root appelle `sweep_stalled` (logique de décision pure,
|
||||||
|
// `now` fourni par l'horloge injectée). Bascule `Alive→Stalled` les agents `Busy`
|
||||||
|
// sans battement depuis `stall_after_ms` (profil) et émet `AgentLivenessChanged`
|
||||||
|
// une fois par transition. Tick d'1 s : largement assez fin pour des seuils en
|
||||||
|
// dizaines de secondes, négligeable en charge. Détaché ⇒ vit autant que l'app.
|
||||||
|
{
|
||||||
|
let sweeper = Arc::clone(&mediated_inbox);
|
||||||
|
// `build` runs in Tauri's `setup` hook (main thread, *no* ambient Tokio
|
||||||
|
// runtime) — `tokio::spawn` would panic with "there is no reactor running".
|
||||||
|
// Use Tauri's global async runtime, like `events::spawn_relay` does.
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
let mut tick = tokio::time::interval(std::time::Duration::from_secs(1));
|
||||||
|
loop {
|
||||||
|
tick.tick().await;
|
||||||
|
sweeper.sweep_stalled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let input_mediator =
|
||||||
|
Arc::clone(&mediated_inbox) as Arc<dyn domain::input::InputMediator>;
|
||||||
// Registre des conversations par paire (cadrage C3) : un fil par paire, session
|
// Registre des conversations par paire (cadrage C3) : un fil par paire, session
|
||||||
// vivante keyée par conversation (lève l'ambiguïté session/agent).
|
// vivante keyée par conversation (lève l'ambiguïté session/agent).
|
||||||
let conversation_registry = Arc::new(InMemoryConversationRegistry::new())
|
let conversation_registry = Arc::new(InMemoryConversationRegistry::new())
|
||||||
@ -870,7 +891,12 @@ impl AppState {
|
|||||||
.with_record_turn(
|
.with_record_turn(
|
||||||
Arc::new(AppRecordTurnProvider) as Arc<dyn RecordTurnProvider>,
|
Arc::new(AppRecordTurnProvider) as Arc<dyn RecordTurnProvider>,
|
||||||
Arc::clone(&clock) as Arc<dyn Clock>,
|
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||||
),
|
)
|
||||||
|
// Registre des sessions IA structurées (§17.5) : permet à `ask_agent` de
|
||||||
|
// router une cible structurée (sans PTY) sur sa session et de la drainer via
|
||||||
|
// `drain_with_readiness` — le `Final` réveille le round-trip ET marque l'agent
|
||||||
|
// `Idle` (readiness/heartbeat lot 1).
|
||||||
|
.with_structured(Arc::clone(&structured_sessions)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- Windows (L10) ---
|
// --- Windows (L10) ---
|
||||||
@ -1119,10 +1145,13 @@ impl AppState {
|
|||||||
let Some(profile) = profile_by_id.get(&agent.profile_id) else {
|
let Some(profile) = profile_by_id.get(&agent.profile_id) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if !is_claude_mcp_profile(profile) {
|
// Dispatch par profil : Claude (`.mcp.json` + settings) ou Codex
|
||||||
continue;
|
// (`config.toml` isolé via `CODEX_HOME`). Tout autre profil ⇒ rien à migrer.
|
||||||
}
|
if is_claude_mcp_profile(profile) {
|
||||||
let _ = migrate_claude_run_dir(project, &agent.id, profile).await;
|
let _ = migrate_claude_run_dir(project, &agent.id, profile).await;
|
||||||
|
} else if is_codex_mcp_profile(profile) {
|
||||||
|
let _ = migrate_codex_run_dir(project, &agent.id, profile).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1229,6 +1258,115 @@ fn claude_mcp_config_target(profile: &AgentProfile) -> Option<&str> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pendant Codex de [`migrate_claude_run_dir`] : répare le `config.toml` MCP isolé
|
||||||
|
/// du run dir (table `[mcp_servers.idea]`) pour que Codex, qui lit ses serveurs MCP
|
||||||
|
/// dans `$CODEX_HOME/config.toml`, voie les outils `idea_*` après un redémarrage.
|
||||||
|
/// `CODEX_HOME` est isolé au run dir au lancement (jamais le `~/.codex` global), donc
|
||||||
|
/// ce fichier appartient entièrement à IdeA : il est régénéré (clobber) dès qu'un
|
||||||
|
/// runtime réel est disponible. Best-effort, idempotent.
|
||||||
|
async fn migrate_codex_run_dir(
|
||||||
|
project: &Project,
|
||||||
|
agent_id: &AgentId,
|
||||||
|
profile: &AgentProfile,
|
||||||
|
) -> Result<(), std::io::Error> {
|
||||||
|
let run_dir = project
|
||||||
|
.root
|
||||||
|
.as_str()
|
||||||
|
.trim_end_matches(['/', '\\'])
|
||||||
|
.to_owned()
|
||||||
|
+ &format!("/.ideai/run/{agent_id}");
|
||||||
|
let run_dir_path = Path::new(&run_dir);
|
||||||
|
if tokio::fs::metadata(run_dir_path).await.is_err() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let runtime = crate::mcp_endpoint::idea_exe_path().map(|exe| McpRuntime {
|
||||||
|
exe,
|
||||||
|
endpoint: crate::mcp_endpoint::mcp_endpoint(&project.id)
|
||||||
|
.as_cli_arg()
|
||||||
|
.to_owned(),
|
||||||
|
project_id: project.id.as_uuid().simple().to_string(),
|
||||||
|
requester: agent_id.to_string(),
|
||||||
|
});
|
||||||
|
migrate_codex_mcp_config(run_dir_path, profile, runtime.as_ref()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn migrate_codex_mcp_config(
|
||||||
|
run_dir: &Path,
|
||||||
|
profile: &AgentProfile,
|
||||||
|
runtime: Option<&McpRuntime>,
|
||||||
|
) -> Result<(), std::io::Error> {
|
||||||
|
let Some((target, _home_env)) = codex_mcp_config_target(profile) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
// Sans runtime réel, seule une déclaration minimale est disponible : on ne
|
||||||
|
// régénère pas (mirror de `migrate_claude_mcp_config`).
|
||||||
|
let Some(runtime) = runtime else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let toml_path = run_dir.join(target);
|
||||||
|
let desired = mcp_server_entry_toml(profile, Some(runtime));
|
||||||
|
|
||||||
|
// `config.toml` isolé = entièrement géré par IdeA ⇒ régénération (clobber) ;
|
||||||
|
// idempotent (no-op si le contenu est déjà à jour).
|
||||||
|
match tokio::fs::read_to_string(&toml_path).await {
|
||||||
|
Ok(existing) if existing == desired => return Ok(()),
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
|
Err(err) => return Err(err),
|
||||||
|
}
|
||||||
|
if let Some(parent) = toml_path.parent() {
|
||||||
|
tokio::fs::create_dir_all(parent).await?;
|
||||||
|
}
|
||||||
|
write_atomically(&toml_path, &desired)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_codex_mcp_profile(profile: &AgentProfile) -> bool {
|
||||||
|
let is_codex = profile.structured_adapter == Some(StructuredAdapter::Codex)
|
||||||
|
|| matches!(
|
||||||
|
&profile.context_injection,
|
||||||
|
ContextInjection::ConventionFile { target }
|
||||||
|
if target
|
||||||
|
.rsplit(['/', '\\'])
|
||||||
|
.next()
|
||||||
|
.unwrap_or(target)
|
||||||
|
.eq_ignore_ascii_case("AGENTS.md")
|
||||||
|
);
|
||||||
|
is_codex && codex_mcp_config_target(profile).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn codex_mcp_config_target(profile: &AgentProfile) -> Option<(&str, &str)> {
|
||||||
|
match profile.mcp.as_ref().map(|mcp| &mcp.config) {
|
||||||
|
Some(McpConfigStrategy::TomlConfigHome { target, home_env }) => {
|
||||||
|
Some((target.as_str(), home_env.as_str()))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rend le contenu du `config.toml` Codex (table `[mcp_servers.idea]`) en réutilisant
|
||||||
|
/// l'encodeur TOML partagé [`domain::McpServerWiring::to_config_toml`] (D2) — même
|
||||||
|
/// source de wiring que la déclaration `.mcp.json` Claude, donc zéro dérive.
|
||||||
|
fn mcp_server_entry_toml(profile: &AgentProfile, runtime: Option<&McpRuntime>) -> String {
|
||||||
|
let transport = profile.mcp.as_ref().map_or(McpTransport::Stdio, |m| m.transport);
|
||||||
|
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).to_config_toml()
|
||||||
|
}
|
||||||
|
|
||||||
fn merge_claude_settings_json(existing: &str, project_root: &str) -> Option<Value> {
|
fn merge_claude_settings_json(existing: &str, project_root: &str) -> Option<Value> {
|
||||||
let mut doc = match serde_json::from_str::<Value>(existing) {
|
let mut doc = match serde_json::from_str::<Value>(existing) {
|
||||||
Ok(Value::Object(map)) => Value::Object(map),
|
Ok(Value::Object(map)) => Value::Object(map),
|
||||||
@ -3284,6 +3422,88 @@ mod mcp_e2e_loopback_tests {
|
|||||||
(Arc::new(service), sessions, mailbox)
|
(Arc::new(service), sessions, mailbox)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Codex twin of [`build_service`]: identical wiring, but the single profile
|
||||||
|
/// targets the **Codex** structured adapter with a `TomlConfigHome` MCP strategy
|
||||||
|
/// (`$CODEX_HOME/config.toml`). This is the couple the bridge guard
|
||||||
|
/// (`materializes_idea_bridge`) must now let through — proving Codex is eligible
|
||||||
|
/// for `idea_ask_agent`, exactly like Claude. No real `codex` binary is ever
|
||||||
|
/// spawned: the runtime/PTY are the same in-memory fakes.
|
||||||
|
fn build_service_codex(
|
||||||
|
contexts: FakeContexts,
|
||||||
|
) -> (
|
||||||
|
Arc<OrchestratorService>,
|
||||||
|
Arc<TerminalSessions>,
|
||||||
|
Arc<InMemoryMailbox>,
|
||||||
|
) {
|
||||||
|
let profiles = Arc::new(FakeProfiles(Arc::new(vec![AgentProfile::new(
|
||||||
|
ProfileId::from_uuid(Uuid::from_u128(9)),
|
||||||
|
"Codex CLI",
|
||||||
|
"codex",
|
||||||
|
Vec::new(),
|
||||||
|
ContextInjection::stdin(),
|
||||||
|
None,
|
||||||
|
"{agentRunDir}",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.with_structured_adapter(StructuredAdapter::Codex)
|
||||||
|
.with_mcp(McpCapability::new(
|
||||||
|
McpConfigStrategy::toml_config_home(".codex/config.toml", "CODEX_HOME").unwrap(),
|
||||||
|
McpTransport::Stdio,
|
||||||
|
))])));
|
||||||
|
let sessions = Arc::new(TerminalSessions::new());
|
||||||
|
let mailbox = Arc::new(InMemoryMailbox::new());
|
||||||
|
let bus = Arc::new(NoopBus);
|
||||||
|
let create = Arc::new(CreateAgentFromScratch::new(
|
||||||
|
Arc::new(contexts.clone()),
|
||||||
|
Arc::new(SeqIds(Mutex::new(1))),
|
||||||
|
bus.clone(),
|
||||||
|
));
|
||||||
|
let launch = Arc::new(LaunchAgent::new(
|
||||||
|
Arc::new(contexts.clone()),
|
||||||
|
Arc::clone(&profiles) as Arc<dyn ProfileStore>,
|
||||||
|
Arc::new(FakeRuntime),
|
||||||
|
Arc::new(FakeFs),
|
||||||
|
Arc::new(FakePty),
|
||||||
|
Arc::new(FakeSkills),
|
||||||
|
Arc::clone(&sessions),
|
||||||
|
bus.clone(),
|
||||||
|
Arc::new(SeqIds(Mutex::new(1))),
|
||||||
|
Arc::new(FakeRecall),
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
let list = Arc::new(ListAgents::new(Arc::new(contexts.clone())));
|
||||||
|
let close = Arc::new(CloseTerminal::new(Arc::new(FakePty), Arc::clone(&sessions)));
|
||||||
|
let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts)));
|
||||||
|
let create_skill = Arc::new(CreateSkill::new(
|
||||||
|
Arc::new(FakeSkills) as Arc<dyn SkillStore>,
|
||||||
|
Arc::new(SeqIds(Mutex::new(1))),
|
||||||
|
));
|
||||||
|
let input = Arc::new(MediatedInbox::with_pty(
|
||||||
|
Arc::clone(&mailbox),
|
||||||
|
Arc::new(SystemMillisClock),
|
||||||
|
Arc::new(FakePty) as Arc<dyn PtyPort>,
|
||||||
|
)) as Arc<dyn domain::input::InputMediator>;
|
||||||
|
let conversations = Arc::new(InMemoryConversationRegistry::new())
|
||||||
|
as Arc<dyn domain::conversation::ConversationRegistry>;
|
||||||
|
let service = OrchestratorService::new(
|
||||||
|
create,
|
||||||
|
launch,
|
||||||
|
list,
|
||||||
|
close,
|
||||||
|
update,
|
||||||
|
create_skill,
|
||||||
|
Arc::clone(&profiles) as Arc<dyn ProfileStore>,
|
||||||
|
Arc::clone(&sessions),
|
||||||
|
)
|
||||||
|
.with_input_mediator(
|
||||||
|
input,
|
||||||
|
Arc::clone(&mailbox) as Arc<dyn domain::mailbox::AgentMailbox>,
|
||||||
|
)
|
||||||
|
.with_conversations(conversations);
|
||||||
|
(Arc::new(service), sessions, mailbox)
|
||||||
|
}
|
||||||
|
|
||||||
/// Pre-seeds a live PTY terminal session for `agent_id` so `ask_agent` reuses it.
|
/// Pre-seeds a live PTY terminal session for `agent_id` so `ask_agent` reuses it.
|
||||||
fn seed_live_pty(sessions: &TerminalSessions, agent_id: AgentId, session_id: SessionId) {
|
fn seed_live_pty(sessions: &TerminalSessions, agent_id: AgentId, session_id: SessionId) {
|
||||||
sessions.insert(
|
sessions.insert(
|
||||||
@ -3519,6 +3739,101 @@ mod mcp_e2e_loopback_tests {
|
|||||||
handle.stop();
|
handle.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 2b. Codex twin of the round-trip above: the *only* difference is the target
|
||||||
|
// profile (Codex + TomlConfigHome MCP). It proves the bridge guard now lets
|
||||||
|
// a Codex target through AND that the inline ask→reply loop still completes.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ask_then_reply_round_trips_inline_over_real_loopback_codex() {
|
||||||
|
let contexts = FakeContexts::new();
|
||||||
|
let agent_id = contexts.seed_agent("architect");
|
||||||
|
let (service, sessions, mailbox) = build_service_codex(contexts);
|
||||||
|
// Target already live in the PTY registry, so the ask reuses its terminal.
|
||||||
|
seed_live_pty(
|
||||||
|
&sessions,
|
||||||
|
agent_id,
|
||||||
|
SessionId::from_uuid(Uuid::from_u128(4242)),
|
||||||
|
);
|
||||||
|
let proj = project();
|
||||||
|
let (handle, endpoint) = start_real_server(service, &proj, None);
|
||||||
|
|
||||||
|
// Connection A: the asker.
|
||||||
|
let conn_a = connect_client(&endpoint).await;
|
||||||
|
let (read_a, mut write_a) = tokio::io::split(conn_a);
|
||||||
|
let mut reader_a = BufReader::new(read_a);
|
||||||
|
write_a
|
||||||
|
.write_all(handshake_line(&project_id_arg(&proj), "agent-asker").as_bytes())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
write_a
|
||||||
|
.write_all(
|
||||||
|
tools_call_line(
|
||||||
|
7,
|
||||||
|
"idea_ask_agent",
|
||||||
|
json!({ "target": "architect", "task": "What is the answer?" }),
|
||||||
|
)
|
||||||
|
.as_bytes(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
write_a.flush().await.unwrap();
|
||||||
|
|
||||||
|
// The ask is now blocked awaiting the reply — observe the pending ticket.
|
||||||
|
tokio::time::timeout(TIMEOUT, async {
|
||||||
|
while mailbox.pending(&agent_id) == 0 {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("ask must enqueue a ticket");
|
||||||
|
|
||||||
|
// Connection B: the target rendering its result. Its handshake requester is
|
||||||
|
// the target agent's id, which the server injects as the Reply `from`.
|
||||||
|
let conn_b = connect_client(&endpoint).await;
|
||||||
|
let (read_b, mut write_b) = tokio::io::split(conn_b);
|
||||||
|
let mut reader_b = BufReader::new(read_b);
|
||||||
|
write_b
|
||||||
|
.write_all(handshake_line(&project_id_arg(&proj), &agent_id.to_string()).as_bytes())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
write_b
|
||||||
|
.write_all(
|
||||||
|
tools_call_line(8, "idea_reply", json!({ "result": "the answer is 42" }))
|
||||||
|
.as_bytes(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
write_b.flush().await.unwrap();
|
||||||
|
let reply_resp = read_one_response(&mut reader_b)
|
||||||
|
.await
|
||||||
|
.expect("a reply ack line");
|
||||||
|
assert_eq!(
|
||||||
|
reply_resp["result"]["isError"],
|
||||||
|
json!(false),
|
||||||
|
"got {reply_resp}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The asker now receives its inline result.
|
||||||
|
let resp = read_one_response(&mut reader_a)
|
||||||
|
.await
|
||||||
|
.expect("an ask response line");
|
||||||
|
assert_eq!(resp["id"], json!(7));
|
||||||
|
assert!(resp["error"].is_null(), "transport error: {resp}");
|
||||||
|
let result = &resp["result"];
|
||||||
|
assert_eq!(result["isError"], json!(false), "got {result}");
|
||||||
|
assert_eq!(
|
||||||
|
result["content"][0]["text"].as_str().expect("text block"),
|
||||||
|
"the answer is 42",
|
||||||
|
"ask reply must be returned inline over the real loopback (Codex target); got {result}"
|
||||||
|
);
|
||||||
|
|
||||||
|
drop(write_a);
|
||||||
|
drop(write_b);
|
||||||
|
handle.stop();
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// 3. idea_reply with no in-flight ask ⇒ typed tool error (isError:true), no
|
// 3. idea_reply with no in-flight ask ⇒ typed tool error (isError:true), no
|
||||||
// panic, connection stays healthy for a follow-up call.
|
// panic, connection stays healthy for a follow-up call.
|
||||||
|
|||||||
@ -57,7 +57,7 @@ fn final_chunk(s: &str) -> ReplyChunk {
|
|||||||
fn chunk_from_event_maps_text_delta() {
|
fn chunk_from_event_maps_text_delta() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
chunk_from_event(ReplyEvent::TextDelta { text: "hi".into() }),
|
chunk_from_event(ReplyEvent::TextDelta { text: "hi".into() }),
|
||||||
ReplyChunk::TextDelta { text: "hi".into() }
|
Some(ReplyChunk::TextDelta { text: "hi".into() })
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -67,9 +67,9 @@ fn chunk_from_event_maps_tool_activity() {
|
|||||||
chunk_from_event(ReplyEvent::ToolActivity {
|
chunk_from_event(ReplyEvent::ToolActivity {
|
||||||
label: "reads file".into()
|
label: "reads file".into()
|
||||||
}),
|
}),
|
||||||
ReplyChunk::ToolActivity {
|
Some(ReplyChunk::ToolActivity {
|
||||||
label: "reads file".into()
|
label: "reads file".into()
|
||||||
}
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,12 +79,19 @@ fn chunk_from_event_maps_final() {
|
|||||||
chunk_from_event(ReplyEvent::Final {
|
chunk_from_event(ReplyEvent::Final {
|
||||||
content: "done".into()
|
content: "done".into()
|
||||||
}),
|
}),
|
||||||
ReplyChunk::Final {
|
Some(ReplyChunk::Final {
|
||||||
content: "done".into()
|
content: "done".into()
|
||||||
}
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chunk_from_event_drops_heartbeat() {
|
||||||
|
// A heartbeat is a non-terminal liveness proof with no chat content (lot 1) ⇒ no
|
||||||
|
// wire chunk; the pump skips it while still draining to the Final.
|
||||||
|
assert_eq!(chunk_from_event(ReplyEvent::Heartbeat), None);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// agent_send pump behaviour: deltas* then exactly one Final (zone 2)
|
// agent_send pump behaviour: deltas* then exactly one Final (zone 2)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -94,7 +101,10 @@ fn chunk_from_event_maps_final() {
|
|||||||
/// end. This is the body of the spawned pump thread, run inline.
|
/// end. This is the body of the spawned pump thread, run inline.
|
||||||
fn pump_turn(bridge: &ChatBridge, session: &SessionId, gen: u64, events: Vec<ReplyEvent>) {
|
fn pump_turn(bridge: &ChatBridge, session: &SessionId, gen: u64, events: Vec<ReplyEvent>) {
|
||||||
for event in events {
|
for event in events {
|
||||||
let _ = bridge.send_output(session, chunk_from_event(event));
|
// Mirror the real pump: heartbeats map to no chunk and are skipped.
|
||||||
|
if let Some(chunk) = chunk_from_event(event) {
|
||||||
|
let _ = bridge.send_output(session, chunk);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
bridge.detach_if(session, gen);
|
bridge.detach_if(session, gen);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -80,8 +80,11 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
|||||||
.expect("codex reference profile is valid")
|
.expect("codex reference profile is valid")
|
||||||
.with_structured_adapter(StructuredAdapter::Codex)
|
.with_structured_adapter(StructuredAdapter::Codex)
|
||||||
.with_mcp(McpCapability::new(
|
.with_mcp(McpCapability::new(
|
||||||
McpConfigStrategy::config_file(".mcp.json")
|
// Codex lit ses serveurs MCP dans `$CODEX_HOME/config.toml`, pas `.mcp.json` :
|
||||||
.expect(".mcp.json is a valid relative MCP config target"),
|
// 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,
|
McpTransport::Stdio,
|
||||||
)),
|
)),
|
||||||
AgentProfile::new(
|
AgentProfile::new(
|
||||||
@ -157,18 +160,36 @@ mod mcp_tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn claude_and_codex_mcp_use_config_file_mcp_json() {
|
fn claude_mcp_uses_config_file_mcp_json() {
|
||||||
for slug in ["claude", "codex"] {
|
let mcp = profile("claude").mcp.expect("mcp present");
|
||||||
let mcp = profile(slug).mcp.expect("mcp present");
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
mcp.config,
|
mcp.config,
|
||||||
McpConfigStrategy::ConfigFile {
|
McpConfigStrategy::ConfigFile {
|
||||||
target: ".mcp.json".to_owned()
|
target: ".mcp.json".to_owned()
|
||||||
},
|
},
|
||||||
"profile `{slug}` should declare `.mcp.json`"
|
"Claude should declare `.mcp.json`"
|
||||||
);
|
);
|
||||||
assert_eq!(mcp.transport, McpTransport::Stdio);
|
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]
|
#[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 } => {
|
domain::profile::McpConfigStrategy::Flag { flag } => {
|
||||||
// Pass the server via a launch flag (e.g. `--mcp-config {path}`). The
|
// 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
|
// 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,
|
transport: domain::profile::McpTransport,
|
||||||
runtime: Option<&McpRuntime>,
|
runtime: Option<&McpRuntime>,
|
||||||
) -> String {
|
) -> String {
|
||||||
// Common `.mcp.json`-style shape (Claude Code et CLIs apparentées). The transport
|
mcp_server_wiring(transport, runtime).to_mcp_json()
|
||||||
// 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!(
|
/// Builds the IdeA MCP server **wiring** (`command` + `args` + transport) shared by
|
||||||
r#"{{
|
/// every materialisation format (cadrage Codex D2). It is the **single source of
|
||||||
"mcpServers": {{
|
/// truth** for *what* the bridge is launched as; the concrete bytes (`.mcp.json`
|
||||||
"idea": {{
|
/// JSON for Claude, `config.toml` TOML for Codex) are produced by the domain
|
||||||
"command": {command},
|
/// encoders [`domain::McpServerWiring::to_mcp_json`] /
|
||||||
"args": [
|
/// [`domain::McpServerWiring::to_config_toml`], so the two sites can never drift.
|
||||||
"mcp-server"{extra_args}
|
///
|
||||||
|
/// `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(),
|
||||||
],
|
],
|
||||||
"transport": "{transport_label}"
|
),
|
||||||
}}
|
None => ("idea".to_owned(), vec!["mcp-server".to_owned()]),
|
||||||
}}
|
};
|
||||||
}}
|
domain::McpServerWiring::new(command, args, transport)
|
||||||
"#
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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 an absolute path string by joining a [`ProjectPath`] with a relative
|
/// 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}")
|
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>/`
|
/// 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
|
/// (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
|
/// 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::unique_md_path;
|
||||||
pub(crate) use lifecycle::ReattachDecision;
|
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 catalogue::{reference_profile_id, reference_profiles, selectable_reference_profiles};
|
||||||
pub use inspect::{InspectConversation, InspectConversationInput, InspectConversationOutput};
|
pub use inspect::{InspectConversation, InspectConversationInput, InspectConversationOutput};
|
||||||
|
|||||||
@ -14,7 +14,10 @@
|
|||||||
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use domain::input::InputMediator;
|
||||||
|
use domain::ids::AgentId;
|
||||||
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent};
|
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
|
/// Envoie `prompt` à la session vivante puis **draine le flux de réponse jusqu'au
|
||||||
/// [`ReplyEvent::Final`]**, et retourne son contenu agrégé.
|
/// [`ReplyEvent::Final`]**, et retourne son contenu agrégé.
|
||||||
@ -39,14 +42,83 @@ pub async fn send_blocking(
|
|||||||
session: &dyn AgentSession,
|
session: &dyn AgentSession,
|
||||||
prompt: &str,
|
prompt: &str,
|
||||||
timeout: Option<Duration>,
|
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> {
|
) -> Result<String, AgentSessionError> {
|
||||||
match timeout {
|
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,
|
Ok(result) => result,
|
||||||
// La session **reste vivante** : on ne `shutdown` rien ici (§17.1).
|
// La session **reste vivante** : on ne `shutdown` rien ici (§17.1).
|
||||||
Err(_elapsed) => Err(AgentSessionError::Timeout),
|
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
|
/// 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
|
/// jusqu'à rencontrer le `Final` (et on retourne son contenu) ; si le flux
|
||||||
/// s'épuise avant, c'est un tour interrompu → erreur [`AgentSessionError::Io`].
|
/// 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(
|
async fn drain_to_final(
|
||||||
session: &dyn AgentSession,
|
session: &dyn AgentSession,
|
||||||
prompt: &str,
|
prompt: &str,
|
||||||
|
mut on_event: impl FnMut(&ReplyEvent),
|
||||||
|
mut on_signal: impl FnMut(ReadinessSignal),
|
||||||
) -> Result<String, AgentSessionError> {
|
) -> Result<String, AgentSessionError> {
|
||||||
let stream = session.send(prompt).await?;
|
let stream = session.send(prompt).await?;
|
||||||
for event in stream {
|
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 {
|
if let ReplyEvent::Final { content } = event {
|
||||||
return Ok(content);
|
return Ok(content);
|
||||||
}
|
}
|
||||||
// TextDelta / ToolActivity : ignorés par le rendez-vous synchrone.
|
// TextDelta / ToolActivity / Heartbeat : non terminaux, ignorés ici.
|
||||||
}
|
}
|
||||||
Err(AgentSessionError::Io(
|
Err(AgentSessionError::Io(
|
||||||
"le flux de réponse s'est terminé sans événement Final".to_string(),
|
"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 mod window;
|
||||||
|
|
||||||
pub use agent::{
|
pub use agent::{
|
||||||
reference_profile_id, reference_profiles, selectable_reference_profiles, send_blocking,
|
drain_with_readiness, reference_profile_id, reference_profiles, selectable_reference_profiles,
|
||||||
ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, ConfigureProfiles,
|
send_blocking, ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, ConfigureProfiles,
|
||||||
ConfigureProfilesInput, ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput,
|
ConfigureProfilesInput, ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput,
|
||||||
CreateAgentOutput, DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput,
|
CreateAgentOutput, DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput,
|
||||||
DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput,
|
DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput,
|
||||||
|
|||||||
@ -32,8 +32,9 @@ use domain::{
|
|||||||
use crate::conversation::RecordTurn;
|
use crate::conversation::RecordTurn;
|
||||||
|
|
||||||
use crate::agent::{
|
use crate::agent::{
|
||||||
CreateAgentFromScratch, CreateAgentInput, LaunchAgent, LaunchAgentInput, ListAgents,
|
drain_with_readiness, CreateAgentFromScratch, CreateAgentInput, LaunchAgent, LaunchAgentInput,
|
||||||
ListAgentsInput, McpRuntime, ReattachDecision, UpdateAgentContext, UpdateAgentContextInput,
|
ListAgents, ListAgentsInput, McpRuntime, ReattachDecision, UpdateAgentContext,
|
||||||
|
UpdateAgentContextInput,
|
||||||
};
|
};
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::orchestrator::{
|
use crate::orchestrator::{
|
||||||
@ -41,7 +42,7 @@ use crate::orchestrator::{
|
|||||||
ReadMemoryInput, WriteMemory, WriteMemoryInput,
|
ReadMemoryInput, WriteMemory, WriteMemoryInput,
|
||||||
};
|
};
|
||||||
use crate::skill::{CreateSkill, CreateSkillInput};
|
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
|
/// 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
|
/// 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
|
/// **without killing the session**, so the requester can retry. Internal and
|
||||||
/// intentionally not yet config-exposed (it may become a per-project setting
|
/// intentionally not yet config-exposed (it may become a per-project setting
|
||||||
/// without changing the contract).
|
/// 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,
|
/// Borne d'attente **en file** pour acquérir le verrou de tour d'un agent (A0,
|
||||||
/// cadrage v5 §4).
|
/// 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
|
/// [`domain::ports::AgentSessionError::Timeout`]) ; le tour en cours n'est pas
|
||||||
/// affecté. Largeur = un tour complet + sa propre file ⇒ on autorise deux tours
|
/// affecté. Largeur = un tour complet + sa propre file ⇒ on autorise deux tours
|
||||||
/// pleins d'attente.
|
/// 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
|
/// 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
|
/// 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
|
/// 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.
|
/// de `SystemTime::now()` brut ici, le temps vient du port injecté au composition root.
|
||||||
clock: Option<Arc<dyn Clock>>,
|
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
|
/// Bundle des quatre use cases C7 sous [`domain::fileguard::FileGuard`], injectés
|
||||||
@ -244,9 +273,19 @@ impl OrchestratorService {
|
|||||||
context_guard: None,
|
context_guard: None,
|
||||||
record_turn: None,
|
record_turn: None,
|
||||||
clock: 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
|
/// Branche les use cases C7 (`context.*`/`memory.*`) sous
|
||||||
/// [`domain::fileguard::FileGuard`]. Builder additif (signature de [`Self::new`]
|
/// [`domain::fileguard::FileGuard`]. Builder additif (signature de [`Self::new`]
|
||||||
/// inchangée).
|
/// 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`).
|
// 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));
|
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
|
// 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).
|
// conversation, et brancher son handle d'entrée sur le médiateur (livraison).
|
||||||
let handle = self
|
let handle = self
|
||||||
@ -786,6 +861,10 @@ impl OrchestratorService {
|
|||||||
}
|
}
|
||||||
None => Ticket::from_human(ticket_id, conversation_id, requester_label, task),
|
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);
|
let pending = input.enqueue(agent_id, ticket);
|
||||||
// Delivery is the mediator's responsibility (`InputMediator::enqueue` writes the
|
// Delivery is the mediator's responsibility (`InputMediator::enqueue` writes the
|
||||||
// turn into the bound handle). The service no longer writes the PTY directly —
|
// 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
|
// 3. Attendre la réponse, bornée. Timeout/canal fermé ⇒ retirer le ticket
|
||||||
// (cible laissée vivante) et renvoyer une erreur typée.
|
// (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)) => {
|
Ok(Ok(result)) => {
|
||||||
// Checkpoint Response (P6b, best-effort) : persister la réponse AVANT de
|
// Checkpoint Response (P6b, best-effort) : persister la réponse AVANT de
|
||||||
// déplacer `result` dans `reply_outcome`. Source = la **cible** (c'est
|
// 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.
|
/// `SubmitHumanInput` (cadrage C4 §5.3) — the **human** Envoyer path.
|
||||||
///
|
///
|
||||||
/// The operator types into IdeA's mediated input; this resolves the `User↔Agent`
|
/// 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
|
/// Binds `conversation` to `session` in both the terminal registry (fast
|
||||||
/// `session_for`) and the [`ConversationRegistry`] (domain thread state), when the
|
/// `session_for`) and the [`ConversationRegistry`] (domain thread state), when the
|
||||||
/// latter is wired. Idempotent.
|
/// latter is wired. Idempotent.
|
||||||
@ -1315,8 +1576,6 @@ impl OrchestratorService {
|
|||||||
profile_id: &ProfileId,
|
profile_id: &ProfileId,
|
||||||
target: &str,
|
target: &str,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
use domain::profile::{McpConfigStrategy, StructuredAdapter};
|
|
||||||
|
|
||||||
let Some(profile) = self
|
let Some(profile) = self
|
||||||
.profiles
|
.profiles
|
||||||
.list()
|
.list()
|
||||||
@ -1327,21 +1586,20 @@ impl OrchestratorService {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
let honours_mcp_json = matches!(
|
// Source de vérité UNIQUE (domaine) : un profil supporte le pont `idea_*` ssi
|
||||||
profile.mcp.as_ref().map(|c| &c.config),
|
// IdeA matérialise réellement sa config MCP pour la CLI qu'il pilote — Claude
|
||||||
Some(McpConfigStrategy::ConfigFile { target }) if target == ".mcp.json"
|
// via `.mcp.json`, Codex via `config.toml`/`CODEX_HOME`. Tout autre couple
|
||||||
);
|
// (y compris MCP absent) ⇒ repli fichier, pont non branché.
|
||||||
let is_claude = profile.structured_adapter == Some(StructuredAdapter::Claude);
|
if profile.materializes_idea_bridge() {
|
||||||
|
|
||||||
if honours_mcp_json && is_claude {
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(AppError::Invalid(format!(
|
Err(AppError::Invalid(format!(
|
||||||
"la cible '{target}' (profil '{}', adaptateur {:?}) ne supporte pas encore le \
|
"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 \
|
pont idea_* : la délégation inter-agents passe par un serveur MCP qu'IdeA \
|
||||||
.mcp.json, que seul un profil Claude consomme aujourd'hui. Cible un agent au \
|
matérialise pour la CLI cible, et ce profil ne déclare pas un couple \
|
||||||
profil Claude.",
|
(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
|
profile.name, profile.structured_adapter
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
@ -1383,6 +1641,54 @@ impl OrchestratorService {
|
|||||||
(profile.prompt_ready_pattern, submit)
|
(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`,
|
/// Resolves a human-friendly profile reference (slug like `claude-code`,
|
||||||
/// command like `claude`, or display name like `Claude Code`) to a configured
|
/// command like `claude`, or display name like `Claude Code`) to a configured
|
||||||
/// [`ProfileId`]. Matching is universal — never hard-coded to one AI — by
|
/// [`ProfileId`]. Matching is universal — never hard-coded to one AI — by
|
||||||
@ -1459,6 +1765,18 @@ mod tests {
|
|||||||
use domain::profile::{AgentProfile, ContextInjection};
|
use domain::profile::{AgentProfile, ContextInjection};
|
||||||
use domain::ProfileId;
|
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 {
|
fn profile(id: u128, name: &str, command: &str) -> AgentProfile {
|
||||||
AgentProfile::new(
|
AgentProfile::new(
|
||||||
ProfileId::from_uuid(uuid::Uuid::from_u128(id)),
|
ProfileId::from_uuid(uuid::Uuid::from_u128(id)),
|
||||||
|
|||||||
338
crates/application/tests/drain_with_readiness_lot1.rs
Normal file
338
crates/application/tests/drain_with_readiness_lot1.rs
Normal file
@ -0,0 +1,338 @@
|
|||||||
|
//! Lot 1 (readiness/heartbeat model-agnostique) — tests unitaires QA **indépendants**
|
||||||
|
//! du helper applicatif `drain_with_readiness`, **100 % fakes**.
|
||||||
|
//!
|
||||||
|
//! Couvre les points 5 et 6 du périmètre QA :
|
||||||
|
//!
|
||||||
|
//! - **Point 5** : un flux se terminant par `Final` appelle `mark_idle(agent)`
|
||||||
|
//! **exactement une fois** ; les `Heartbeat`/deltas/activités intermédiaires
|
||||||
|
//! n'appellent **jamais** `mark_idle`.
|
||||||
|
//! - **Point 6 (le bug d'origine)** : un agent cible qui **ne fait que renvoyer un
|
||||||
|
//! `Final`** (sans jamais appeler `idea_reply`) débloque bien la file via
|
||||||
|
//! `mark_idle` — c'est exactement la cause racine du blocage `Busy`.
|
||||||
|
//!
|
||||||
|
//! On teste au niveau `drain_with_readiness` (le rendez-vous synchrone qu'`ask_agent`
|
||||||
|
//! utilise sur la session structurée d'une cible) avec :
|
||||||
|
//! - un fake `AgentSession` scriptable (calqué sur `send_blocking_d1.rs`) ;
|
||||||
|
//! - un fake `InputMediator` qui **compte** les `mark_idle` par agent et journalise
|
||||||
|
//! l'ordre des appels, sans seconde file ni I/O.
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use application::drain_with_readiness;
|
||||||
|
use domain::ids::AgentId;
|
||||||
|
use domain::input::{AgentBusyState, InputMediator};
|
||||||
|
use domain::mailbox::{PendingReply, Ticket};
|
||||||
|
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream};
|
||||||
|
use domain::SessionId;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
fn sid(n: u128) -> SessionId {
|
||||||
|
SessionId::from_uuid(Uuid::from_u128(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn aid(n: u128) -> AgentId {
|
||||||
|
AgentId::from_uuid(Uuid::from_u128(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fake AgentSession scriptable (mono-usage), repris de send_blocking_d1.rs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
enum Script {
|
||||||
|
Stream(Vec<ReplyEvent>),
|
||||||
|
Err(AgentSessionError),
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ScriptedSession {
|
||||||
|
id: SessionId,
|
||||||
|
script: Mutex<Option<Script>>,
|
||||||
|
shutdowns: AtomicUsize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScriptedSession {
|
||||||
|
fn new(script: Script) -> Self {
|
||||||
|
Self {
|
||||||
|
id: sid(1),
|
||||||
|
script: Mutex::new(Some(script)),
|
||||||
|
shutdowns: AtomicUsize::new(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn shutdown_count(&self) -> usize {
|
||||||
|
self.shutdowns.load(Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AgentSession for ScriptedSession {
|
||||||
|
fn id(&self) -> SessionId {
|
||||||
|
self.id
|
||||||
|
}
|
||||||
|
fn conversation_id(&self) -> Option<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
|
||||||
|
let script = self
|
||||||
|
.script
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.take()
|
||||||
|
.expect("send scripté une seule fois");
|
||||||
|
match script {
|
||||||
|
Script::Stream(events) => Ok(Box::new(events.into_iter())),
|
||||||
|
Script::Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async fn shutdown(&self) -> Result<(), AgentSessionError> {
|
||||||
|
self.shutdowns.fetch_add(1, Ordering::SeqCst);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fake InputMediator : compte les mark_idle par agent + journalise les appels.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct RecordingMediator {
|
||||||
|
/// (agent, "mark_idle") dans l'ordre des appels.
|
||||||
|
calls: Mutex<Vec<(AgentId, &'static str)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecordingMediator {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
calls: Mutex::new(Vec::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn mark_idle_count(&self, agent: AgentId) -> usize {
|
||||||
|
self.calls
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.filter(|(a, kind)| *a == agent && *kind == "mark_idle")
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
fn total_calls(&self) -> usize {
|
||||||
|
self.calls.lock().unwrap().len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InputMediator for RecordingMediator {
|
||||||
|
fn enqueue(&self, _agent: AgentId, _ticket: Ticket) -> PendingReply {
|
||||||
|
// Jamais utilisé par drain_with_readiness ; un future qui ne résout pas.
|
||||||
|
PendingReply::new(Box::pin(std::future::pending()))
|
||||||
|
}
|
||||||
|
fn preempt(&self, agent: AgentId) {
|
||||||
|
self.calls.lock().unwrap().push((agent, "preempt"));
|
||||||
|
}
|
||||||
|
fn mark_idle(&self, agent: AgentId) {
|
||||||
|
self.calls.lock().unwrap().push((agent, "mark_idle"));
|
||||||
|
}
|
||||||
|
fn busy_state(&self, _agent: AgentId) -> AgentBusyState {
|
||||||
|
AgentBusyState::Idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers d'événements
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn delta(t: &str) -> ReplyEvent {
|
||||||
|
ReplyEvent::TextDelta { text: t.to_owned() }
|
||||||
|
}
|
||||||
|
fn tool(l: &str) -> ReplyEvent {
|
||||||
|
ReplyEvent::ToolActivity {
|
||||||
|
label: l.to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn heartbeat() -> ReplyEvent {
|
||||||
|
ReplyEvent::Heartbeat
|
||||||
|
}
|
||||||
|
fn final_(c: &str) -> ReplyEvent {
|
||||||
|
ReplyEvent::Final {
|
||||||
|
content: c.to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Point 6 (LE point qui compte) : un Final SEUL débloque la file via mark_idle,
|
||||||
|
// sans aucun idea_reply.
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn final_only_stream_unblocks_queue_via_mark_idle() {
|
||||||
|
// L'agent cible ne fait QUE renvoyer un Final (jamais d'idea_reply).
|
||||||
|
let agent = aid(42);
|
||||||
|
let session = ScriptedSession::new(Script::Stream(vec![final_("done")]));
|
||||||
|
let mediator = RecordingMediator::new();
|
||||||
|
|
||||||
|
let out = drain_with_readiness(&session, "tâche", None, &mediator, agent).await;
|
||||||
|
|
||||||
|
assert_eq!(out, Ok("done".to_owned()), "le Final rend bien son contenu");
|
||||||
|
assert_eq!(
|
||||||
|
mediator.mark_idle_count(agent),
|
||||||
|
1,
|
||||||
|
"le Final déterministe DOIT marquer l'agent Idle (fix de la cause racine du blocage Busy)"
|
||||||
|
);
|
||||||
|
// Aucun autre appel parasite, et la session reste vivante (pas de shutdown).
|
||||||
|
assert_eq!(mediator.total_calls(), 1);
|
||||||
|
assert_eq!(session.shutdown_count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Point 5 : exactement UN mark_idle ; les events intermédiaires n'en émettent pas.
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn intermediate_events_do_not_mark_idle_only_final_does() {
|
||||||
|
let agent = aid(7);
|
||||||
|
// Heartbeats + deltas + activité PUIS le Final.
|
||||||
|
let session = ScriptedSession::new(Script::Stream(vec![
|
||||||
|
heartbeat(),
|
||||||
|
delta("hel"),
|
||||||
|
tool("lit un fichier"),
|
||||||
|
heartbeat(),
|
||||||
|
delta("lo"),
|
||||||
|
final_("hello"),
|
||||||
|
]));
|
||||||
|
let mediator = RecordingMediator::new();
|
||||||
|
|
||||||
|
let out = drain_with_readiness(&session, "x", None, &mediator, agent).await;
|
||||||
|
|
||||||
|
assert_eq!(out, Ok("hello".to_owned()));
|
||||||
|
assert_eq!(
|
||||||
|
mediator.mark_idle_count(agent),
|
||||||
|
1,
|
||||||
|
"mark_idle exactement UNE fois (au Final), jamais sur heartbeat/delta/activité"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
mediator.total_calls(),
|
||||||
|
1,
|
||||||
|
"aucun appel mediator hormis l'unique mark_idle"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn heartbeats_alone_never_mark_idle_before_final() {
|
||||||
|
// Que des heartbeats avant le Final : aucun ne doit marquer Idle.
|
||||||
|
let agent = aid(9);
|
||||||
|
let session = ScriptedSession::new(Script::Stream(vec![
|
||||||
|
heartbeat(),
|
||||||
|
heartbeat(),
|
||||||
|
heartbeat(),
|
||||||
|
final_("fini"),
|
||||||
|
]));
|
||||||
|
let mediator = RecordingMediator::new();
|
||||||
|
|
||||||
|
let out = drain_with_readiness(&session, "x", None, &mediator, agent).await;
|
||||||
|
assert_eq!(out, Ok("fini".to_owned()));
|
||||||
|
assert_eq!(mediator.mark_idle_count(agent), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Bords : pas de Final ⇒ pas de mark_idle ; erreur de send propagée ; mauvais agent.
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stream_without_final_does_not_mark_idle_and_is_io_error() {
|
||||||
|
let agent = aid(3);
|
||||||
|
let session = ScriptedSession::new(Script::Stream(vec![heartbeat(), delta("a"), tool("b")]));
|
||||||
|
let mediator = RecordingMediator::new();
|
||||||
|
|
||||||
|
let out = drain_with_readiness(&session, "x", None, &mediator, agent).await;
|
||||||
|
assert!(
|
||||||
|
matches!(out, Err(AgentSessionError::Io(_))),
|
||||||
|
"flux épuisé sans Final ⇒ Io, obtenu {out:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
mediator.mark_idle_count(agent),
|
||||||
|
0,
|
||||||
|
"sans Final, on ne marque JAMAIS Idle (jamais de faux Idle)"
|
||||||
|
);
|
||||||
|
assert_eq!(mediator.total_calls(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn send_error_is_propagated_and_no_mark_idle() {
|
||||||
|
let agent = aid(5);
|
||||||
|
let session =
|
||||||
|
ScriptedSession::new(Script::Err(AgentSessionError::Decode("bad json".to_owned())));
|
||||||
|
let mediator = RecordingMediator::new();
|
||||||
|
|
||||||
|
let out = drain_with_readiness(&session, "x", None, &mediator, agent).await;
|
||||||
|
assert_eq!(out, Err(AgentSessionError::Decode("bad json".to_owned())));
|
||||||
|
assert_eq!(mediator.mark_idle_count(agent), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mark_idle_targets_the_drained_agent_only() {
|
||||||
|
// Le mark_idle doit porter sur l'agent passé à drain_with_readiness, pas un autre.
|
||||||
|
let drained = aid(100);
|
||||||
|
let other = aid(200);
|
||||||
|
let session = ScriptedSession::new(Script::Stream(vec![final_("ok")]));
|
||||||
|
let mediator = RecordingMediator::new();
|
||||||
|
|
||||||
|
let _ = drain_with_readiness(&session, "x", None, &mediator, drained).await;
|
||||||
|
assert_eq!(mediator.mark_idle_count(drained), 1);
|
||||||
|
assert_eq!(
|
||||||
|
mediator.mark_idle_count(other),
|
||||||
|
0,
|
||||||
|
"mark_idle ne doit cibler que l'agent drainé"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Timeout (garde-fou du tour) : Timeout renvoyé, pas de mark_idle, session vivante.
|
||||||
|
// (Le helper borne via tokio::time::timeout ; on force l'expiration avec un Final
|
||||||
|
// reporté — réutilise un fake retardé minimal local.)
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
struct DelayedSession {
|
||||||
|
delay: Duration,
|
||||||
|
shutdowns: AtomicUsize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AgentSession for DelayedSession {
|
||||||
|
fn id(&self) -> SessionId {
|
||||||
|
sid(2)
|
||||||
|
}
|
||||||
|
fn conversation_id(&self) -> Option<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
|
||||||
|
tokio::time::sleep(self.delay).await;
|
||||||
|
Ok(Box::new(vec![final_("too late")].into_iter()))
|
||||||
|
}
|
||||||
|
async fn shutdown(&self) -> Result<(), AgentSessionError> {
|
||||||
|
self.shutdowns.fetch_add(1, Ordering::SeqCst);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn timeout_returns_timeout_no_mark_idle_session_alive() {
|
||||||
|
let agent = aid(11);
|
||||||
|
let session = DelayedSession {
|
||||||
|
delay: Duration::from_secs(1),
|
||||||
|
shutdowns: AtomicUsize::new(0),
|
||||||
|
};
|
||||||
|
let mediator = RecordingMediator::new();
|
||||||
|
|
||||||
|
let out =
|
||||||
|
drain_with_readiness(&session, "x", Some(Duration::from_millis(20)), &mediator, agent).await;
|
||||||
|
assert_eq!(out, Err(AgentSessionError::Timeout));
|
||||||
|
assert_eq!(
|
||||||
|
mediator.mark_idle_count(agent),
|
||||||
|
0,
|
||||||
|
"un timeout ne marque pas Idle (le tour n'a pas rendu son Final)"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
session.shutdowns.load(Ordering::SeqCst),
|
||||||
|
0,
|
||||||
|
"timeout ne tue PAS la session (§17.1)"
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2825,3 +2825,257 @@ async fn p6b_failing_record_does_not_degrade_ask() {
|
|||||||
"no turns stored when append fails"
|
"no turns stored when append fails"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Lot 1b — auto-lancement d'une cible **froide** structurée sur le chemin `ask`
|
||||||
|
// ===========================================================================
|
||||||
|
//
|
||||||
|
// Avant le Lot 1b, `ask_agent` ne routait vers `ask_structured` que si la cible avait
|
||||||
|
// **déjà** une session structurée vivante ; une cible structurée froide tombait dans
|
||||||
|
// `ensure_live_pty` (chemin PTY) — or une cible structurée n'a pas de PTY, donc le tour
|
||||||
|
// restait bloqué `Busy` (débloquable seulement par un `idea_reply` explicite). Le Lot 1b
|
||||||
|
// démarre la session structurée via le **même** launcher que le chemin PTY (qui route
|
||||||
|
// §17.4 vers `launch_structured` et l'insère dans le registre **partagé**), puis route
|
||||||
|
// vers `ask_structured` : le `Final` déterministe de la session débloque le tour et
|
||||||
|
// marque l'agent `Idle` (`mark_idle`), **sans** `idea_reply` ni PTY.
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
|
use application::StructuredSessions;
|
||||||
|
use domain::ports::{
|
||||||
|
AgentSession, AgentSessionError, AgentSessionFactory, ReplyEvent, ReplyStream,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Session structurée fake : `send()` rend un unique [`ReplyEvent::Final`] qui **écho**
|
||||||
|
/// le prompt reçu — c'est ce `Final` que `drain_with_readiness` transforme en réponse
|
||||||
|
/// du tour **et** en `mark_idle`. Aucun `idea_reply` n'est nécessaire.
|
||||||
|
struct EchoSession {
|
||||||
|
id: SessionId,
|
||||||
|
}
|
||||||
|
#[async_trait]
|
||||||
|
impl AgentSession for EchoSession {
|
||||||
|
fn id(&self) -> SessionId {
|
||||||
|
self.id
|
||||||
|
}
|
||||||
|
fn conversation_id(&self) -> Option<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
async fn send(&self, prompt: &str) -> Result<ReplyStream, AgentSessionError> {
|
||||||
|
let stream: ReplyStream = Box::new(
|
||||||
|
vec![ReplyEvent::Final {
|
||||||
|
content: format!("structured: {prompt}"),
|
||||||
|
}]
|
||||||
|
.into_iter(),
|
||||||
|
);
|
||||||
|
Ok(stream)
|
||||||
|
}
|
||||||
|
async fn shutdown(&self) -> Result<(), AgentSessionError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fabrique fake : `supports` = profil structuré, et `start` **compte** les démarrages
|
||||||
|
/// (preuve qu'une session froide a bien été auto-lancée) en rendant une [`EchoSession`].
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
struct CountingFactory {
|
||||||
|
starts: Arc<AtomicUsize>,
|
||||||
|
next_id: Arc<Mutex<u128>>,
|
||||||
|
}
|
||||||
|
impl CountingFactory {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
starts: Arc::new(AtomicUsize::new(0)),
|
||||||
|
next_id: Arc::new(Mutex::new(9000)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn start_count(&self) -> usize {
|
||||||
|
self.starts.load(Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[async_trait]
|
||||||
|
impl AgentSessionFactory for CountingFactory {
|
||||||
|
fn supports(&self, profile: &AgentProfile) -> bool {
|
||||||
|
profile.structured_adapter.is_some()
|
||||||
|
}
|
||||||
|
async fn start(
|
||||||
|
&self,
|
||||||
|
_profile: &AgentProfile,
|
||||||
|
_ctx: &PreparedContext,
|
||||||
|
_cwd: &ProjectPath,
|
||||||
|
_session: &SessionPlan,
|
||||||
|
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
||||||
|
self.starts.fetch_add(1, Ordering::SeqCst);
|
||||||
|
let id = {
|
||||||
|
let mut n = self.next_id.lock().unwrap();
|
||||||
|
let id = SessionId::from_uuid(Uuid::from_u128(*n));
|
||||||
|
*n += 1;
|
||||||
|
id
|
||||||
|
};
|
||||||
|
Ok(Arc::new(EchoSession { id }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tout le câblage `ask` **structuré** : launcher + service voient le **même** registre
|
||||||
|
/// `StructuredSessions`, et le launcher porte la fabrique (routage §17.4).
|
||||||
|
struct StructuredAskFixture {
|
||||||
|
service: Arc<OrchestratorService>,
|
||||||
|
structured: Arc<StructuredSessions>,
|
||||||
|
factory: CountingFactory,
|
||||||
|
pty: FakePty,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn structured_ask_fixture(contexts: FakeContexts) -> StructuredAskFixture {
|
||||||
|
let profiles = Arc::new(FakeProfiles::new(vec![claude_profile()]));
|
||||||
|
let sessions = Arc::new(TerminalSessions::new());
|
||||||
|
let pty = FakePty::new(sid(777));
|
||||||
|
let bus = SpyBus::default();
|
||||||
|
let mailbox = Arc::new(TestMailbox::new());
|
||||||
|
let structured = Arc::new(StructuredSessions::new());
|
||||||
|
let factory = CountingFactory::new();
|
||||||
|
|
||||||
|
let create = Arc::new(CreateAgentFromScratch::new(
|
||||||
|
Arc::new(contexts.clone()),
|
||||||
|
Arc::new(SeqIds::new()),
|
||||||
|
Arc::new(bus.clone()),
|
||||||
|
));
|
||||||
|
// Launcher câblé **structuré** : pour un profil à `structured_adapter`, `execute`
|
||||||
|
// route §17.4 → `launch_structured`, démarre la session via la fabrique et l'insère
|
||||||
|
// dans CE registre partagé (le même `Arc` que le service ci-dessous).
|
||||||
|
let launch = Arc::new(
|
||||||
|
LaunchAgent::new(
|
||||||
|
Arc::new(contexts.clone()),
|
||||||
|
Arc::clone(&profiles) as Arc<dyn ProfileStore>,
|
||||||
|
Arc::new(FakeRuntime),
|
||||||
|
Arc::new(FakeFs),
|
||||||
|
Arc::new(pty.clone()),
|
||||||
|
Arc::new(FakeSkills),
|
||||||
|
Arc::clone(&sessions),
|
||||||
|
Arc::new(bus.clone()),
|
||||||
|
Arc::new(SeqIds::new()),
|
||||||
|
Arc::new(FakeRecall),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.with_structured(
|
||||||
|
Arc::new(factory.clone()) as Arc<dyn AgentSessionFactory>,
|
||||||
|
Arc::clone(&structured),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let list = Arc::new(ListAgents::new(Arc::new(contexts.clone())));
|
||||||
|
let close = Arc::new(CloseTerminal::new(
|
||||||
|
Arc::new(pty.clone()),
|
||||||
|
Arc::clone(&sessions),
|
||||||
|
));
|
||||||
|
let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts.clone())));
|
||||||
|
let create_skill = Arc::new(CreateSkill::new(
|
||||||
|
Arc::new(RecordingSkills::default()) as Arc<dyn SkillStore>,
|
||||||
|
Arc::new(SeqIds::new()),
|
||||||
|
));
|
||||||
|
let mediator = Arc::new(TestMediator::new(
|
||||||
|
Arc::clone(&mailbox),
|
||||||
|
Arc::new(pty.clone()) as Arc<dyn PtyPort>,
|
||||||
|
));
|
||||||
|
let conversations = Arc::new(TestConversations::new()) as Arc<dyn ConversationRegistry>;
|
||||||
|
|
||||||
|
let service = Arc::new(
|
||||||
|
OrchestratorService::new(
|
||||||
|
create,
|
||||||
|
launch,
|
||||||
|
list,
|
||||||
|
close,
|
||||||
|
update,
|
||||||
|
create_skill,
|
||||||
|
Arc::clone(&profiles) as Arc<dyn ProfileStore>,
|
||||||
|
Arc::clone(&sessions),
|
||||||
|
)
|
||||||
|
.with_input_mediator(
|
||||||
|
Arc::clone(&mediator) as Arc<dyn InputMediator>,
|
||||||
|
Arc::clone(&mailbox) as Arc<dyn AgentMailbox>,
|
||||||
|
)
|
||||||
|
.with_conversations(conversations)
|
||||||
|
.with_events(Arc::new(bus.clone()))
|
||||||
|
// Même registre que le launcher : `ask_agent` y relit la session fraîchement
|
||||||
|
// insérée par `launch_structured`.
|
||||||
|
.with_structured(Arc::clone(&structured)),
|
||||||
|
);
|
||||||
|
|
||||||
|
StructuredAskFixture {
|
||||||
|
service,
|
||||||
|
structured,
|
||||||
|
factory,
|
||||||
|
pty,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cible **froide** structurée (adaptateur Claude, aucune session vivante) : `ask_agent`
|
||||||
|
/// auto-lance sa session structurée via le launcher (factory.start appelé **une** fois),
|
||||||
|
/// puis la draine — le `Final` débloque le round-trip **sans** `idea_reply` ni PTY.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ask_cold_structured_target_autolaunches_session_and_final_unblocks() {
|
||||||
|
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
|
||||||
|
let fx = structured_ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
|
||||||
|
|
||||||
|
// Pré-condition : aucune session structurée vivante pour la cible (elle est froide).
|
||||||
|
assert!(
|
||||||
|
fx.structured.session_for_agent(&aid(1)).is_none(),
|
||||||
|
"cible structurée froide : aucune session avant l'ask"
|
||||||
|
);
|
||||||
|
|
||||||
|
let out = fx
|
||||||
|
.service
|
||||||
|
.dispatch(&project(), cmd(ASK_JSON))
|
||||||
|
.await
|
||||||
|
.expect("ask ok");
|
||||||
|
|
||||||
|
// Le `Final` de la session a rendu le contenu — sans aucun `idea_reply` dispatché.
|
||||||
|
assert_eq!(
|
||||||
|
out.reply.as_deref(),
|
||||||
|
Some("structured: Analyse §17"),
|
||||||
|
"le Final de la session auto-lancée débloque le tour"
|
||||||
|
);
|
||||||
|
// Exactement **un** démarrage de session structurée (la cible froide a été lancée).
|
||||||
|
assert_eq!(
|
||||||
|
fx.factory.start_count(),
|
||||||
|
1,
|
||||||
|
"une session structurée a été auto-lancée pour la cible froide"
|
||||||
|
);
|
||||||
|
// La session est désormais vivante dans le registre partagé (insérée par le launcher).
|
||||||
|
assert!(
|
||||||
|
fx.structured.session_for_agent(&aid(1)).is_some(),
|
||||||
|
"la session auto-lancée est enregistrée dans StructuredSessions"
|
||||||
|
);
|
||||||
|
// Chemin structuré ⇒ **aucun** PTY spawné (la cible n'a pas de terminal).
|
||||||
|
assert!(
|
||||||
|
fx.pty.spawns().is_empty(),
|
||||||
|
"cible structurée : aucun PTY spawné (chemin chat, pas terminal)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Réutilisation : une **deuxième** délégation vers la **même** cible (désormais chaude)
|
||||||
|
/// ne relance **pas** de session — elle réutilise celle insérée au premier `ask`. Prouve
|
||||||
|
/// que le `session_for_agent` court-circuite l'auto-lancement (idempotence du chemin).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ask_warm_structured_target_reuses_session_without_relaunch() {
|
||||||
|
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
|
||||||
|
let fx = structured_ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
|
||||||
|
|
||||||
|
// 1er ask : auto-lance (start_count == 1).
|
||||||
|
let _ = fx
|
||||||
|
.service
|
||||||
|
.dispatch(&project(), cmd(ASK_JSON))
|
||||||
|
.await
|
||||||
|
.expect("first ask ok");
|
||||||
|
assert_eq!(fx.factory.start_count(), 1, "1er ask auto-lance la session");
|
||||||
|
|
||||||
|
// 2e ask : cible chaude ⇒ aucune relance.
|
||||||
|
let out = fx
|
||||||
|
.service
|
||||||
|
.dispatch(&project(), cmd(ASK_JSON))
|
||||||
|
.await
|
||||||
|
.expect("second ask ok");
|
||||||
|
assert_eq!(out.reply.as_deref(), Some("structured: Analyse §17"));
|
||||||
|
assert_eq!(
|
||||||
|
fx.factory.start_count(),
|
||||||
|
1,
|
||||||
|
"cible chaude : la session est réutilisée, aucune relance"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -71,6 +71,19 @@ pub enum DomainEvent {
|
|||||||
/// `true` when a turn is in flight, `false` when the agent is idle.
|
/// `true` when a turn is in flight, `false` when the agent is idle.
|
||||||
busy: bool,
|
busy: bool,
|
||||||
},
|
},
|
||||||
|
/// An agent's **liveness** (alive/stalled) changed (lot 2, chantier
|
||||||
|
/// readiness/heartbeat). Emitted **once per transition** by the stall detector:
|
||||||
|
/// `Alive→Stalled` when no proof of liveness arrived for longer than the profile's
|
||||||
|
/// `stall_after_ms`, and `Stalled→Alive` when a late battement (delta / tool
|
||||||
|
/// activity / heartbeat) revives it or the agent returns to `Idle`. Discrete,
|
||||||
|
/// low-frequency beacon (no spam) relayed to the front so the mediated-input view
|
||||||
|
/// can badge a frozen agent. Purely advisory — the FIFO and the turn keep running.
|
||||||
|
AgentLivenessChanged {
|
||||||
|
/// The agent whose liveness changed.
|
||||||
|
agent_id: AgentId,
|
||||||
|
/// The new liveness state.
|
||||||
|
liveness: crate::input::AgentLiveness,
|
||||||
|
},
|
||||||
/// An agent's runtime profile was changed (hot-swap of the AI engine).
|
/// An agent's runtime profile was changed (hot-swap of the AI engine).
|
||||||
AgentProfileChanged {
|
AgentProfileChanged {
|
||||||
/// The agent.
|
/// The agent.
|
||||||
|
|||||||
@ -80,6 +80,29 @@ pub enum AgentBusyState {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether an agent currently shows **signs of life** during a turn (lot 2,
|
||||||
|
/// chantier readiness/heartbeat — détection « Stalled »).
|
||||||
|
///
|
||||||
|
/// Orthogonal to [`AgentBusyState`]: an agent can be `Busy` **and** `Alive` (it is
|
||||||
|
/// working, producing deltas/heartbeats) or `Busy` **and** `Stalled` (no proof of
|
||||||
|
/// liveness for longer than its profile's `stall_after_ms`). Only meaningful while
|
||||||
|
/// `Busy`; an `Idle` agent is considered `Alive` (its turn ended cleanly).
|
||||||
|
///
|
||||||
|
/// Published to the front (`AgentLivenessChanged`) **once per transition** so the UI
|
||||||
|
/// can badge a frozen agent without event spam. Derived from the per-agent
|
||||||
|
/// `last_seen_ms` heartbeat, never from parsing the model output.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", tag = "liveness")]
|
||||||
|
pub enum AgentLiveness {
|
||||||
|
/// The agent is producing proof of liveness (deltas / tool activity /
|
||||||
|
/// heartbeats) within its `stall_after_ms` window — or is idle.
|
||||||
|
Alive,
|
||||||
|
/// No proof of liveness for longer than the profile's `stall_after_ms`: the
|
||||||
|
/// agent is presumed frozen. Advisory only — the FIFO and the turn keep running
|
||||||
|
/// (a late heartbeat flips it back to [`Self::Alive`]).
|
||||||
|
Stalled,
|
||||||
|
}
|
||||||
|
|
||||||
impl AgentBusyState {
|
impl AgentBusyState {
|
||||||
/// Whether a turn is currently in flight.
|
/// Whether a turn is currently in flight.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
@ -195,6 +218,29 @@ pub trait InputMediator: Send + Sync {
|
|||||||
/// Marks `agent` free (prompt-ready or explicit signal) so its FIFO advances.
|
/// Marks `agent` free (prompt-ready or explicit signal) so its FIFO advances.
|
||||||
fn mark_idle(&self, agent: AgentId);
|
fn mark_idle(&self, agent: AgentId);
|
||||||
|
|
||||||
|
/// Records a **proof of liveness** (« battement ») for `agent` — called on every
|
||||||
|
/// non-terminal turn event (text delta, tool activity, [`crate::ports::ReplyEvent::Heartbeat`])
|
||||||
|
/// by the drain loop. Refreshes the per-agent `last_seen` timestamp so the stall
|
||||||
|
/// detector ([`crate::events::DomainEvent::AgentLivenessChanged`], lot 2) knows the
|
||||||
|
/// agent is still working; a battement arriving after a `Stalled` transition flips it
|
||||||
|
/// back to [`AgentLiveness::Alive`].
|
||||||
|
///
|
||||||
|
/// Default: no-op (a mediator that does not track liveness). The infra adapter
|
||||||
|
/// `MediatedInbox` overrides it to refresh `last_seen` and publish an
|
||||||
|
/// `AgentLivenessChanged{Stalled→Alive}` recovery on the first late battement.
|
||||||
|
fn mark_alive(&self, _agent: AgentId) {}
|
||||||
|
|
||||||
|
/// Declares the agent's **stall threshold** (its profile's
|
||||||
|
/// [`crate::profile::LivenessStrategy::stall_after_ms`]) so the stall detector knows
|
||||||
|
/// how long without a battement means "frozen" (lot 2). The orchestrator resolves it
|
||||||
|
/// from the target's profile and calls this **before** the enqueue that starts a
|
||||||
|
/// turn; the adapter stashes it and arms a fresh liveness window when the turn
|
||||||
|
/// begins. `None` ⇒ no stall detection for this agent (legacy / no `liveness` block —
|
||||||
|
/// zero regression).
|
||||||
|
///
|
||||||
|
/// Default: no-op (a mediator that does not track liveness).
|
||||||
|
fn set_stall_threshold(&self, _agent: AgentId, _stall_after_ms: Option<u32>) {}
|
||||||
|
|
||||||
/// The current [`AgentBusyState`] of `agent`.
|
/// The current [`AgentBusyState`] of `agent`.
|
||||||
fn busy_state(&self, agent: AgentId) -> AgentBusyState;
|
fn busy_state(&self, agent: AgentId) -> AgentBusyState;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,6 +47,7 @@ pub mod orchestrator;
|
|||||||
pub mod ports;
|
pub mod ports;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
pub mod project;
|
pub mod project;
|
||||||
|
pub mod readiness;
|
||||||
pub mod remote;
|
pub mod remote;
|
||||||
pub mod skill;
|
pub mod skill;
|
||||||
pub mod template;
|
pub mod template;
|
||||||
@ -74,7 +75,8 @@ pub use skill::{Skill, SkillRef, SkillScope};
|
|||||||
pub use template::{AgentTemplate, TemplateVersion};
|
pub use template::{AgentTemplate, TemplateVersion};
|
||||||
|
|
||||||
pub use profile::{
|
pub use profile::{
|
||||||
AgentProfile, ContextInjection, EmbedderProfile, EmbedderStrategy, SessionStrategy,
|
AgentProfile, ContextInjection, EmbedderProfile, EmbedderStrategy, LivenessStrategy,
|
||||||
|
McpServerWiring, SessionStrategy,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use mailbox::{AgentMailbox, MailboxError, PendingReply, Ticket, TicketId};
|
pub use mailbox::{AgentMailbox, MailboxError, PendingReply, Ticket, TicketId};
|
||||||
@ -84,7 +86,9 @@ pub use conversation::{
|
|||||||
ConversationSession, SessionRef, WaitForGraph,
|
ConversationSession, SessionRef, WaitForGraph,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use input::{AgentBusyState, InputMediator, InputSource};
|
pub use input::{AgentBusyState, AgentLiveness, InputMediator, InputSource};
|
||||||
|
|
||||||
|
pub use readiness::{ReadinessPolicy, ReadinessSignal};
|
||||||
|
|
||||||
pub use conversation_log::{
|
pub use conversation_log::{
|
||||||
ConversationLog, ConversationTurn, Handoff, HandoffStore, HandoffSummarizer,
|
ConversationLog, ConversationTurn, Handoff, HandoffStore, HandoffSummarizer,
|
||||||
|
|||||||
@ -209,6 +209,17 @@ pub enum ReplyEvent {
|
|||||||
/// Libellé humain-lisible de l'activité.
|
/// Libellé humain-lisible de l'activité.
|
||||||
label: String,
|
label: String,
|
||||||
},
|
},
|
||||||
|
/// **Preuve de vivacité non terminale** (model-agnostique) : l'agent est
|
||||||
|
/// toujours en train de travailler. Émis par l'adapter quand le moteur signale
|
||||||
|
/// un battement de cœur natif **sans contenu utile** (handshake/init, fenêtre de
|
||||||
|
/// limite de débit, début/fin de tour côté moteur…). Sert à distinguer « agent
|
||||||
|
/// vivant mais lent » d'« agent bloqué » (readiness/heartbeat, lot 1).
|
||||||
|
///
|
||||||
|
/// **Jamais terminal** : un `Heartbeat` ne clôt **pas** le flux — il s'intercale
|
||||||
|
/// comme un delta (ignoré par les consommateurs synchrones) et le flux continue
|
||||||
|
/// jusqu'au [`ReplyEvent::Final`]. Un tour comporte ≥0 `Heartbeat`, jamais
|
||||||
|
/// d'obligation d'en émettre.
|
||||||
|
Heartbeat,
|
||||||
/// **Événement terminal déterministe** d'un tour : l'adapter l'émet quand il a
|
/// **Événement terminal déterministe** d'un tour : l'adapter l'émet quand il a
|
||||||
/// lu le message `result` documenté de la CLI. Porte le contenu final agrégé.
|
/// lu le message `result` documenté de la CLI. Porte le contenu final agrégé.
|
||||||
/// Après `Final`, le flux se termine (plus aucun événement).
|
/// Après `Final`, le flux se termine (plus aucun événement).
|
||||||
@ -220,8 +231,10 @@ pub enum ReplyEvent {
|
|||||||
|
|
||||||
/// Flux borné d'événements de réponse d'UN tour (ARCHITECTURE §17.1). Se termine
|
/// Flux borné d'événements de réponse d'UN tour (ARCHITECTURE §17.1). Se termine
|
||||||
/// après le [`ReplyEvent::Final`] (ou sur erreur). Calqué sur [`OutputStream`],
|
/// après le [`ReplyEvent::Final`] (ou sur erreur). Calqué sur [`OutputStream`],
|
||||||
/// mais **typé** : deltas de texte → activités d'outil → un `Final` déterministe,
|
/// mais **typé** : deltas de texte → activités d'outil → battements de cœur*
|
||||||
/// plutôt que des octets bruts.
|
/// ([`ReplyEvent::Heartbeat`], non terminaux, en nombre quelconque et entrelacés) →
|
||||||
|
/// **un** `Final` terminal déterministe, plutôt que des octets bruts. Seul le `Final`
|
||||||
|
/// clôt le flux ; deltas, activités et heartbeats sont tous non terminaux.
|
||||||
pub type ReplyStream = Box<dyn Iterator<Item = ReplyEvent> + Send>;
|
pub type ReplyStream = Box<dyn Iterator<Item = ReplyEvent> + Send>;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -118,6 +118,61 @@ impl SessionStrategy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Réglages de **vivacité** (readiness/heartbeat) d'un profil IA — place ménagée
|
||||||
|
/// pour le lot 2 (chantier readiness/heartbeat). Donnée **déclarative** (pas de code
|
||||||
|
/// par CLI — Open/Closed), calquée sur [`SessionStrategy`] / [`McpCapability`].
|
||||||
|
///
|
||||||
|
/// Deux seuils optionnels :
|
||||||
|
/// - `stall_after_ms` : délai sans **aucune** preuve de vivacité (delta / activité /
|
||||||
|
/// `ReplyEvent::Heartbeat`) au bout duquel l'agent est présumé **bloqué**
|
||||||
|
/// (`ReadinessSignal::Stalled`) — détection au lot 2 ;
|
||||||
|
/// - `turn_timeout_ms` : durée maximale d'**un tour** avant le garde-fou
|
||||||
|
/// (`ReadinessSignal::TimedOut`) — remplacement des timeouts en dur au lot 2.
|
||||||
|
///
|
||||||
|
/// **Lot 1 : champs présents mais non consommés** — on ne fait que ménager la place
|
||||||
|
/// (le modèle de sérialisation et l'API sont figés ici pour éviter une migration au
|
||||||
|
/// lot 2). `None` (défaut, et valeur des profils existants) ⇒ comportement actuel.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct LivenessStrategy {
|
||||||
|
/// Délai (ms) sans preuve de vivacité avant de présumer l'agent bloqué.
|
||||||
|
/// `None` ⇒ pas de détection de stagnation (lot 2).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub stall_after_ms: Option<u32>,
|
||||||
|
/// Durée maximale (ms) d'un tour avant le garde-fou de timeout. `None` ⇒ pas de
|
||||||
|
/// garde-fou par profil (lot 2).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub turn_timeout_ms: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LivenessStrategy {
|
||||||
|
/// Construit une stratégie de vivacité validée (parse-don't-validate, comme
|
||||||
|
/// [`SessionStrategy::new`]).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Renvoie [`DomainError::EmptyField`] si un seuil fourni vaut `0` (un seuil de
|
||||||
|
/// `0 ms` n'a pas de sens : `None` est la façon d'exprimer « pas de seuil »).
|
||||||
|
pub const fn new(
|
||||||
|
stall_after_ms: Option<u32>,
|
||||||
|
turn_timeout_ms: Option<u32>,
|
||||||
|
) -> Result<Self, DomainError> {
|
||||||
|
if let Some(0) = stall_after_ms {
|
||||||
|
return Err(DomainError::EmptyField {
|
||||||
|
field: "liveness.stallAfterMs",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(0) = turn_timeout_ms {
|
||||||
|
return Err(DomainError::EmptyField {
|
||||||
|
field: "liveness.turnTimeoutMs",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
stall_after_ms,
|
||||||
|
turn_timeout_ms,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Adapter d'**exécution structurée** qui pilote un profil IA (ARCHITECTURE §17).
|
/// Adapter d'**exécution structurée** qui pilote un profil IA (ARCHITECTURE §17).
|
||||||
///
|
///
|
||||||
/// Déclaratif, Open/Closed (comme [`EmbedderStrategy`]) : un profil déclare quel
|
/// Déclaratif, Open/Closed (comme [`EmbedderStrategy`]) : un profil déclare quel
|
||||||
@ -193,6 +248,22 @@ pub enum McpConfigStrategy {
|
|||||||
/// Nom de la variable d'environnement.
|
/// Nom de la variable d'environnement.
|
||||||
var: String,
|
var: String,
|
||||||
},
|
},
|
||||||
|
/// Écrire un fichier de conf MCP **TOML** au chemin (relatif au run dir isolé
|
||||||
|
/// §14.1) attendu par la CLI, et pousser `home_env` (le nom d'une variable
|
||||||
|
/// d'environnement) vers le **dossier parent** de `target` pour isoler la CLI
|
||||||
|
/// de sa config globale. C'est le pendant Codex de [`Self::ConfigFile`] : Codex
|
||||||
|
/// lit ses serveurs MCP dans `$CODEX_HOME/config.toml` (défaut `~/.codex`), table
|
||||||
|
/// TOML `[mcp_servers.<nom>]`. IdeA écrit ce `config.toml` DANS le run dir de
|
||||||
|
/// l'agent et pointe `CODEX_HOME={runDir}/.codex` pour ne JAMAIS toucher au
|
||||||
|
/// `~/.codex` global (isolation par agent, miroir du `.mcp.json` de Claude).
|
||||||
|
TomlConfigHome {
|
||||||
|
/// Chemin relatif sûr du fichier `config.toml` (convention :
|
||||||
|
/// `".codex/config.toml"`).
|
||||||
|
target: String,
|
||||||
|
/// Nom de la variable d'environnement pointée sur le **dossier parent** de
|
||||||
|
/// `target` (ex. `"CODEX_HOME"`).
|
||||||
|
home_env: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl McpConfigStrategy {
|
impl McpConfigStrategy {
|
||||||
@ -227,6 +298,24 @@ impl McpConfigStrategy {
|
|||||||
crate::validation::valid_env_var(&var)?;
|
crate::validation::valid_env_var(&var)?;
|
||||||
Ok(Self::Env { var })
|
Ok(Self::Env { var })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Constructeur validé `TomlConfigHome` (pendant Codex de [`Self::config_file`]).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// - [`DomainError::PathNotRelativeSafe`] si `target` est absolu ou contient `..`
|
||||||
|
/// (même validation que [`Self::config_file`]),
|
||||||
|
/// - [`DomainError::InvalidEnvVar`] si `home_env` n'est pas un identifiant de
|
||||||
|
/// variable d'environnement valide.
|
||||||
|
pub fn toml_config_home(
|
||||||
|
target: impl Into<String>,
|
||||||
|
home_env: impl Into<String>,
|
||||||
|
) -> Result<Self, DomainError> {
|
||||||
|
let target = target.into();
|
||||||
|
let home_env = home_env.into();
|
||||||
|
crate::validation::relative_safe(&target)?;
|
||||||
|
crate::validation::valid_env_var(&home_env)?;
|
||||||
|
Ok(Self::TomlConfigHome { target, home_env })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Capacité MCP d'un profil : COMMENT déclarer le serveur MCP IdeA à cette CLI,
|
/// Capacité MCP d'un profil : COMMENT déclarer le serveur MCP IdeA à cette CLI,
|
||||||
@ -253,6 +342,128 @@ impl McpCapability {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Donnée de **wiring** du serveur MCP IdeA (`command` + `args` + `transport`),
|
||||||
|
/// factorisée pour servir de **source unique** aux deux sérialisations qui en
|
||||||
|
/// dérivaient séparément (et risquaient de diverger) : la déclaration `.mcp.json`
|
||||||
|
/// de Claude (JSON) et la table `[mcp_servers.idea]` de Codex (TOML).
|
||||||
|
///
|
||||||
|
/// Pure (aucune I/O, aucune dépendance) : les deux encodeurs construisent une
|
||||||
|
/// chaîne à la main, donc le domaine reste sans dépendance (`serde_json`/`toml`
|
||||||
|
/// non requis). Le contenu (exe, endpoint, …) est calculé par l'appelant — le
|
||||||
|
/// domaine ne fait que la **mise en forme**.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct McpServerWiring {
|
||||||
|
/// Commande à lancer (le binaire IdeA en mode `mcp-server`, ou `"idea"` en
|
||||||
|
/// déclaration minimale dégradée).
|
||||||
|
pub command: String,
|
||||||
|
/// Arguments passés à `command` (ex. `["mcp-server", "--endpoint", …]`).
|
||||||
|
pub args: Vec<String>,
|
||||||
|
/// Transport du serveur MCP (surfacé dans les deux formats).
|
||||||
|
pub transport: McpTransport,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl McpServerWiring {
|
||||||
|
/// Construit le wiring depuis ses parties.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new(command: String, args: Vec<String>, transport: McpTransport) -> Self {
|
||||||
|
Self {
|
||||||
|
command,
|
||||||
|
args,
|
||||||
|
transport,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Étiquette stable du transport, identique pour les deux formats.
|
||||||
|
#[must_use]
|
||||||
|
const fn transport_label(&self) -> &'static str {
|
||||||
|
match self.transport {
|
||||||
|
McpTransport::Stdio => "stdio",
|
||||||
|
McpTransport::Socket => "socket",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode un document **`.mcp.json`** complet (Claude Code et CLIs apparentées) :
|
||||||
|
/// `{ "mcpServers": { "idea": { command, args, transport } } }`. Chaque chaîne est
|
||||||
|
/// échappée en littéral JSON (chemins avec espaces/backslash/quotes restent
|
||||||
|
/// valides).
|
||||||
|
#[must_use]
|
||||||
|
pub fn to_mcp_json(&self) -> String {
|
||||||
|
let command = json_string(&self.command);
|
||||||
|
let args = if self.args.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
let joined = self
|
||||||
|
.args
|
||||||
|
.iter()
|
||||||
|
.map(|a| format!("\n {}", json_string(a)))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",");
|
||||||
|
format!("{joined}\n ")
|
||||||
|
};
|
||||||
|
let transport = self.transport_label();
|
||||||
|
format!(
|
||||||
|
r#"{{
|
||||||
|
"mcpServers": {{
|
||||||
|
"idea": {{
|
||||||
|
"command": {command},
|
||||||
|
"args": [{args}],
|
||||||
|
"transport": "{transport}"
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode la table **`[mcp_servers.idea]`** d'un `config.toml` Codex :
|
||||||
|
/// `command`, `args` (tableau TOML), `transport`. Chaque chaîne est échappée en
|
||||||
|
/// chaîne basique TOML (équivalent du `json_string` : espaces/backslash/quotes
|
||||||
|
/// restent valides).
|
||||||
|
#[must_use]
|
||||||
|
pub fn to_config_toml(&self) -> String {
|
||||||
|
let command = toml_string(&self.command);
|
||||||
|
let args = self
|
||||||
|
.args
|
||||||
|
.iter()
|
||||||
|
.map(|a| toml_string(a))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
let transport = self.transport_label();
|
||||||
|
format!(
|
||||||
|
"[mcp_servers.idea]\ncommand = {command}\nargs = [{args}]\ntransport = \"{transport}\"\n"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Échappe `s` en **littéral de chaîne JSON** (guillemets inclus) pour les chemins
|
||||||
|
/// exe/endpoint avec espaces, backslash ou quotes.
|
||||||
|
fn json_string(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len() + 2);
|
||||||
|
out.push('"');
|
||||||
|
for c in s.chars() {
|
||||||
|
match c {
|
||||||
|
'"' => out.push_str("\\\""),
|
||||||
|
'\\' => out.push_str("\\\\"),
|
||||||
|
'\n' => out.push_str("\\n"),
|
||||||
|
'\r' => out.push_str("\\r"),
|
||||||
|
'\t' => out.push_str("\\t"),
|
||||||
|
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
|
||||||
|
c => out.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push('"');
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Échappe `s` en **chaîne basique TOML** (guillemets inclus). Les chaînes
|
||||||
|
/// basiques TOML utilisent les mêmes séquences d'échappement que JSON pour `"`,
|
||||||
|
/// `\`, et les contrôles.
|
||||||
|
fn toml_string(s: &str) -> String {
|
||||||
|
// Le jeu d'échappement requis par une chaîne basique TOML coïncide avec celui de
|
||||||
|
// JSON pour les caractères qui nous concernent (chemins, flags).
|
||||||
|
json_string(s)
|
||||||
|
}
|
||||||
|
|
||||||
/// Declarative runtime configuration for one AI CLI.
|
/// Declarative runtime configuration for one AI CLI.
|
||||||
///
|
///
|
||||||
/// Invariants:
|
/// Invariants:
|
||||||
@ -316,6 +527,13 @@ pub struct AgentProfile {
|
|||||||
/// échappé présent dans la sortie. Un moteur regex pourra être ajouté plus tard
|
/// échappé présent dans la sortie. Un moteur regex pourra être ajouté plus tard
|
||||||
/// comme variante déclarative (Open/Closed) si le besoin se confirme.
|
/// comme variante déclarative (Open/Closed) si le besoin se confirme.
|
||||||
///
|
///
|
||||||
|
/// **Rang (chantier readiness/heartbeat, lot 1) : signal de repli n°3.** Depuis
|
||||||
|
/// l'introduction de la fin-de-tour structurée ([`crate::ports::ReplyEvent::Final`]
|
||||||
|
/// ⇒ [`crate::readiness::ReadinessSignal::TurnEnded`], signal n°1) et du signal
|
||||||
|
/// explicite `idea_reply` (n°2), ce sniff littéral est **rétrogradé** au rang de
|
||||||
|
/// repli : il ne sert plus que pour les agents **TUI/PTY sans adapter structuré**.
|
||||||
|
/// Conservé tel quel pour la rétro-compat (jamais supprimé).
|
||||||
|
///
|
||||||
/// `None` (défaut, et valeur des profils existants) ⇒ **aucune** détection par
|
/// `None` (défaut, et valeur des profils existants) ⇒ **aucune** détection par
|
||||||
/// motif : l'agent ne repasse `Idle` que sur signal explicite (`idea_reply`) ou via
|
/// motif : l'agent ne repasse `Idle` que sur signal explicite (`idea_reply`) ou via
|
||||||
/// le garde-fou du timeout par tour. Conforme au fallback « en cas de doute → reste
|
/// le garde-fou du timeout par tour. Conforme au fallback « en cas de doute → reste
|
||||||
@ -325,6 +543,15 @@ pub struct AgentProfile {
|
|||||||
/// un profil sans motif sérialise exactement comme avant.
|
/// un profil sans motif sérialise exactement comme avant.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub prompt_ready_pattern: Option<String>,
|
pub prompt_ready_pattern: Option<String>,
|
||||||
|
/// Réglages de **vivacité** (readiness/heartbeat, chantier lot 1). `None` (défaut,
|
||||||
|
/// et valeur des profils existants) ⇒ comportement actuel. **Lot 1** : champ
|
||||||
|
/// présent mais **non consommé** (place ménagée pour les seuils de stagnation /
|
||||||
|
/// timeout de tour du lot 2).
|
||||||
|
///
|
||||||
|
/// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de sérialisation :
|
||||||
|
/// un profil sans cette clé sérialise exactement comme avant.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub liveness: Option<LivenessStrategy>,
|
||||||
/// Séquence de soumission écrite **après** le texte d'une délégation pour la
|
/// Séquence de soumission écrite **après** le texte d'une délégation pour la
|
||||||
/// faire valider par la CLI (§20.3, fix Bug 1). Le portail d'écriture (front)
|
/// faire valider par la CLI (§20.3, fix Bug 1). Le portail d'écriture (front)
|
||||||
/// écrit d'abord le texte (sans `\n`, pour esquiver la détection de paste de
|
/// écrit d'abord le texte (sans `\n`, pour esquiver la détection de paste de
|
||||||
@ -483,6 +710,7 @@ impl AgentProfile {
|
|||||||
structured_adapter: None,
|
structured_adapter: None,
|
||||||
mcp: None,
|
mcp: None,
|
||||||
prompt_ready_pattern: None,
|
prompt_ready_pattern: None,
|
||||||
|
liveness: None,
|
||||||
submit_sequence: None,
|
submit_sequence: None,
|
||||||
submit_delay_ms: None,
|
submit_delay_ms: None,
|
||||||
})
|
})
|
||||||
@ -515,6 +743,15 @@ impl AgentProfile {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builder : fixe la [`LivenessStrategy`] (readiness/heartbeat, lot 1) et renvoie
|
||||||
|
/// le profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
|
||||||
|
/// profils sans réglage de vivacité ne l'appellent simplement pas.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn with_liveness(mut self, liveness: LivenessStrategy) -> Self {
|
||||||
|
self.liveness = Some(liveness);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Builder : fixe la [`Self::submit_sequence`] (§20.3, fix Bug 1) et renvoie le
|
/// Builder : fixe la [`Self::submit_sequence`] (§20.3, fix Bug 1) et renvoie le
|
||||||
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
|
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
|
||||||
/// profils qui s'en remettent au défaut `"\r"` ne l'appellent simplement pas.
|
/// profils qui s'en remettent au défaut `"\r"` ne l'appellent simplement pas.
|
||||||
@ -546,6 +783,33 @@ impl AgentProfile {
|
|||||||
pub fn is_selectable(&self) -> bool {
|
pub fn is_selectable(&self) -> bool {
|
||||||
self.structured_adapter.is_some()
|
self.structured_adapter.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **Source de vérité UNIQUE** de la whitelist des couples (adaptateur structuré
|
||||||
|
/// × stratégie MCP) qu'IdeA **matérialise réellement** pour exposer les outils
|
||||||
|
/// `idea_*` à la CLI — donc les seuls couples vers lesquels la délégation
|
||||||
|
/// inter-agents (`idea_ask_agent`/`idea_reply`) peut router une cible.
|
||||||
|
///
|
||||||
|
/// Un profil déclare bien une [`McpConfigStrategy`], mais ce n'est honoré que si
|
||||||
|
/// IdeA sait écrire la config que **cette** CLI lit nativement :
|
||||||
|
/// - `Claude` + `ConfigFile { target == ".mcp.json" }` ⇒ Claude lit `.mcp.json`
|
||||||
|
/// dans son cwd (run dir isolé) ;
|
||||||
|
/// - `Codex` + `TomlConfigHome { .. }` ⇒ Codex lit `$CODEX_HOME/config.toml`,
|
||||||
|
/// qu'IdeA isole dans le run dir via `home_env` ;
|
||||||
|
/// - tout autre couple (y compris `mcp` absent) ⇒ `false` : repli fichier
|
||||||
|
/// `.ideai/requests` + prose, le pont natif n'est pas branché.
|
||||||
|
///
|
||||||
|
/// Cette fonction centralise le critère pour que la garde applicative
|
||||||
|
/// ([`crate`] côté application) et la matérialisation ne puissent pas diverger.
|
||||||
|
#[must_use]
|
||||||
|
pub fn materializes_idea_bridge(&self) -> bool {
|
||||||
|
match (self.structured_adapter, self.mcp.as_ref().map(|c| &c.config)) {
|
||||||
|
(Some(StructuredAdapter::Claude), Some(McpConfigStrategy::ConfigFile { target })) => {
|
||||||
|
target == ".mcp.json"
|
||||||
|
}
|
||||||
|
(Some(StructuredAdapter::Codex), Some(McpConfigStrategy::TomlConfigHome { .. })) => true,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@ -832,4 +1096,200 @@ mod mcp_tests {
|
|||||||
assert_eq!(back.submit_sequence.as_deref(), Some("\r"));
|
assert_eq!(back.submit_sequence.as_deref(), Some("\r"));
|
||||||
assert_eq!(back.submit_delay_ms, Some(60));
|
assert_eq!(back.submit_delay_ms, Some(60));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- Lot 1 : liveness (readiness/heartbeat) — non-régression de sérialisation --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profile_default_has_no_liveness() {
|
||||||
|
// Profils existants (via `new`) : aucun réglage de vivacité.
|
||||||
|
assert!(profile_without_mcp().liveness.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profile_without_liveness_omits_key_in_json() {
|
||||||
|
let json = serde_json::to_string(&profile_without_mcp()).expect("serialise");
|
||||||
|
assert!(
|
||||||
|
!json.contains("liveness"),
|
||||||
|
"a profile without liveness must NOT serialise the key (zero regression); got: {json}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_json_without_liveness_deserialises_to_none() {
|
||||||
|
let legacy = r#"{
|
||||||
|
"id": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"name": "Dev",
|
||||||
|
"command": "claude",
|
||||||
|
"args": [],
|
||||||
|
"contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" },
|
||||||
|
"detect": null,
|
||||||
|
"cwdTemplate": "{agentRunDir}"
|
||||||
|
}"#;
|
||||||
|
let profile: AgentProfile = serde_json::from_str(legacy).expect("legacy deserialise");
|
||||||
|
assert!(profile.liveness.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn with_liveness_sets_and_round_trips_camel_case() {
|
||||||
|
let liveness = LivenessStrategy::new(Some(30_000), Some(600_000)).expect("valid liveness");
|
||||||
|
let profile = profile_without_mcp().with_liveness(liveness);
|
||||||
|
assert_eq!(profile.liveness, Some(liveness));
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&profile).expect("serialise");
|
||||||
|
assert!(json.contains("liveness"), "key present: {json}");
|
||||||
|
assert!(json.contains("stallAfterMs"), "camelCase field: {json}");
|
||||||
|
assert!(json.contains("turnTimeoutMs"), "camelCase field: {json}");
|
||||||
|
|
||||||
|
let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
|
||||||
|
assert_eq!(profile, back);
|
||||||
|
assert_eq!(back.liveness, Some(liveness));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn liveness_omits_unset_thresholds_in_json() {
|
||||||
|
// Un seul seuil fixé : l'autre est `None` ⇒ sa clé est omise.
|
||||||
|
let liveness = LivenessStrategy::new(None, Some(600_000)).expect("valid liveness");
|
||||||
|
let json = serde_json::to_string(&liveness).expect("serialise");
|
||||||
|
assert!(
|
||||||
|
!json.contains("stallAfterMs"),
|
||||||
|
"an unset stall threshold must be omitted; got: {json}"
|
||||||
|
);
|
||||||
|
assert!(json.contains("turnTimeoutMs"), "set threshold present: {json}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn liveness_new_rejects_zero_thresholds() {
|
||||||
|
assert!(matches!(
|
||||||
|
LivenessStrategy::new(Some(0), None).unwrap_err(),
|
||||||
|
DomainError::EmptyField { .. }
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
LivenessStrategy::new(None, Some(0)).unwrap_err(),
|
||||||
|
DomainError::EmptyField { .. }
|
||||||
|
));
|
||||||
|
// Les deux None : valide (= « aucun seuil »).
|
||||||
|
assert!(LivenessStrategy::new(None, None).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Codex : surface MCP `TomlConfigHome` (pont inter-agents Codex) ----------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toml_config_home_round_trips_with_tagged_strategy() {
|
||||||
|
let strategy = McpConfigStrategy::toml_config_home(".codex/config.toml", "CODEX_HOME")
|
||||||
|
.expect("valid toml config home");
|
||||||
|
let json = serde_json::to_string(&strategy).expect("serialise");
|
||||||
|
|
||||||
|
// Wire contract: tagged enum (`strategy` tag) + camelCase variant & fields.
|
||||||
|
assert!(
|
||||||
|
json.contains("\"strategy\":\"tomlConfigHome\""),
|
||||||
|
"tagged camelCase variant expected; got: {json}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
json.contains("\"target\":\".codex/config.toml\""),
|
||||||
|
"target field expected; got: {json}"
|
||||||
|
);
|
||||||
|
// NB: `rename_all = "camelCase"` on this enum renames *variants*, not the
|
||||||
|
// fields of a struct variant, so `home_env` stays snake_case on the wire.
|
||||||
|
assert!(
|
||||||
|
json.contains("\"home_env\":\"CODEX_HOME\""),
|
||||||
|
"home_env field expected; got: {json}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let back: McpConfigStrategy = serde_json::from_str(&json).expect("deserialise");
|
||||||
|
assert_eq!(strategy, back);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toml_config_home_rejects_absolute_and_parent_target() {
|
||||||
|
let abs = McpConfigStrategy::toml_config_home("/abs/x", "CODEX_HOME").unwrap_err();
|
||||||
|
assert!(matches!(abs, DomainError::PathNotRelativeSafe { .. }));
|
||||||
|
|
||||||
|
let parent = McpConfigStrategy::toml_config_home("../escape", "CODEX_HOME").unwrap_err();
|
||||||
|
assert!(matches!(parent, DomainError::PathNotRelativeSafe { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toml_config_home_rejects_invalid_home_env() {
|
||||||
|
// Empty home_env: first char is None ⇒ invalid identifier.
|
||||||
|
let empty = McpConfigStrategy::toml_config_home(".codex/config.toml", "").unwrap_err();
|
||||||
|
assert!(matches!(empty, DomainError::InvalidEnvVar { .. }));
|
||||||
|
|
||||||
|
// Illegal character in the env var name.
|
||||||
|
let illegal =
|
||||||
|
McpConfigStrategy::toml_config_home(".codex/config.toml", "BAD-NAME").unwrap_err();
|
||||||
|
assert!(matches!(illegal, DomainError::InvalidEnvVar { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn materializes_idea_bridge_matrix() {
|
||||||
|
// Claude + `.mcp.json` ConfigFile ⇒ bridge materialised.
|
||||||
|
let claude = profile_without_mcp()
|
||||||
|
.with_structured_adapter(StructuredAdapter::Claude)
|
||||||
|
.with_mcp(McpCapability::new(
|
||||||
|
McpConfigStrategy::config_file(".mcp.json").expect("valid target"),
|
||||||
|
McpTransport::Stdio,
|
||||||
|
));
|
||||||
|
assert!(claude.materializes_idea_bridge());
|
||||||
|
|
||||||
|
// Codex + TomlConfigHome ⇒ bridge materialised (the key point: Codex used to
|
||||||
|
// be refused before this surface existed).
|
||||||
|
let codex = profile_without_mcp()
|
||||||
|
.with_structured_adapter(StructuredAdapter::Codex)
|
||||||
|
.with_mcp(McpCapability::new(
|
||||||
|
McpConfigStrategy::toml_config_home(".codex/config.toml", "CODEX_HOME")
|
||||||
|
.expect("valid toml config home"),
|
||||||
|
McpTransport::Stdio,
|
||||||
|
));
|
||||||
|
assert!(codex.materializes_idea_bridge());
|
||||||
|
|
||||||
|
// Codex WITHOUT any MCP capability ⇒ no bridge.
|
||||||
|
let codex_no_mcp =
|
||||||
|
profile_without_mcp().with_structured_adapter(StructuredAdapter::Codex);
|
||||||
|
assert!(!codex_no_mcp.materializes_idea_bridge());
|
||||||
|
|
||||||
|
// Codex + wrong strategy (`.mcp.json` ConfigFile, Claude's shape) ⇒ no bridge.
|
||||||
|
let codex_wrong_strategy = profile_without_mcp()
|
||||||
|
.with_structured_adapter(StructuredAdapter::Codex)
|
||||||
|
.with_mcp(McpCapability::new(
|
||||||
|
McpConfigStrategy::config_file(".mcp.json").expect("valid target"),
|
||||||
|
McpTransport::Stdio,
|
||||||
|
));
|
||||||
|
assert!(!codex_wrong_strategy.materializes_idea_bridge());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mcp_server_wiring_encodes_expected_toml() {
|
||||||
|
// A command path with a space and a backslash exercises TOML escaping.
|
||||||
|
let wiring = McpServerWiring::new(
|
||||||
|
"/opt/My Apps\\idea".to_owned(),
|
||||||
|
vec![
|
||||||
|
"mcp-server".to_owned(),
|
||||||
|
"--endpoint".to_owned(),
|
||||||
|
"/tmp/sock 1".to_owned(),
|
||||||
|
],
|
||||||
|
McpTransport::Stdio,
|
||||||
|
);
|
||||||
|
let toml = wiring.to_config_toml();
|
||||||
|
|
||||||
|
// Table header present.
|
||||||
|
assert!(
|
||||||
|
toml.contains("[mcp_servers.idea]"),
|
||||||
|
"table header expected; got: {toml}"
|
||||||
|
);
|
||||||
|
// Command path escaped as a TOML basic string (backslash doubled, space kept).
|
||||||
|
assert!(
|
||||||
|
toml.contains("command = \"/opt/My Apps\\\\idea\""),
|
||||||
|
"escaped command path expected; got: {toml}"
|
||||||
|
);
|
||||||
|
// Args preserved in order, as a TOML array.
|
||||||
|
assert!(
|
||||||
|
toml.contains("args = [\"mcp-server\", \"--endpoint\", \"/tmp/sock 1\"]"),
|
||||||
|
"args in order expected; got: {toml}"
|
||||||
|
);
|
||||||
|
// Transport surfaced.
|
||||||
|
assert!(
|
||||||
|
toml.contains("transport = \"stdio\""),
|
||||||
|
"transport expected; got: {toml}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
111
crates/domain/src/readiness.rs
Normal file
111
crates/domain/src/readiness.rs
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
//! Politique de **readiness** (« fin-de-tour ») model-agnostique (chantier
|
||||||
|
//! readiness/heartbeat, lot 1).
|
||||||
|
//!
|
||||||
|
//! Objet **pur** (aucune I/O, aucune dépendance externe) qui classe un signal
|
||||||
|
//! observable d'un tour d'agent en un [`ReadinessSignal`] normalisé. Le but : que
|
||||||
|
//! l'application puisse décider de marquer un agent `Idle`
|
||||||
|
//! ([`crate::input::InputMediator::mark_idle`]) sur un **signal déterministe**
|
||||||
|
//! (`Final` du flux structuré) plutôt que de dépendre uniquement d'un `idea_reply`
|
||||||
|
//! explicite ou d'un sniff littéral de prompt PTY.
|
||||||
|
//!
|
||||||
|
//! # Hiérarchie des signaux de fin-de-tour (rappel cadrage)
|
||||||
|
//!
|
||||||
|
//! 1. **Signal n°1 — fin de tour structurée** : [`ReplyEvent::Final`] émis par
|
||||||
|
//! l'adapter (Claude `type:"result"`, Codex `agent_message`/`item.completed`).
|
||||||
|
//! Déterministe, model-agnostique ⇒ classé [`ReadinessSignal::TurnEnded`].
|
||||||
|
//! 2. **Signal n°2 — `idea_reply` explicite** : l'agent appelle l'outil MCP
|
||||||
|
//! [`crate::ports`]/délégation. Premier arrivé gagne avec le n°1.
|
||||||
|
//! 3. **Signal n°3 — repli `prompt_ready_pattern`** : sniff littéral du sigil de
|
||||||
|
//! prompt dans la sortie PTY ([`crate::profile::AgentProfile::prompt_ready_pattern`]).
|
||||||
|
//! **Rétrogradé** au rang de repli depuis ce lot : il ne sert que pour les agents
|
||||||
|
//! TUI/PTY sans adapter structuré (rétro-compat, jamais supprimé).
|
||||||
|
//!
|
||||||
|
//! Les variantes [`ReadinessSignal::Stalled`]/[`ReadinessSignal::TimedOut`] sont la
|
||||||
|
//! place réservée au **lot 2** (détection de stagnation, remplacement des timeouts) :
|
||||||
|
//! elles existent dans le vocabulaire mais ne sont **pas** produites par
|
||||||
|
//! [`ReadinessPolicy::classify`] dans ce lot.
|
||||||
|
|
||||||
|
use crate::ports::ReplyEvent;
|
||||||
|
|
||||||
|
/// Signal de readiness normalisé, model-agnostique, qu'une [`ReadinessPolicy`]
|
||||||
|
/// déduit d'un événement observable du tour.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ReadinessSignal {
|
||||||
|
/// Le tour est **déterministiquement terminé** : l'agent a rendu son `Final`.
|
||||||
|
/// C'est le signal n°1, model-agnostique — il doit réveiller le `pending` et
|
||||||
|
/// marquer l'agent `Idle`.
|
||||||
|
TurnEnded,
|
||||||
|
/// Un `idea_reply` explicite a été observé (signal n°2). N'est **pas** produit
|
||||||
|
/// par [`ReadinessPolicy::classify`] (qui ne voit que des [`ReplyEvent`]) : il
|
||||||
|
/// est porté par le chemin de délégation, présent ici pour compléter le
|
||||||
|
/// vocabulaire et le rendre explicite.
|
||||||
|
ExplicitReply,
|
||||||
|
/// Le sigil de prompt PTY (repli n°3) est apparu. Idem : non produit par
|
||||||
|
/// `classify`, présent pour nommer le signal de repli legacy.
|
||||||
|
PromptReady,
|
||||||
|
/// L'agent semble **bloqué** (aucune preuve de vivacité depuis un seuil). Place
|
||||||
|
/// réservée au **lot 2** — non produit dans ce lot.
|
||||||
|
Stalled,
|
||||||
|
/// Le garde-fou de durée de tour a expiré. Place réservée au **lot 2** — non
|
||||||
|
/// produit dans ce lot.
|
||||||
|
TimedOut,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Politique **pure** de classification d'un événement de tour en
|
||||||
|
/// [`ReadinessSignal`]. Sans état, sans I/O : un simple `match` sur le contrat de
|
||||||
|
/// port universel [`ReplyEvent`], pour que la décision « ce tour est-il fini ? »
|
||||||
|
/// vive dans le **domaine** et reste testable sans process ni réseau.
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
|
pub struct ReadinessPolicy;
|
||||||
|
|
||||||
|
impl ReadinessPolicy {
|
||||||
|
/// Classe un [`ReplyEvent`] en signal de readiness.
|
||||||
|
///
|
||||||
|
/// - [`ReplyEvent::Final`] ⇒ `Some(`[`ReadinessSignal::TurnEnded`]`)` : seul
|
||||||
|
/// événement terminal, il signe la fin de tour déterministe.
|
||||||
|
/// - [`ReplyEvent::TextDelta`] / [`ReplyEvent::ToolActivity`] /
|
||||||
|
/// [`ReplyEvent::Heartbeat`] ⇒ `None` : tous **non terminaux** (le flux
|
||||||
|
/// continue). Un heartbeat prouve la vivacité mais ne termine pas le tour.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn classify(event: &ReplyEvent) -> Option<ReadinessSignal> {
|
||||||
|
match event {
|
||||||
|
ReplyEvent::Final { .. } => Some(ReadinessSignal::TurnEnded),
|
||||||
|
ReplyEvent::TextDelta { .. }
|
||||||
|
| ReplyEvent::ToolActivity { .. }
|
||||||
|
| ReplyEvent::Heartbeat => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn final_classifies_as_turn_ended() {
|
||||||
|
let ev = ReplyEvent::Final {
|
||||||
|
content: "fini".to_owned(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
ReadinessPolicy::classify(&ev),
|
||||||
|
Some(ReadinessSignal::TurnEnded)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deltas_activities_and_heartbeats_are_non_terminal() {
|
||||||
|
assert_eq!(
|
||||||
|
ReadinessPolicy::classify(&ReplyEvent::TextDelta { text: "x".into() }),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ReadinessPolicy::classify(&ReplyEvent::ToolActivity { label: "lit".into() }),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ReadinessPolicy::classify(&ReplyEvent::Heartbeat),
|
||||||
|
None,
|
||||||
|
"un heartbeat prouve la vivacité mais ne termine JAMAIS le tour"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -25,7 +25,7 @@ use std::sync::{Arc, Mutex};
|
|||||||
|
|
||||||
use domain::events::DomainEvent;
|
use domain::events::DomainEvent;
|
||||||
use domain::ids::AgentId;
|
use domain::ids::AgentId;
|
||||||
use domain::input::{AgentBusyState, InputMediator, SubmitConfig};
|
use domain::input::{AgentBusyState, AgentLiveness, InputMediator, SubmitConfig};
|
||||||
use domain::mailbox::{AgentMailbox, PendingReply, Ticket, TicketId};
|
use domain::mailbox::{AgentMailbox, PendingReply, Ticket, TicketId};
|
||||||
use domain::ports::{EventBus, PtyHandle, PtyPort};
|
use domain::ports::{EventBus, PtyHandle, PtyPort};
|
||||||
|
|
||||||
@ -40,13 +40,34 @@ use crate::mailbox::InMemoryMailbox;
|
|||||||
/// every path (explicit `mark_idle`, prompt-ready match) stays consistent.
|
/// every path (explicit `mark_idle`, prompt-ready match) stays consistent.
|
||||||
struct BusyTracker {
|
struct BusyTracker {
|
||||||
busy: Mutex<HashMap<AgentId, AgentBusyState>>,
|
busy: Mutex<HashMap<AgentId, AgentBusyState>>,
|
||||||
|
/// Per-agent **liveness** bookkeeping (lot 2) : dernier battement observé, seuil de
|
||||||
|
/// stagnation issu du profil, et état de vivacité courant pour n'émettre
|
||||||
|
/// `AgentLivenessChanged` qu'**une fois par transition** (pas de spam).
|
||||||
|
liveness: Mutex<HashMap<AgentId, LivenessState>>,
|
||||||
events: Option<Arc<dyn EventBus>>,
|
events: Option<Arc<dyn EventBus>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// État de vivacité d'un agent maintenu par le [`BusyTracker`] (lot 2).
|
||||||
|
///
|
||||||
|
/// Pur (aucune I/O) : un timestamp `last_seen_ms` rafraîchi à chaque battement, le
|
||||||
|
/// seuil `stall_after_ms` du profil (cf. [`domain::profile::LivenessStrategy`]) et
|
||||||
|
/// l'état courant `current` pour décider d'une **transition** (et donc d'un seul
|
||||||
|
/// événement). `stall_after_ms = None` ⇒ pas de détection (comportement legacy).
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct LivenessState {
|
||||||
|
/// Epoch-millis du dernier battement (ou du démarrage du tour).
|
||||||
|
last_seen_ms: u64,
|
||||||
|
/// Seuil de stagnation du profil de l'agent. `None` ⇒ détection désactivée.
|
||||||
|
stall_after_ms: Option<u32>,
|
||||||
|
/// État de vivacité courant (pour n'émettre qu'à la transition).
|
||||||
|
current: AgentLiveness,
|
||||||
|
}
|
||||||
|
|
||||||
impl BusyTracker {
|
impl BusyTracker {
|
||||||
fn new(events: Option<Arc<dyn EventBus>>) -> Self {
|
fn new(events: Option<Arc<dyn EventBus>>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
busy: Mutex::new(HashMap::new()),
|
busy: Mutex::new(HashMap::new()),
|
||||||
|
liveness: Mutex::new(HashMap::new()),
|
||||||
events,
|
events,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -57,6 +78,100 @@ impl BusyTracker {
|
|||||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn lock_liveness(&self) -> std::sync::MutexGuard<'_, HashMap<AgentId, LivenessState>> {
|
||||||
|
self.liveness
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publie un `AgentLivenessChanged` (si un bus est câblé).
|
||||||
|
fn publish_liveness(&self, agent: AgentId, liveness: AgentLiveness) {
|
||||||
|
if let Some(events) = &self.events {
|
||||||
|
events.publish(DomainEvent::AgentLivenessChanged {
|
||||||
|
agent_id: agent,
|
||||||
|
liveness,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enregistre le seuil de stagnation du profil d'un agent au moment où son tour
|
||||||
|
/// démarre (depuis l'enqueue) : (re)initialise `last_seen` à `now` et repart d'un
|
||||||
|
/// état `Alive`. Sans seuil (`None`), l'entrée existe quand même mais le sweep ne
|
||||||
|
/// la déclarera jamais `Stalled` (zéro régression pour un profil sans liveness).
|
||||||
|
fn arm_liveness(&self, agent: AgentId, stall_after_ms: Option<u32>, now_ms: u64) {
|
||||||
|
self.lock_liveness().insert(
|
||||||
|
agent,
|
||||||
|
LivenessState {
|
||||||
|
last_seen_ms: now_ms,
|
||||||
|
stall_after_ms,
|
||||||
|
current: AgentLiveness::Alive,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rafraîchit le `last_seen` d'un agent (un **battement**) et, s'il était
|
||||||
|
/// `Stalled`, le ramène à `Alive` en émettant l'unique transition de reprise. No-op
|
||||||
|
/// si l'agent n'a pas d'entrée de vivacité armée (tour non structuré / legacy).
|
||||||
|
fn touch(&self, agent: AgentId, now_ms: u64) {
|
||||||
|
let recovered = {
|
||||||
|
let mut map = self.lock_liveness();
|
||||||
|
let Some(state) = map.get_mut(&agent) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
state.last_seen_ms = now_ms;
|
||||||
|
if state.current == AgentLiveness::Stalled {
|
||||||
|
state.current = AgentLiveness::Alive;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if recovered {
|
||||||
|
self.publish_liveness(agent, AgentLiveness::Alive);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Balaye tous les agents et, pour chacun dont le tour stagne
|
||||||
|
/// (`now - last_seen > stall_after_ms` et seuil défini), bascule `Alive→Stalled`
|
||||||
|
/// en émettant **une seule** transition. **Pure et testable sans horloge réelle** :
|
||||||
|
/// `now_ms` est passé en paramètre. Idempotente : un agent déjà `Stalled` ne ré-émet
|
||||||
|
/// pas. Un agent sans seuil (`None`) ou redevenu `Idle` n'est jamais déclaré stalled.
|
||||||
|
fn sweep_stalled(&self, now_ms: u64) {
|
||||||
|
let newly_stalled: Vec<AgentId> = {
|
||||||
|
let mut map = self.lock_liveness();
|
||||||
|
map.iter_mut()
|
||||||
|
.filter_map(|(agent, state)| {
|
||||||
|
let threshold = state.stall_after_ms?;
|
||||||
|
if state.current != AgentLiveness::Alive {
|
||||||
|
return None; // déjà Stalled : pas de ré-émission.
|
||||||
|
}
|
||||||
|
let elapsed = now_ms.saturating_sub(state.last_seen_ms);
|
||||||
|
if elapsed > u64::from(threshold) {
|
||||||
|
state.current = AgentLiveness::Stalled;
|
||||||
|
Some(*agent)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
for agent in newly_stalled {
|
||||||
|
self.publish_liveness(agent, AgentLiveness::Stalled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retire l'entrée de vivacité d'un agent (fin de tour). Si l'agent était `Stalled`,
|
||||||
|
/// la fin de tour est en soi un retour à `Alive` ⇒ on émet la transition de reprise.
|
||||||
|
fn clear_liveness(&self, agent: AgentId) {
|
||||||
|
let was_stalled = self
|
||||||
|
.lock_liveness()
|
||||||
|
.remove(&agent)
|
||||||
|
.is_some_and(|s| s.current == AgentLiveness::Stalled);
|
||||||
|
if was_stalled {
|
||||||
|
self.publish_liveness(agent, AgentLiveness::Alive);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn busy_state(&self, agent: AgentId) -> AgentBusyState {
|
fn busy_state(&self, agent: AgentId) -> AgentBusyState {
|
||||||
self.lock()
|
self.lock()
|
||||||
.get(&agent)
|
.get(&agent)
|
||||||
@ -94,6 +209,9 @@ impl BusyTracker {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Fin de tour : retirer l'entrée de vivacité (émet la reprise si l'agent
|
||||||
|
// était `Stalled`). Indépendant de `was_busy` pour rester idempotent.
|
||||||
|
self.clear_liveness(agent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -143,6 +261,10 @@ pub struct MediatedInbox {
|
|||||||
/// stashed at bind time (§20.3) and echoed on the `DelegationReady` event when a
|
/// stashed at bind time (§20.3) and echoed on the `DelegationReady` event when a
|
||||||
/// turn starts. Absent ⇒ both `None` (the front applies its defaults).
|
/// turn starts. Absent ⇒ both `None` (the front applies its defaults).
|
||||||
submit: Mutex<HashMap<AgentId, SubmitConfig>>,
|
submit: Mutex<HashMap<AgentId, SubmitConfig>>,
|
||||||
|
/// Per-agent stall threshold (`LivenessStrategy::stall_after_ms`, lot 2), stashed by
|
||||||
|
/// `set_stall_threshold` and consumed to arm a fresh liveness window on the enqueue
|
||||||
|
/// that starts a turn. Absent ⇒ `None` (no stall detection — legacy behaviour).
|
||||||
|
stall: Mutex<HashMap<AgentId, Option<u32>>>,
|
||||||
/// Agents whose prompt-ready watcher thread is already armed, so re-binding the same
|
/// Agents whose prompt-ready watcher thread is already armed, so re-binding the same
|
||||||
/// handle does not spawn a duplicate watcher (lot C5). Shared (`Arc`) because each
|
/// handle does not spawn a duplicate watcher (lot C5). Shared (`Arc`) because each
|
||||||
/// watcher thread un-arms its own entry on exit.
|
/// watcher thread un-arms its own entry on exit.
|
||||||
@ -161,6 +283,7 @@ impl MediatedInbox {
|
|||||||
pty: None,
|
pty: None,
|
||||||
handles: Mutex::new(HashMap::new()),
|
handles: Mutex::new(HashMap::new()),
|
||||||
submit: Mutex::new(HashMap::new()),
|
submit: Mutex::new(HashMap::new()),
|
||||||
|
stall: Mutex::new(HashMap::new()),
|
||||||
watched: Arc::new(Mutex::new(HashSet::new())),
|
watched: Arc::new(Mutex::new(HashSet::new())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -190,6 +313,7 @@ impl MediatedInbox {
|
|||||||
pty: Some(pty),
|
pty: Some(pty),
|
||||||
handles: Mutex::new(HashMap::new()),
|
handles: Mutex::new(HashMap::new()),
|
||||||
submit: Mutex::new(HashMap::new()),
|
submit: Mutex::new(HashMap::new()),
|
||||||
|
stall: Mutex::new(HashMap::new()),
|
||||||
watched: Arc::new(Mutex::new(HashSet::new())),
|
watched: Arc::new(Mutex::new(HashSet::new())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -215,6 +339,22 @@ impl MediatedInbox {
|
|||||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn stall(&self) -> std::sync::MutexGuard<'_, HashMap<AgentId, Option<u32>>> {
|
||||||
|
self.stall
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Détection de stagnation** (lot 2) : balaie les agents `Busy` et bascule
|
||||||
|
/// `Alive→Stalled` ceux dont le dernier battement remonte à plus de
|
||||||
|
/// `stall_after_ms`. Émet `AgentLivenessChanged` **une fois par transition**. La
|
||||||
|
/// logique de décision est **pure** ([`BusyTracker::sweep_stalled`]) : `now_ms` est
|
||||||
|
/// fourni par l'horloge injectée, donc testable sans horloge réelle. Destinée à
|
||||||
|
/// être appelée périodiquement par une tâche détenue au composition root.
|
||||||
|
pub fn sweep_stalled(&self) {
|
||||||
|
self.tracker.sweep_stalled(self.clock.now_ms());
|
||||||
|
}
|
||||||
|
|
||||||
/// The underlying mailbox (e.g. for `cancel_head` / `resolve` from the orchestrator).
|
/// The underlying mailbox (e.g. for `cancel_head` / `resolve` from the orchestrator).
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn mailbox(&self) -> Arc<InMemoryMailbox> {
|
pub fn mailbox(&self) -> Arc<InMemoryMailbox> {
|
||||||
@ -305,13 +445,22 @@ impl InputMediator for MediatedInbox {
|
|||||||
// If the agent is Idle, this enqueue starts its turn ⇒ go Busy. If already
|
// If the agent is Idle, this enqueue starts its turn ⇒ go Busy. If already
|
||||||
// Busy, we still accept (queue grows; the turn advances on mark_idle) — never
|
// Busy, we still accept (queue grows; the turn advances on mark_idle) — never
|
||||||
// reject the sender (forward fallback).
|
// reject the sender (forward fallback).
|
||||||
|
let now_ms = self.clock.now_ms();
|
||||||
let started_turn = self.tracker.start_turn(
|
let started_turn = self.tracker.start_turn(
|
||||||
agent,
|
agent,
|
||||||
AgentBusyState::Busy {
|
AgentBusyState::Busy {
|
||||||
ticket: ticket_id,
|
ticket: ticket_id,
|
||||||
since_ms: self.clock.now_ms(),
|
since_ms: now_ms,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
// Sur l'enqueue qui **démarre** un tour : armer une fenêtre de vivacité fraîche
|
||||||
|
// avec le seuil de stagnation stashé du profil (lot 2). `last_seen = now`, état
|
||||||
|
// `Alive`. Un profil sans seuil (`None`) arme une entrée jamais déclarée stalled
|
||||||
|
// (zéro régression). Un re-enqueue pendant `Busy` ne ré-arme pas (le tour court).
|
||||||
|
if started_turn {
|
||||||
|
let stall_after_ms = self.stall().get(&agent).copied().flatten();
|
||||||
|
self.tracker.arm_liveness(agent, stall_after_ms, now_ms);
|
||||||
|
}
|
||||||
// Publish only on the enqueue that **starts** a turn (Idle→Busy); a second
|
// Publish only on the enqueue that **starts** a turn (Idle→Busy); a second
|
||||||
// enqueue while Busy queues behind without re-announcing (cadrage C4 §4.2,
|
// enqueue while Busy queues behind without re-announcing (cadrage C4 §4.2,
|
||||||
// ARCHITECTURE §20). Published outside the busy mutex (the tracker released it
|
// ARCHITECTURE §20). Published outside the busy mutex (the tracker released it
|
||||||
@ -391,10 +540,23 @@ impl InputMediator for MediatedInbox {
|
|||||||
|
|
||||||
fn mark_idle(&self, agent: AgentId) {
|
fn mark_idle(&self, agent: AgentId) {
|
||||||
// Single authority (also used by the prompt-ready watcher): real Busy→Idle only,
|
// Single authority (also used by the prompt-ready watcher): real Busy→Idle only,
|
||||||
// publishing AgentBusyChanged{busy:false} once.
|
// publishing AgentBusyChanged{busy:false} once. Also clears the liveness entry
|
||||||
|
// (fin de tour) — émet la reprise si l'agent était `Stalled`.
|
||||||
self.tracker.mark_idle(agent);
|
self.tracker.mark_idle(agent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn mark_alive(&self, agent: AgentId) {
|
||||||
|
// Un battement (delta / activité / heartbeat) rafraîchit `last_seen` et ramène
|
||||||
|
// l'agent à `Alive` s'il était `Stalled` (lot 2).
|
||||||
|
self.tracker.touch(agent, self.clock.now_ms());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_stall_threshold(&self, agent: AgentId, stall_after_ms: Option<u32>) {
|
||||||
|
// Stashé par l'orchestrateur depuis le profil de la cible AVANT l'enqueue qui
|
||||||
|
// démarre le tour ; consommé par `arm_liveness` au start_turn (lot 2).
|
||||||
|
self.stall().insert(agent, stall_after_ms);
|
||||||
|
}
|
||||||
|
|
||||||
fn busy_state(&self, agent: AgentId) -> AgentBusyState {
|
fn busy_state(&self, agent: AgentId) -> AgentBusyState {
|
||||||
self.tracker.busy_state(agent)
|
self.tracker.busy_state(agent)
|
||||||
}
|
}
|
||||||
@ -985,4 +1147,205 @@ mod tests {
|
|||||||
std::thread::sleep(std::time::Duration::from_millis(60));
|
std::thread::sleep(std::time::Duration::from_millis(60));
|
||||||
assert!(inbox.busy_state(a).is_busy(), "no match ⇒ stays Busy");
|
assert!(inbox.busy_state(a).is_busy(), "no match ⇒ stays Busy");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ====================================================================
|
||||||
|
// Lot 2 — détection de stagnation (last_seen / sweep_stalled / liveness)
|
||||||
|
// ====================================================================
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use domain::input::AgentLiveness;
|
||||||
|
|
||||||
|
/// Horloge millis **mutable** (atomique) pour piloter le temps dans les tests de
|
||||||
|
/// stagnation sans horloge réelle.
|
||||||
|
struct MutClock(AtomicU64);
|
||||||
|
impl MutClock {
|
||||||
|
fn new(start: u64) -> Arc<Self> {
|
||||||
|
Arc::new(Self(AtomicU64::new(start)))
|
||||||
|
}
|
||||||
|
fn set(&self, now: u64) {
|
||||||
|
self.0.store(now, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl MillisClock for MutClock {
|
||||||
|
fn now_ms(&self) -> u64 {
|
||||||
|
self.0.load(Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construit un inbox sur l'horloge mutable + un bus enregistreur, renvoie les deux.
|
||||||
|
fn inbox_with_clock(clock: Arc<MutClock>) -> (MediatedInbox, Arc<RecordingBus>) {
|
||||||
|
let bus = Arc::new(RecordingBus::default());
|
||||||
|
let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), clock)
|
||||||
|
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
|
||||||
|
(inbox, bus)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecordingBus {
|
||||||
|
/// Toutes les transitions de vivacité publiées, dans l'ordre.
|
||||||
|
fn liveness_events(&self) -> Vec<(AgentId, AgentLiveness)> {
|
||||||
|
self.0
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
.iter()
|
||||||
|
.filter_map(|e| match e {
|
||||||
|
DomainEvent::AgentLivenessChanged { agent_id, liveness } => {
|
||||||
|
Some((*agent_id, *liveness))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// (a) absence de battement > stall_after_ms ⇒ Stalled
|
||||||
|
#[test]
|
||||||
|
fn no_heartbeat_past_threshold_marks_stalled() {
|
||||||
|
let clock = MutClock::new(1_000);
|
||||||
|
let (inbox, bus) = inbox_with_clock(Arc::clone(&clock));
|
||||||
|
let a = agent(1);
|
||||||
|
|
||||||
|
// Profil : seuil de stagnation à 30_000 ms. Armé avant le tour.
|
||||||
|
inbox.set_stall_threshold(a, Some(30_000));
|
||||||
|
inbox.enqueue(a, ticket(10, "task")); // démarre le tour à t=1_000 ⇒ last_seen=1_000
|
||||||
|
|
||||||
|
// Dans la fenêtre : pas de transition.
|
||||||
|
clock.set(1_000 + 30_000);
|
||||||
|
inbox.sweep_stalled();
|
||||||
|
assert_eq!(bus.liveness_events(), vec![], "à la limite exacte, pas encore stalled");
|
||||||
|
|
||||||
|
// Au-delà du seuil : exactement une transition Stalled.
|
||||||
|
clock.set(1_000 + 30_001);
|
||||||
|
inbox.sweep_stalled();
|
||||||
|
assert_eq!(bus.liveness_events(), vec![(a, AgentLiveness::Stalled)]);
|
||||||
|
|
||||||
|
// Idempotent : un second sweep ne ré-émet pas.
|
||||||
|
clock.set(1_000 + 60_000);
|
||||||
|
inbox.sweep_stalled();
|
||||||
|
assert_eq!(
|
||||||
|
bus.liveness_events(),
|
||||||
|
vec![(a, AgentLiveness::Stalled)],
|
||||||
|
"déjà stalled ⇒ pas de spam"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// (b) un battement dans la fenêtre réinitialise last_seen (pas de stall)
|
||||||
|
#[test]
|
||||||
|
fn heartbeat_within_window_resets_last_seen() {
|
||||||
|
let clock = MutClock::new(1_000);
|
||||||
|
let (inbox, bus) = inbox_with_clock(Arc::clone(&clock));
|
||||||
|
let a = agent(1);
|
||||||
|
inbox.set_stall_threshold(a, Some(30_000));
|
||||||
|
inbox.enqueue(a, ticket(10, "task")); // last_seen=1_000
|
||||||
|
|
||||||
|
// Battement à t=20_000 ⇒ last_seen=20_000.
|
||||||
|
clock.set(20_000);
|
||||||
|
inbox.mark_alive(a);
|
||||||
|
|
||||||
|
// À t=45_000 : 45_000 - 20_000 = 25_000 < 30_000 ⇒ toujours Alive.
|
||||||
|
clock.set(45_000);
|
||||||
|
inbox.sweep_stalled();
|
||||||
|
assert_eq!(
|
||||||
|
bus.liveness_events(),
|
||||||
|
vec![],
|
||||||
|
"un battement a réinitialisé la fenêtre ⇒ pas de stall"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// (d) AgentLivenessChanged émis une seule fois par transition (Alive→Stalled→Alive)
|
||||||
|
#[test]
|
||||||
|
fn liveness_event_once_per_transition() {
|
||||||
|
let clock = MutClock::new(0);
|
||||||
|
let (inbox, bus) = inbox_with_clock(Arc::clone(&clock));
|
||||||
|
let a = agent(1);
|
||||||
|
inbox.set_stall_threshold(a, Some(10_000));
|
||||||
|
inbox.enqueue(a, ticket(10, "t")); // last_seen=0
|
||||||
|
|
||||||
|
// Stall.
|
||||||
|
clock.set(10_001);
|
||||||
|
inbox.sweep_stalled();
|
||||||
|
// Sweeps répétés : pas de ré-émission.
|
||||||
|
inbox.sweep_stalled();
|
||||||
|
inbox.sweep_stalled();
|
||||||
|
assert_eq!(bus.liveness_events(), vec![(a, AgentLiveness::Stalled)]);
|
||||||
|
|
||||||
|
// Battement tardif ⇒ une seule reprise Alive.
|
||||||
|
clock.set(20_000);
|
||||||
|
inbox.mark_alive(a);
|
||||||
|
inbox.mark_alive(a); // second battement : déjà Alive ⇒ rien.
|
||||||
|
assert_eq!(
|
||||||
|
bus.liveness_events(),
|
||||||
|
vec![(a, AgentLiveness::Stalled), (a, AgentLiveness::Alive)]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-stall possible après reprise (nouvelle transition).
|
||||||
|
clock.set(20_000 + 10_001);
|
||||||
|
inbox.sweep_stalled();
|
||||||
|
assert_eq!(
|
||||||
|
bus.liveness_events(),
|
||||||
|
vec![
|
||||||
|
(a, AgentLiveness::Stalled),
|
||||||
|
(a, AgentLiveness::Alive),
|
||||||
|
(a, AgentLiveness::Stalled),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// mark_idle (fin de tour) sur un agent Stalled émet la reprise et retire l'entrée.
|
||||||
|
#[test]
|
||||||
|
fn mark_idle_on_stalled_emits_recovery_and_clears() {
|
||||||
|
let clock = MutClock::new(0);
|
||||||
|
let (inbox, bus) = inbox_with_clock(Arc::clone(&clock));
|
||||||
|
let a = agent(1);
|
||||||
|
inbox.set_stall_threshold(a, Some(5_000));
|
||||||
|
inbox.enqueue(a, ticket(10, "t"));
|
||||||
|
clock.set(5_001);
|
||||||
|
inbox.sweep_stalled();
|
||||||
|
assert_eq!(bus.liveness_events(), vec![(a, AgentLiveness::Stalled)]);
|
||||||
|
|
||||||
|
inbox.mark_idle(a); // fin de tour ⇒ reprise Alive + entrée retirée.
|
||||||
|
assert_eq!(
|
||||||
|
bus.liveness_events(),
|
||||||
|
vec![(a, AgentLiveness::Stalled), (a, AgentLiveness::Alive)]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Plus d'entrée : un sweep ultérieur ne ré-émet rien (même très tard).
|
||||||
|
clock.set(1_000_000);
|
||||||
|
inbox.sweep_stalled();
|
||||||
|
assert_eq!(
|
||||||
|
bus.liveness_events(),
|
||||||
|
vec![(a, AgentLiveness::Stalled), (a, AgentLiveness::Alive)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// (e) agent sans LivenessStrategy (stall_after_ms = None) ⇒ comportement legacy :
|
||||||
|
// jamais Stalled, aucun AgentLivenessChanged.
|
||||||
|
#[test]
|
||||||
|
fn agent_without_threshold_is_never_stalled() {
|
||||||
|
let clock = MutClock::new(0);
|
||||||
|
let (inbox, bus) = inbox_with_clock(Arc::clone(&clock));
|
||||||
|
let a = agent(1);
|
||||||
|
// Pas de set_stall_threshold (ou None) : armé sans seuil au start_turn.
|
||||||
|
inbox.enqueue(a, ticket(10, "t"));
|
||||||
|
clock.set(10_000_000); // très loin dans le futur.
|
||||||
|
inbox.sweep_stalled();
|
||||||
|
assert_eq!(
|
||||||
|
bus.liveness_events(),
|
||||||
|
vec![],
|
||||||
|
"sans seuil ⇒ jamais stalled (zéro régression)"
|
||||||
|
);
|
||||||
|
// Et un mark_alive sur un agent jamais stalled n'émet rien non plus.
|
||||||
|
inbox.mark_alive(a);
|
||||||
|
assert_eq!(bus.liveness_events(), vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un battement n'a aucun effet sur un agent dont aucune entrée n'est armée.
|
||||||
|
#[test]
|
||||||
|
fn mark_alive_on_unarmed_agent_is_noop() {
|
||||||
|
let clock = MutClock::new(0);
|
||||||
|
let (inbox, bus) = inbox_with_clock(Arc::clone(&clock));
|
||||||
|
let a = agent(1);
|
||||||
|
inbox.mark_alive(a); // jamais de tour démarré.
|
||||||
|
inbox.sweep_stalled();
|
||||||
|
assert_eq!(bus.liveness_events(), vec![]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,10 +47,10 @@ pub struct ParsedLine {
|
|||||||
/// Le flux est du **JSONL** (un objet JSON par ligne). Types réels :
|
/// Le flux est du **JSONL** (un objet JSON par ligne). Types réels :
|
||||||
///
|
///
|
||||||
/// - `{"type":"system","subtype":"init","session_id":"<uuid>","cwd":…,"tools":…,…}`
|
/// - `{"type":"system","subtype":"init","session_id":"<uuid>","cwd":…,"tools":…,…}`
|
||||||
/// ⇒ capture le `session_id` (= id de conversation pour la reprise), **aucun**
|
/// ⇒ capture le `session_id` (= id de conversation pour la reprise) **et** émet un
|
||||||
/// événement émis.
|
/// [`ReplyEvent::Heartbeat`] (preuve de vivacité non terminale : la CLI a démarré).
|
||||||
/// - `{"type":"rate_limit_event","rate_limit_info":{…},"session_id":"…"}`
|
/// - `{"type":"rate_limit_event","rate_limit_info":{…},"session_id":"…"}`
|
||||||
/// ⇒ **ignoré** (comme tout `type` inconnu).
|
/// ⇒ [`ReplyEvent::Heartbeat`] (pas de contenu, mais le moteur est vivant).
|
||||||
/// - `{"type":"assistant","message":{"role":"assistant","content":[
|
/// - `{"type":"assistant","message":{"role":"assistant","content":[
|
||||||
/// {"type":"text","text":"…"} | {"type":"tool_use","name":"…", …}
|
/// {"type":"text","text":"…"} | {"type":"tool_use","name":"…", …}
|
||||||
/// ], …},"session_id":"…","parent_tool_use_id":null}`
|
/// ], …},"session_id":"…","parent_tool_use_id":null}`
|
||||||
@ -81,7 +81,12 @@ pub fn parse_event(line: &str) -> Result<ParsedLine, AgentSessionError> {
|
|||||||
.map(str::to_owned);
|
.map(str::to_owned);
|
||||||
|
|
||||||
let events = match value.get("type").and_then(Value::as_str) {
|
let events = match value.get("type").and_then(Value::as_str) {
|
||||||
Some("system") => Vec::new(), // init/handshake : on ne capte que le session_id.
|
// init/handshake : on capte le session_id ET on émet un battement de cœur
|
||||||
|
// (preuve de vivacité non terminale : la CLI a démarré et répond).
|
||||||
|
Some("system") => vec![ReplyEvent::Heartbeat],
|
||||||
|
// Fenêtre de limite de débit : pas de contenu, mais le moteur est vivant ⇒
|
||||||
|
// battement de cœur (readiness/heartbeat lot 1), plus ignoré.
|
||||||
|
Some("rate_limit_event") => vec![ReplyEvent::Heartbeat],
|
||||||
Some("assistant") => assistant_events(&value),
|
Some("assistant") => assistant_events(&value),
|
||||||
Some("result") => value
|
Some("result") => value
|
||||||
.get("result")
|
.get("result")
|
||||||
@ -92,7 +97,7 @@ pub fn parse_event(line: &str) -> Result<ParsedLine, AgentSessionError> {
|
|||||||
}]
|
}]
|
||||||
})
|
})
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
_ => Vec::new(), // type inconnu / non pertinent (rate_limit_event, …) : ignoré.
|
_ => Vec::new(), // type inconnu / non pertinent : ignoré (robustesse).
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(ParsedLine { events, session_id })
|
Ok(ParsedLine { events, session_id })
|
||||||
@ -216,13 +221,22 @@ impl AgentSession for ClaudeSdkSession {
|
|||||||
|
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
let mut captured_id = None;
|
let mut captured_id = None;
|
||||||
for line in &raw_lines {
|
'lines: for line in &raw_lines {
|
||||||
let parsed = parse_event(line)?;
|
let parsed = parse_event(line)?;
|
||||||
if let Some(id) = parsed.session_id {
|
if let Some(id) = parsed.session_id {
|
||||||
captured_id = Some(id);
|
captured_id = Some(id);
|
||||||
}
|
}
|
||||||
// Aplatit : une ligne `assistant` multi-blocs rend plusieurs événements.
|
// Aplatit : une ligne `assistant` multi-blocs rend plusieurs événements.
|
||||||
events.extend(parsed.events);
|
// Le `Final` est **terminal** (contrat de port) : on arrête d'émettre dès
|
||||||
|
// qu'on l'a vu, pour qu'aucun heartbeat de fin (`rate_limit_event` tardif…)
|
||||||
|
// ne le suive dans le flux.
|
||||||
|
for event in parsed.events {
|
||||||
|
let is_final = matches!(event, ReplyEvent::Final { .. });
|
||||||
|
events.push(event);
|
||||||
|
if is_final {
|
||||||
|
break 'lines;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Persiste le session_id capté (pivot de reprise) avant de rendre le flux.
|
// Persiste le session_id capté (pivot de reprise) avant de rendre le flux.
|
||||||
if let Some(id) = captured_id {
|
if let Some(id) = captured_id {
|
||||||
|
|||||||
@ -43,13 +43,13 @@ pub struct ParsedLine {
|
|||||||
///
|
///
|
||||||
/// - `{"type":"thread.started","thread_id":"<id>"}` ⇒ capte le `thread_id`
|
/// - `{"type":"thread.started","thread_id":"<id>"}` ⇒ capte le `thread_id`
|
||||||
/// (= id de conversation pour la reprise), **aucun** événement émis.
|
/// (= id de conversation pour la reprise), **aucun** événement émis.
|
||||||
/// - `{"type":"turn.started"}` ⇒ ignoré.
|
/// - `{"type":"turn.started"}` ⇒ [`ReplyEvent::Heartbeat`] (vivacité non terminale).
|
||||||
/// - `{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"…"}}`
|
/// - `{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"…"}}`
|
||||||
/// ⇒ si `item.type=="agent_message"` ⇒ [`ReplyEvent::Final`] (`content` = `item.text`,
|
/// ⇒ si `item.type=="agent_message"` ⇒ [`ReplyEvent::Final`] (`content` = `item.text`,
|
||||||
/// c'est la réponse) ; sinon (`reasoning`/`command`/autre) ⇒
|
/// c'est la réponse) ; sinon (`reasoning`/`command`/autre) ⇒
|
||||||
/// [`ReplyEvent::ToolActivity`] (`label` = `item.type`).
|
/// [`ReplyEvent::ToolActivity`] (`label` = `item.type`).
|
||||||
/// - `{"type":"turn.completed","usage":{…}}` ⇒ ignoré (le `Final` vient de
|
/// - `{"type":"turn.completed","usage":{…}}` ⇒ [`ReplyEvent::Heartbeat`] (le `Final`
|
||||||
/// l'`agent_message`).
|
/// vient de l'`agent_message`, pas de `turn.completed`).
|
||||||
///
|
///
|
||||||
/// Ligne vide ⇒ ignorée ; type inconnu ⇒ ignoré sans erreur ; JSON illisible ⇒
|
/// Ligne vide ⇒ ignorée ; type inconnu ⇒ ignoré sans erreur ; JSON illisible ⇒
|
||||||
/// [`AgentSessionError::Decode`] (jamais de JSON brut propagé).
|
/// [`AgentSessionError::Decode`] (jamais de JSON brut propagé).
|
||||||
@ -75,6 +75,10 @@ pub fn parse_event(line: &str) -> Result<ParsedLine, AgentSessionError> {
|
|||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.map(str::to_owned);
|
.map(str::to_owned);
|
||||||
}
|
}
|
||||||
|
// Début/fin de tour côté moteur : pas de contenu, mais preuve de vivacité ⇒
|
||||||
|
// battement de cœur non terminal (readiness/heartbeat lot 1). Le `Final` vient
|
||||||
|
// toujours de l'`agent_message`, jamais de `turn.completed`.
|
||||||
|
Some("turn.started") | Some("turn.completed") => events.push(ReplyEvent::Heartbeat),
|
||||||
Some("item.completed") => {
|
Some("item.completed") => {
|
||||||
if let Some(item) = value.get("item") {
|
if let Some(item) = value.get("item") {
|
||||||
match item.get("type").and_then(Value::as_str) {
|
match item.get("type").and_then(Value::as_str) {
|
||||||
@ -94,7 +98,7 @@ pub fn parse_event(line: &str) -> Result<ParsedLine, AgentSessionError> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// turn.started / turn.completed / type inconnu : ignoré (robustesse).
|
// type inconnu : ignoré (robustesse).
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -192,12 +196,21 @@ impl AgentSession for CodexExecSession {
|
|||||||
|
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
let mut captured_id = None;
|
let mut captured_id = None;
|
||||||
for line in &raw_lines {
|
'lines: for line in &raw_lines {
|
||||||
let parsed = parse_event(line)?;
|
let parsed = parse_event(line)?;
|
||||||
if let Some(id) = parsed.conversation_id {
|
if let Some(id) = parsed.conversation_id {
|
||||||
captured_id = Some(id);
|
captured_id = Some(id);
|
||||||
}
|
}
|
||||||
events.extend(parsed.events);
|
// Le `Final` est **terminal** (contrat de port) : on arrête d'émettre dès
|
||||||
|
// qu'on l'a vu, pour qu'aucun heartbeat de fin (`turn.completed` postérieur)
|
||||||
|
// ne le suive dans le flux.
|
||||||
|
for event in parsed.events {
|
||||||
|
let is_final = matches!(event, ReplyEvent::Final { .. });
|
||||||
|
events.push(event);
|
||||||
|
if is_final {
|
||||||
|
break 'lines;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let Some(id) = captured_id {
|
if let Some(id) = captured_id {
|
||||||
*self.conversation_id.lock().expect("mutex sain") = Some(id);
|
*self.conversation_id.lock().expect("mutex sain") = Some(id);
|
||||||
|
|||||||
@ -208,14 +208,17 @@ pub(crate) mod harness {
|
|||||||
}
|
}
|
||||||
other => panic!("le dernier événement doit être Final, vu: {other:?}"),
|
other => panic!("le dernier événement doit être Final, vu: {other:?}"),
|
||||||
}
|
}
|
||||||
// Les événements avant le Final ne sont que des deltas / activités.
|
// Les événements avant le Final ne sont que des deltas / activités / heartbeats
|
||||||
|
// (tous **non terminaux** ; le heartbeat est une preuve de vivacité, lot 1).
|
||||||
for e in &events[..events.len() - 1] {
|
for e in &events[..events.len() - 1] {
|
||||||
assert!(
|
assert!(
|
||||||
matches!(
|
matches!(
|
||||||
e,
|
e,
|
||||||
ReplyEvent::TextDelta { .. } | ReplyEvent::ToolActivity { .. }
|
ReplyEvent::TextDelta { .. }
|
||||||
|
| ReplyEvent::ToolActivity { .. }
|
||||||
|
| ReplyEvent::Heartbeat
|
||||||
),
|
),
|
||||||
"avant le Final, seuls deltas/activités sont permis, vu: {e:?}"
|
"avant le Final, seuls deltas/activités/heartbeats sont permis, vu: {e:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -106,23 +106,24 @@ mod tests {
|
|||||||
// -- parse_event Claude (format RÉEL vérifié 2026-06-09) --------------
|
// -- parse_event Claude (format RÉEL vérifié 2026-06-09) --------------
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn claude_parse_init_captures_session_id_without_event() {
|
fn claude_parse_init_captures_session_id_and_heartbeats() {
|
||||||
let parsed = claude::parse_event(
|
let parsed = claude::parse_event(
|
||||||
r#"{"type":"system","subtype":"init","session_id":"conv-123","cwd":"/tmp","tools":[],"model":"claude-opus-4-8"}"#,
|
r#"{"type":"system","subtype":"init","session_id":"conv-123","cwd":"/tmp","tools":[],"model":"claude-opus-4-8"}"#,
|
||||||
)
|
)
|
||||||
.expect("parse ok");
|
.expect("parse ok");
|
||||||
assert_eq!(parsed.session_id.as_deref(), Some("conv-123"));
|
assert_eq!(parsed.session_id.as_deref(), Some("conv-123"));
|
||||||
assert!(parsed.events.is_empty());
|
// L'init capte le session_id ET émet un heartbeat (vivacité non terminale, lot 1).
|
||||||
|
assert_eq!(parsed.events, vec![ReplyEvent::Heartbeat]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn claude_parse_rate_limit_event_is_ignored() {
|
fn claude_parse_rate_limit_event_is_heartbeat() {
|
||||||
let parsed = claude::parse_event(
|
let parsed = claude::parse_event(
|
||||||
r#"{"type":"rate_limit_event","rate_limit_info":{"x":1},"session_id":"conv-123"}"#,
|
r#"{"type":"rate_limit_event","rate_limit_info":{"x":1},"session_id":"conv-123"}"#,
|
||||||
)
|
)
|
||||||
.expect("parse ok");
|
.expect("parse ok");
|
||||||
// Type inconnu/non pertinent : ignoré (mais session_id tout de même capté).
|
// Plus ignoré : preuve de vivacité ⇒ heartbeat (mais session_id tout de même capté).
|
||||||
assert!(parsed.events.is_empty());
|
assert_eq!(parsed.events, vec![ReplyEvent::Heartbeat]);
|
||||||
assert_eq!(parsed.session_id.as_deref(), Some("conv-123"));
|
assert_eq!(parsed.session_id.as_deref(), Some("conv-123"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -221,10 +222,15 @@ mod tests {
|
|||||||
let sess =
|
let sess =
|
||||||
codex::parse_event(r#"{"type":"thread.started","thread_id":"cx-9"}"#).expect("ok");
|
codex::parse_event(r#"{"type":"thread.started","thread_id":"cx-9"}"#).expect("ok");
|
||||||
assert_eq!(sess.conversation_id.as_deref(), Some("cx-9"));
|
assert_eq!(sess.conversation_id.as_deref(), Some("cx-9"));
|
||||||
|
// Le handshake ne capte que le thread_id, sans événement (pas un heartbeat).
|
||||||
assert!(sess.events.is_empty());
|
assert!(sess.events.is_empty());
|
||||||
|
|
||||||
|
// turn.started / turn.completed ⇒ heartbeat (vivacité non terminale, lot 1).
|
||||||
let started = codex::parse_event(r#"{"type":"turn.started"}"#).expect("ok");
|
let started = codex::parse_event(r#"{"type":"turn.started"}"#).expect("ok");
|
||||||
assert!(started.events.is_empty());
|
assert_eq!(started.events, vec![ReplyEvent::Heartbeat]);
|
||||||
|
let completed =
|
||||||
|
codex::parse_event(r#"{"type":"turn.completed","usage":{}}"#).expect("ok");
|
||||||
|
assert_eq!(completed.events, vec![ReplyEvent::Heartbeat]);
|
||||||
|
|
||||||
let msg = codex::parse_event(
|
let msg = codex::parse_event(
|
||||||
r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"fini"}}"#,
|
r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"fini"}}"#,
|
||||||
@ -236,11 +242,6 @@ mod tests {
|
|||||||
content: "fini".to_owned()
|
content: "fini".to_owned()
|
||||||
}]
|
}]
|
||||||
);
|
);
|
||||||
|
|
||||||
let completed =
|
|
||||||
codex::parse_event(r#"{"type":"turn.completed","usage":{"input_tokens":10}}"#)
|
|
||||||
.expect("ok");
|
|
||||||
assert!(completed.events.is_empty());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -728,30 +729,26 @@ mod tests {
|
|||||||
assert!(p.events.is_empty());
|
assert!(p.events.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ligne vide / type inconnu / turn.started / turn.completed ⇒ ignorés sans erreur.
|
/// Ligne vide / type inconnu ⇒ ignorés sans erreur. (turn.started/completed sont
|
||||||
|
/// désormais des heartbeats : couverts par `codex_parse_thread_started_message_and_final`.)
|
||||||
#[test]
|
#[test]
|
||||||
fn codex_empty_and_unknown_ignored() {
|
fn codex_empty_and_unknown_ignored() {
|
||||||
assert_eq!(codex::parse_event("").unwrap(), Default::default());
|
assert_eq!(codex::parse_event("").unwrap(), Default::default());
|
||||||
assert!(codex::parse_event(r#"{"type":"heartbeat"}"#)
|
// Un `type` inconnu reste ignoré (robustesse), pas un heartbeat.
|
||||||
|
assert!(codex::parse_event(r#"{"type":"telemetry"}"#)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.events
|
.events
|
||||||
.is_empty());
|
.is_empty());
|
||||||
assert!(codex::parse_event(r#"{"type":"turn.started"}"#)
|
|
||||||
.unwrap()
|
|
||||||
.events
|
|
||||||
.is_empty());
|
|
||||||
assert!(
|
|
||||||
codex::parse_event(r#"{"type":"turn.completed","usage":{}}"#)
|
|
||||||
.unwrap()
|
|
||||||
.events
|
|
||||||
.is_empty()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Machinerie process via FakeCli ---------------------------------
|
// ---- Machinerie process via FakeCli ---------------------------------
|
||||||
|
|
||||||
/// Deltas PUIS Final : le flux ne contient rien après le `Final` (déjà couvert
|
/// Deltas PUIS Final : **exactement un** `Final`, et aucun autre `Final` après lui
|
||||||
/// pour Claude ; ici on le prouve aussi pour Codex, substituabilité Liskov).
|
/// (substituabilité Liskov). Note (lot 1) : un `turn.completed` postérieur émet un
|
||||||
|
/// `Heartbeat` non terminal — légitimement après le `Final` —, donc on ne teste plus
|
||||||
|
/// « rien après le Final » mais « pas de second Final, et seul un heartbeat peut
|
||||||
|
/// suivre ». Le rendez-vous synchrone (`drain_to_final`) s'arrête de toute façon au
|
||||||
|
/// premier `Final`.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn codex_stream_closed_after_final() {
|
async fn codex_stream_closed_after_final() {
|
||||||
let fake = FakeCli::printing(&[
|
let fake = FakeCli::printing(&[
|
||||||
@ -762,12 +759,19 @@ mod tests {
|
|||||||
]);
|
]);
|
||||||
let s = CodexExecSession::new(SessionId::new_random(), fake.command(), "/", None);
|
let s = CodexExecSession::new(SessionId::new_random(), fake.command(), "/", None);
|
||||||
let events: Vec<_> = s.send("x").await.expect("send").collect();
|
let events: Vec<_> = s.send("x").await.expect("send").collect();
|
||||||
let after = events
|
let finals = events
|
||||||
|
.iter()
|
||||||
|
.filter(|e| matches!(e, ReplyEvent::Final { .. }))
|
||||||
|
.count();
|
||||||
|
assert_eq!(finals, 1, "exactement un Final");
|
||||||
|
// Après le Final, seuls des événements non terminaux (heartbeat) peuvent suivre.
|
||||||
|
let after_final_terminals = events
|
||||||
.iter()
|
.iter()
|
||||||
.skip_while(|e| !matches!(e, ReplyEvent::Final { .. }))
|
.skip_while(|e| !matches!(e, ReplyEvent::Final { .. }))
|
||||||
.skip(1)
|
.skip(1)
|
||||||
|
.filter(|e| matches!(e, ReplyEvent::Final { .. }))
|
||||||
.count();
|
.count();
|
||||||
assert_eq!(after, 0);
|
assert_eq!(after_final_terminals, 0, "aucun second Final après le premier");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// LIMITE/ÉCART (à arbitrer) : un flux SANS `Final` ne provoque PAS d'erreur au
|
/// LIMITE/ÉCART (à arbitrer) : un flux SANS `Final` ne provoque PAS d'erreur au
|
||||||
@ -1097,6 +1101,8 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
events,
|
events,
|
||||||
vec![
|
vec![
|
||||||
|
// L'init `system` émet un heartbeat (vivacité non terminale, lot 1).
|
||||||
|
ReplyEvent::Heartbeat,
|
||||||
ReplyEvent::TextDelta { text: "a".into() },
|
ReplyEvent::TextDelta { text: "a".into() },
|
||||||
ReplyEvent::ToolActivity { label: "T".into() },
|
ReplyEvent::ToolActivity { label: "T".into() },
|
||||||
ReplyEvent::TextDelta { text: "b".into() },
|
ReplyEvent::TextDelta { text: "b".into() },
|
||||||
@ -1104,7 +1110,7 @@ mod tests {
|
|||||||
content: "final-ok".into()
|
content: "final-ok".into()
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"le flot complet doit aplatir les 3 blocs PUIS un seul Final"
|
"heartbeat d'init, puis les 3 blocs aplatis, PUIS un seul Final"
|
||||||
);
|
);
|
||||||
// Un seul Final, en dernière position (redondant mais explicite).
|
// Un seul Final, en dernière position (redondant mais explicite).
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
Reference in New Issue
Block a user