feat(agent): conversation par paire + entrée médiée + pivot terminal/MCP

Coeur inter-agents consolidé et surface front réalignée sur la décision
"terminal natif PTY, pas d'UI chat" (Option 1).

Domaine
- nouveaux modules conversation, mailbox, input, fileguard (ports + types)
- orchestrator/profile/events étendus (conversation par paire, FIFO)

Application / Infrastructure
- orchestrator/service + context_guard : sérialisation FIFO par agent,
  garde RW mémoire/contexte, dispatch ask/reply
- adapters in-memory conversation / mailbox / input / fileguard
- registry session + lifecycle agent durcis (1 agent = 1 session vivante)
- outils MCP idea_* alignés sur le nouveau dispatch

Frontend
- MediatedInput + useAgentBusy : entrée utilisateur médiée par IdeA,
  terminal = vue sortie inchangée
- suppression de la vue chat structurée (AgentChatView) — abandonnée
- adapter input + ports mis à jour

Divers
- .ideai/ : mémoire projet + briefs de cadrage versionnés ;
  requests/ runtime ignoré ; agents projet réels (DevBackend/DevFrontend/QA)

Tests : Rust (domain/application/infrastructure/app-tauri) + front (346) verts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 07:33:04 +02:00
parent 5f45c22941
commit eca2ba95c4
61 changed files with 9491 additions and 2512 deletions

View File

@ -42,7 +42,8 @@ use infrastructure::{
embedder_from_profile, AdaptiveMemoryRecall, ClaudeTranscriptInspector, CliAgentRuntime,
EmbedderEnvProbe, FsEmbedderProfileStore, FsEmbedderPromptStore, FsMemoryStore,
FsOrchestratorWatcher, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore,
Git2Repository, IdeaiContextStore, LocalFileSystem, LocalProcessSpawner, McpServer,
Git2Repository, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, SystemMillisClock,
NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, StructuredSessionFactory,
SystemClock, TokioBroadcastEventBus,
UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
@ -50,7 +51,7 @@ use infrastructure::{
};
use crate::chat::ChatBridge;
use crate::mcp_endpoint::{mcp_endpoint, McpEndpoint};
use crate::mcp_endpoint::{mcp_endpoint, AppMcpRuntimeProvider, McpEndpoint};
use crate::pty::PtyBridge;
use infrastructure::StdioTransport;
@ -558,27 +559,29 @@ impl AppState {
));
// LaunchAgent shares the SAME pty_port and terminal_sessions as the terminal
// use cases — indispensable for the PtyBridge to work correctly.
let launch_agent = Arc::new(
LaunchAgent::new(
Arc::clone(&contexts_port),
Arc::clone(&profile_store_port),
Arc::clone(&runtime_port),
Arc::clone(&fs_port),
Arc::clone(&pty_port),
Arc::clone(&skill_store_port),
Arc::clone(&terminal_sessions),
Arc::clone(&events_port),
Arc::clone(&ids) as Arc<dyn IdGenerator>,
Arc::clone(&memory_recall_port),
Some(Arc::clone(&check_embedder_suggestion)),
)
// Routage structuré §17.4 : un profil avec `structured_adapter` lance une
// AgentSession (cellule chat) au lieu d'un PTY ; sinon chemin PTY inchangé.
.with_structured(
Arc::clone(&session_factory),
Arc::clone(&structured_sessions),
),
);
//
// Option 1 « Terminal + MCP » (lot B-2) : on **ne câble plus** la fabrique
// structurée. La vue humaine d'un agent est désormais le **terminal brut
// natif** (PTY interactif) — réflexion live + Échap natifs CLI, zéro parsing —
// et la délégation inter-agents passe par les outils MCP (`idea_ask_agent` /
// `idea_reply`), pas par une `AgentChatView`. Sans `with_structured`, le point
// de routage §17.4 de `LaunchAgent::execute` retombe **toujours** sur le chemin
// PTH : tout profil (Claude/Codex inclus) ouvre une cellule terminal. Le code
// `launch_structured` reste en place (mort-code retiré au lot de nettoyage B-6).
let _session_factory = session_factory; // décâblé en B-2 (nettoyage B-6)
let launch_agent = Arc::new(LaunchAgent::new(
Arc::clone(&contexts_port),
Arc::clone(&profile_store_port),
Arc::clone(&runtime_port),
Arc::clone(&fs_port),
Arc::clone(&pty_port),
Arc::clone(&skill_store_port),
Arc::clone(&terminal_sessions),
Arc::clone(&events_port),
Arc::clone(&ids) as Arc<dyn IdGenerator>,
Arc::clone(&memory_recall_port),
Some(Arc::clone(&check_embedder_suggestion)),
));
// Hot-swap an agent's runtime profile (§15.1). Reuses the shared context/
// profile/project/fs stores, the live-session registry and PTY port, and
@ -737,6 +740,30 @@ impl AppState {
// the UI drives (IdeA stays the single source of truth for the agent/skill
// lifecycle). The per-project watcher that feeds it is started lazily when
// a project is opened (see `ensure_orchestrator_watch`).
// File inter-agents (Option 1 « Terminal + MCP », B-3) : un ticket par tâche
// déléguée, résolu par `idea_reply`. Une instance par AppState ⇒ partagée par
// tous les projets (clé interne = AgentId, jamais de collision cross-projet).
// Médiateur d'entrée (cadrage C3) : enveloppe l'`InMemoryMailbox` (moteur de
// corrélation par ticket) + porte la **livraison** du tour dans le PTY de la
// cible (écriture sérialisée, une seule voie d'entrée). Le mailbox concret est
// 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(
MediatedInbox::with_pty(
Arc::clone(&inmemory_mailbox),
Arc::new(SystemMillisClock),
Arc::clone(&pty_port),
)
// É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>;
// 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()) as Arc<dyn domain::conversation::ConversationRegistry>;
let orchestrator_service = Arc::new(
OrchestratorService::new(
Arc::clone(&create_agent),
@ -748,10 +775,17 @@ impl AppState {
Arc::clone(&profile_store_port),
Arc::clone(&terminal_sessions),
)
// Messagerie inter-agents §17.4 : registre structuré + bus pour
// `agent.message` (AskAgent) rendez-vous synchrone via send_blocking.
.with_structured(Arc::clone(&structured_sessions))
.with_events(Arc::clone(&events_port)),
// Messagerie inter-agents (cadrage C3) : médiateur d'entrée (file + livraison
// PTV sérialisée) + registre de conversations + bus pour AgentReplied.
.with_input_mediator(Arc::clone(&input_mediator), Arc::clone(&mailbox))
.with_conversations(Arc::clone(&conversation_registry))
.with_events(Arc::clone(&events_port))
// Faits OS/runtime (exe $APPIMAGE/current_exe + endpoint loopback) pour
// que les (re)lancements issus du chemin `ask` écrivent la déclaration MCP
// réelle ⇒ le pont MCP de la cible se spawne et `idea_reply` débloque le round-trip.
.with_mcp_runtime_provider(
Arc::new(AppMcpRuntimeProvider) as Arc<dyn application::McpRuntimeProvider>
),
);
// --- Windows (L10) ---
@ -982,6 +1016,21 @@ fn bind_endpoint(endpoint: &McpEndpoint) -> Option<LocalSocketListener> {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
// D1 — reclaim the **corpse** socket left by a SIGKILL'd run BEFORE binding.
// `reclaim_name(true)` (below) only unlinks the socket on *drop*; in
// `interprocess` 2.4 it does **not** clear a pre-existing inode at bind time,
// so a stale socket file makes the bind fail with `EADDRINUSE` (the D1 test
// proves this). We therefore unlink it ourselves first — but only when the
// path is actually a **socket** (never clobber a real file we don't own).
#[cfg(unix)]
{
use std::os::unix::fs::FileTypeExt;
if let Ok(meta) = std::fs::symlink_metadata(&path) {
if meta.file_type().is_socket() {
let _ = std::fs::remove_file(&path);
}
}
}
}
let name = endpoint
.as_cli_arg()
@ -989,7 +1038,7 @@ fn bind_endpoint(endpoint: &McpEndpoint) -> Option<LocalSocketListener> {
.ok()?;
ListenerOptions::new()
.name(name)
// Replace a corpse socket left by a previous run; reclaim (unlink) on drop.
// Reclaim (unlink) on drop so a clean close leaves no socket behind.
.reclaim_name(true)
.create_tokio()
.ok()
@ -1234,7 +1283,10 @@ mod mcp_serve_peer_tests {
PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec,
StoreError,
};
use domain::profile::{AgentProfile, ContextInjection};
use domain::profile::{
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
StructuredAdapter,
};
use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
use domain::skill::{Skill, SkillScope};
@ -1532,6 +1584,9 @@ mod mcp_serve_peer_tests {
/// Builds an `OrchestratorService` over the in-memory fakes (no structured
/// registry), mirroring `infrastructure/tests/mcp_server.rs::build_service`.
fn build_service(contexts: FakeContexts) -> Arc<OrchestratorService> {
// Profil Claude **complet** (adaptateur structuré + capacité MCP `.mcp.json`) :
// seul profil que la garde F2 (`guard_mcp_bridge_supported`) laisse passer pour
// `idea_ask_agent`, car seul Claude consomme réellement le pont `.mcp.json`.
let profiles = Arc::new(FakeProfiles(Arc::new(vec![AgentProfile::new(
ProfileId::from_uuid(Uuid::from_u128(9)),
"Claude Code",
@ -1542,7 +1597,12 @@ mod mcp_serve_peer_tests {
"{agentRunDir}",
None,
)
.unwrap()])));
.unwrap()
.with_structured_adapter(StructuredAdapter::Claude)
.with_mcp(McpCapability::new(
McpConfigStrategy::config_file(".mcp.json").unwrap(),
McpTransport::Stdio,
))])));
let sessions = Arc::new(TerminalSessions::new());
let bus = Arc::new(NoopBus);
let create = Arc::new(CreateAgentFromScratch::new(
@ -1681,14 +1741,24 @@ mod mcp_serve_peer_tests {
for expected in [
"idea_list_agents",
"idea_ask_agent",
"idea_reply",
"idea_launch_agent",
"idea_stop_agent",
"idea_update_context",
"idea_create_skill",
// FileGuard-mediated context/memory tools (cadrage C7).
"idea_context_read",
"idea_context_propose",
"idea_memory_read",
"idea_memory_write",
] {
assert!(names.contains(&expected), "missing tool {expected}; got {names:?}");
}
assert_eq!(tools.len(), 6, "exactly the six idea_* tools; got {names:?}");
assert_eq!(
tools.len(),
11,
"exactly the eleven idea_* tools (7 base + 4 FileGuard C7); got {names:?}"
);
drop(client); // EOF ⇒ serve loop ends
tokio::time::timeout(TIMEOUT, peer)
@ -2158,19 +2228,22 @@ mod mcp_e2e_loopback_tests {
use application::{
CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents,
OrchestratorService, StructuredSessions, TerminalSessions, UpdateAgentContext,
OrchestratorService, TerminalSessions, UpdateAgentContext,
};
use domain::agent::{AgentManifest, ManifestEntry};
use domain::events::{DomainEvent, OrchestrationSource};
use domain::ids::{AgentId, NodeId, ProfileId, ProjectId, SkillId};
use domain::markdown::MarkdownDoc;
use domain::ports::{
AgentContextStore, AgentRuntime, AgentSession, AgentSessionError, ContextInjectionPlan,
DirEntry, EventBus, EventStream, ExitStatus, FileSystem, FsError, IdGenerator, OutputStream,
PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath, ReplyEvent,
ReplyStream, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError,
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext, ProfileStore,
PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec,
StoreError,
};
use domain::profile::{
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
StructuredAdapter,
};
use domain::profile::{AgentProfile, ContextInjection};
use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
use domain::skill::{Skill, SkillScope};
@ -2180,7 +2253,9 @@ mod mcp_e2e_loopback_tests {
use super::{bind_endpoint, mcp_endpoint, McpServerHandle};
use crate::mcp_endpoint::McpEndpoint;
use infrastructure::McpServer;
use infrastructure::{
InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, SystemMillisClock,
};
/// Test timeout for any single loopback interaction. Generous but finite: a
/// correct round-trip is sub-millisecond, so this only ever fires on a real hang.
@ -2417,36 +2492,6 @@ mod mcp_e2e_loopback_tests {
}
}
/// A structured session whose turn deterministically ends on a `Final` carrying a
/// fixed reply — lets us exercise `idea_ask_agent` without a real CLI.
struct FakeSession {
id: SessionId,
reply: String,
}
#[async_trait]
impl AgentSession for FakeSession {
fn id(&self) -> SessionId {
self.id
}
fn conversation_id(&self) -> Option<String> {
None
}
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
let events = vec![
ReplyEvent::TextDelta {
text: "thinking…".to_owned(),
},
ReplyEvent::Final {
content: self.reply.clone(),
},
];
Ok(Box::new(events.into_iter()))
}
async fn shutdown(&self) -> Result<(), AgentSessionError> {
Ok(())
}
}
#[derive(Default, Clone)]
struct NoopBus;
impl EventBus for NoopBus {
@ -2495,17 +2540,19 @@ mod mcp_e2e_loopback_tests {
}
/// Builds an `OrchestratorService` over the in-memory fakes, returning the
/// shared `TerminalSessions` (so a test can pre-bind a live **PTY** session for a
/// raw-PTY target) and `StructuredSessions` (so a test can pre-insert a replying
/// **structured** session for `idea_ask_agent`). Mirrors the established harness;
/// no use case is re-invented.
/// shared `TerminalSessions` (so a test can pre-bind a live PTY target for an
/// `idea_ask_agent`) and the `InMemoryMailbox` (so a test can observe pending
/// tickets). Mirrors the established harness; no use case is re-invented.
fn build_service(
contexts: FakeContexts,
) -> (
Arc<OrchestratorService>,
Arc<TerminalSessions>,
Arc<StructuredSessions>,
Arc<InMemoryMailbox>,
) {
// Profil Claude **complet** (adaptateur structuré + capacité MCP `.mcp.json`) :
// seul profil que la garde F2 (`guard_mcp_bridge_supported`) laisse passer pour
// `idea_ask_agent` — le round-trip e2e testé ici suppose une cible éligible.
let profiles = Arc::new(FakeProfiles(Arc::new(vec![AgentProfile::new(
ProfileId::from_uuid(Uuid::from_u128(9)),
"Claude Code",
@ -2516,9 +2563,14 @@ mod mcp_e2e_loopback_tests {
"{agentRunDir}",
None,
)
.unwrap()])));
.unwrap()
.with_structured_adapter(StructuredAdapter::Claude)
.with_mcp(McpCapability::new(
McpConfigStrategy::config_file(".mcp.json").unwrap(),
McpTransport::Stdio,
))])));
let sessions = Arc::new(TerminalSessions::new());
let structured = Arc::new(StructuredSessions::new());
let mailbox = Arc::new(InMemoryMailbox::new());
let bus = Arc::new(NoopBus);
let create = Arc::new(CreateAgentFromScratch::new(
Arc::new(contexts.clone()),
@ -2545,6 +2597,13 @@ mod mcp_e2e_loopback_tests {
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,
@ -2555,8 +2614,26 @@ mod mcp_e2e_loopback_tests {
Arc::clone(&profiles) as Arc<dyn ProfileStore>,
Arc::clone(&sessions),
)
.with_structured(Arc::clone(&structured));
(Arc::new(service), sessions, structured)
.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(
PtyHandle { session_id },
TerminalSession::starting(
session_id,
NodeId::from_uuid(Uuid::from_u128(7)),
ProjectPath::new("/home/me/proj").unwrap(),
SessionKind::Agent { agent_id },
PtySize { rows: 24, cols: 80 },
),
);
}
// --- real loopback harness ---------------------------------------------
@ -2648,7 +2725,7 @@ mod mcp_e2e_loopback_tests {
let contexts = FakeContexts::new();
contexts.seed_agent("architect");
contexts.seed_agent("dev-backend");
let (service, _pty, _structured) = build_service(contexts);
let (service, _sessions, _mailbox) = build_service(contexts);
let proj = project();
let (handle, endpoint) = start_real_server(service, &proj, None);
@ -2689,34 +2766,31 @@ mod mcp_e2e_loopback_tests {
}
// -----------------------------------------------------------------------
// 2. idea_ask_agent e2e: structured target ⇒ reply returned INLINE.
// 2. idea_ask_agent → idea_reply e2e over the real loopback (Option 1, B-3/B-4):
// the asker's `idea_ask_agent` blocks on the mailbox; the target's `idea_reply`
// (on a second connection, with its agent id as the handshake requester)
// resolves it and the asker gets the result INLINE.
// -----------------------------------------------------------------------
#[tokio::test]
async fn ask_structured_agent_returns_reply_inline_over_real_loopback() {
async fn ask_then_reply_round_trips_inline_over_real_loopback() {
let contexts = FakeContexts::new();
let agent_id = contexts.seed_agent("architect");
let (service, _pty, structured) = build_service(contexts);
structured.insert(
Arc::new(FakeSession {
id: SessionId::from_uuid(Uuid::from_u128(4242)),
reply: "the answer is 42".to_owned(),
}),
agent_id,
NodeId::from_uuid(Uuid::from_u128(7)),
);
let (service, sessions, mailbox) = build_service(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);
let conn = connect_client(&endpoint).await;
let (read_half, mut write_half) = tokio::io::split(conn);
let mut reader = BufReader::new(read_half);
write_half
// 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_half
write_a
.write_all(
tools_call_line(
7,
@ -2727,9 +2801,41 @@ mod mcp_e2e_loopback_tests {
)
.await
.unwrap();
write_half.flush().await.unwrap();
write_a.flush().await.unwrap();
let resp = read_one_response(&mut reader)
// 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));
@ -2742,33 +2848,21 @@ mod mcp_e2e_loopback_tests {
"ask reply must be returned inline over the real loopback; got {result}"
);
drop(write_half);
drop(write_a);
drop(write_b);
handle.stop();
}
// -----------------------------------------------------------------------
// 3. idea_ask_agent against a raw-PTY target ⇒ typed tool error (isError:true),
// no panic, connection stays healthy for a follow-up call.
// 3. idea_reply with no in-flight ask ⇒ typed tool error (isError:true), no
// panic, connection stays healthy for a follow-up call.
// -----------------------------------------------------------------------
#[tokio::test]
async fn ask_raw_pty_target_is_typed_error_over_real_loopback() {
async fn orphan_reply_is_typed_error_over_real_loopback() {
let contexts = FakeContexts::new();
let agent_id = contexts.seed_agent("dev");
let (service, pty, _structured) = build_service(contexts);
// Pre-bind a live **PTY** session for the agent (no structured session): the
// ask path must reject it with AppError::Invalid ⇒ tool result isError:true.
let session_id = SessionId::from_uuid(Uuid::from_u128(555));
pty.insert(
PtyHandle { session_id },
TerminalSession::starting(
session_id,
NodeId::from_uuid(Uuid::from_u128(8)),
ProjectPath::new("/home/me/proj").unwrap(),
SessionKind::Agent { agent_id },
PtySize { rows: 24, cols: 80 },
),
);
let (service, _sessions, _mailbox) = build_service(contexts);
let proj = project();
let (handle, endpoint) = start_real_server(service, &proj, None);
@ -2776,14 +2870,15 @@ mod mcp_e2e_loopback_tests {
let (read_half, mut write_half) = tokio::io::split(conn);
let mut reader = BufReader::new(read_half);
// The peer identifies as `agent_id`; an idea_reply with no matching ask in
// flight ⇒ the IdeA command fails ⇒ tool result isError:true (not transport).
write_half
.write_all(handshake_line(&project_id_arg(&proj), "agent-asker").as_bytes())
.write_all(handshake_line(&project_id_arg(&proj), &agent_id.to_string()).as_bytes())
.await
.unwrap();
write_half
.write_all(
tools_call_line(3, "idea_ask_agent", json!({ "target": "dev", "task": "hi" }))
.as_bytes(),
tools_call_line(3, "idea_reply", json!({ "result": "orphan" })).as_bytes(),
)
.await
.unwrap();
@ -2791,14 +2886,12 @@ mod mcp_e2e_loopback_tests {
let resp = read_one_response(&mut reader)
.await
.expect("an ask response line");
.expect("a reply response line");
assert_eq!(resp["id"], json!(3));
// Healthy connection: NO JSON-RPC transport error...
assert!(
resp["error"].is_null(),
"a raw-PTY target must be a tool error, not a transport error: {resp}"
"an orphan reply must be a tool error, not a transport error: {resp}"
);
// ...but the tool result is flagged as an error.
let result = &resp["result"];
assert_eq!(result["isError"], json!(true), "got {result}");
assert!(
@ -2844,7 +2937,7 @@ mod mcp_e2e_loopback_tests {
async fn malformed_jsonrpc_after_handshake_errors_and_server_survives() {
let contexts = FakeContexts::new();
contexts.seed_agent("architect");
let (service, _pty, _structured) = build_service(contexts);
let (service, _sessions, _mailbox) = build_service(contexts);
let proj = project();
let (handle, endpoint) = start_real_server(service, &proj, None);
@ -2906,7 +2999,7 @@ mod mcp_e2e_loopback_tests {
#[tokio::test]
async fn handshake_requester_propagates_over_real_loopback() {
let contexts = FakeContexts::new();
let (service, _pty, _structured) = build_service(contexts);
let (service, _sessions, _mailbox) = build_service(contexts);
let (publish, captured) = capturing_events();
let proj = project();
let (handle, endpoint) = start_real_server(service, &proj, Some(publish));
@ -2991,3 +3084,78 @@ mod mcp_e2e_loopback_tests {
handle.stop();
}
}
#[cfg(test)]
mod bind_endpoint_d1_tests {
//! D1 — non-regression lock on [`bind_endpoint`]'s corpse-socket reclaim
//! (cadrage §7 row D1, §5.2 "verrouille `reclaim_name(true)`").
//!
//! A crashed run (SIGKILL) leaves the Unix socket **file** behind: a plain
//! `bind` would then fail with `EADDRINUSE`. The production path passes
//! `reclaim_name(true)`, which **unlinks the corpse** before binding. These tests
//! pin that behaviour so a future refactor cannot silently drop the flag and
//! resurrect the "address already in use" failure on restart.
//!
//! Unix-only: on Windows the endpoint is a named pipe with no filesystem corpse to
//! reclaim, so there is nothing to assert.
#![cfg(unix)]
use super::{bind_endpoint, mcp_endpoint};
use domain::ProjectId;
use uuid::Uuid;
/// Rebinding after a **corpse** socket (file left in place, as after a SIGKILL)
/// succeeds — no `EADDRINUSE` — because `reclaim_name(true)` unlinks it first.
///
/// The corpse is reproduced **faithfully**: a `std::os::unix::net::UnixListener`
/// binds the path then is dropped — std does **not** unlink on drop, so the socket
/// inode is left on the filesystem with **no live listener**, exactly the state a
/// SIGKILL'd run leaves behind (the fd is gone, the inode lingers). A plain `bind`
/// over that inode would return `EADDRINUSE`; `bind_endpoint`'s `reclaim_name(true)`
/// must unlink it first.
#[tokio::test]
async fn rebind_after_corpse_socket_succeeds() {
use std::os::unix::net::UnixListener;
let ep = mcp_endpoint(&ProjectId::from_uuid(Uuid::from_u128(0xD1_0001)));
let path = ep.socket_path().expect("unix endpoint exposes a socket path");
// Clean any leftover from a previous run of this very test.
let _ = std::fs::remove_file(&path);
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
// 1) Lay down a CORPSE: bind with std (which leaves the inode on drop) and
// drop it ⇒ socket file remains with no live listener (SIGKILL aftermath).
{
let corpse = UnixListener::bind(&path).expect("lay corpse socket");
drop(corpse);
}
assert!(path.exists(), "corpse socket inode remains (no live listener)");
// 2) Rebind over the corpse: must succeed (reclaim unlinks then binds), NOT
// fail with EADDRINUSE.
let first = bind_endpoint(&ep);
assert!(
first.is_some(),
"rebind over the corpse socket must succeed (reclaim_name unlinks it)"
);
assert!(path.exists(), "a fresh live socket now sits at the path");
// 3) Idempotent over a live socket: a second bind also reclaims + succeeds.
let second = bind_endpoint(&ep);
assert!(second.is_some(), "bind is idempotent across a live socket");
// 4) No file leak after a clean close: dropping the live listener(s) unlinks
// the socket (interprocess reclaim guard on drop).
drop(first);
drop(second);
assert!(
!path.exists(),
"socket file unlinked on clean close (no leak)"
);
}
}