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:
@ -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.
|
||||
|
||||
Reference in New Issue
Block a user