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:
2026-06-14 09:28:44 +02:00
parent fdcf16c387
commit 0f8ba38d51
24 changed files with 2745 additions and 156 deletions

View File

@ -175,11 +175,17 @@ impl ChatBridge {
/// 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
/// (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]
pub fn chunk_from_event(event: ReplyEvent) -> ReplyChunk {
pub fn chunk_from_event(event: ReplyEvent) -> Option<ReplyChunk> {
match event {
ReplyEvent::TextDelta { text } => ReplyChunk::TextDelta { text },
ReplyEvent::ToolActivity { label } => ReplyChunk::ToolActivity { label },
ReplyEvent::Final { content } => ReplyChunk::Final { content },
ReplyEvent::TextDelta { text } => Some(ReplyChunk::TextDelta { text }),
ReplyEvent::ToolActivity { label } => Some(ReplyChunk::ToolActivity { label }),
ReplyEvent::Final { content } => Some(ReplyChunk::Final { content }),
ReplyEvent::Heartbeat => None,
}
}

View File

@ -1196,7 +1196,11 @@ pub async fn agent_send(
let bridge = std::sync::Arc::clone(&state.chat_bridge);
std::thread::spawn(move || {
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
// boolean only reflects live delivery. If the view navigated away
// (no channel at this generation) we keep draining so the turn still

View File

@ -13,6 +13,7 @@ use serde::Serialize;
use tauri::{AppHandle, Emitter};
use domain::events::{DomainEvent, OrchestrationSource};
use domain::input::AgentLiveness;
use infrastructure::TokioBroadcastEventBus;
/// 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_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).
#[serde(rename_all = "camelCase")]
AgentProfileChanged {
@ -215,6 +227,26 @@ pub enum OrchestrationSourceDto {
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 {
fn from(source: OrchestrationSource) -> Self {
match source {
@ -265,6 +297,12 @@ impl From<&DomainEvent> for DomainEventDto {
agent_id: agent_id.to_string(),
reply_len: *reply_len,
},
DomainEvent::AgentLivenessChanged { agent_id, liveness } => {
Self::AgentLivenessChanged {
agent_id: agent_id.to_string(),
liveness: (*liveness).into(),
}
}
DomainEvent::AgentProfileChanged {
agent_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");
}
}

View File

@ -827,7 +827,7 @@ impl AppState {
// partagé pour `resolve`/`resolve_ticket`/`cancel_head` côté orchestrateur.
let inmemory_mailbox = Arc::new(InMemoryMailbox::new());
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(
Arc::clone(&inmemory_mailbox),
Arc::new(SystemMillisClock),
@ -836,7 +836,28 @@ impl AppState {
// Émet `AgentBusyChanged` à la source (Busy à l'enqueue qui démarre un
// tour, Idle au mark_idle) ⇒ relayé au front en event Tauri (cadrage C4).
.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
// vivante keyée par conversation (lève l'ambiguïté session/agent).
let conversation_registry = Arc::new(InMemoryConversationRegistry::new())
@ -870,7 +891,12 @@ impl AppState {
.with_record_turn(
Arc::new(AppRecordTurnProvider) as Arc<dyn RecordTurnProvider>,
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) ---
@ -1119,10 +1145,13 @@ impl AppState {
let Some(profile) = profile_by_id.get(&agent.profile_id) else {
continue;
};
if !is_claude_mcp_profile(profile) {
continue;
// Dispatch par profil : Claude (`.mcp.json` + settings) ou Codex
// (`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;
} else if is_codex_mcp_profile(profile) {
let _ = migrate_codex_run_dir(project, &agent.id, profile).await;
}
let _ = migrate_claude_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> {
let mut doc = match serde_json::from_str::<Value>(existing) {
Ok(Value::Object(map)) => Value::Object(map),
@ -3284,6 +3422,88 @@ mod mcp_e2e_loopback_tests {
(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.
fn seed_live_pty(sessions: &TerminalSessions, agent_id: AgentId, session_id: SessionId) {
sessions.insert(
@ -3519,6 +3739,101 @@ mod mcp_e2e_loopback_tests {
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
// panic, connection stays healthy for a follow-up call.

View File

@ -57,7 +57,7 @@ fn final_chunk(s: &str) -> ReplyChunk {
fn chunk_from_event_maps_text_delta() {
assert_eq!(
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 {
label: "reads file".into()
}),
ReplyChunk::ToolActivity {
Some(ReplyChunk::ToolActivity {
label: "reads file".into()
}
})
);
}
@ -79,12 +79,19 @@ fn chunk_from_event_maps_final() {
chunk_from_event(ReplyEvent::Final {
content: "done".into()
}),
ReplyChunk::Final {
Some(ReplyChunk::Final {
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)
// ---------------------------------------------------------------------------
@ -94,7 +101,10 @@ fn chunk_from_event_maps_final() {
/// end. This is the body of the spawned pump thread, run inline.
fn pump_turn(bridge: &ChatBridge, session: &SessionId, gen: u64, events: Vec<ReplyEvent>) {
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);
}