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:
@ -40,7 +40,8 @@ use crate::dto::{
|
||||
EmbedderProfileListDto, ErrorDto, FirstRunStateDto, GitBranchesDto, GitCheckoutRequestDto,
|
||||
GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
|
||||
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
|
||||
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto,
|
||||
InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto,
|
||||
LiveAgentListDto, SubmitAgentInputRequestDto,
|
||||
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenTerminalRequestDto, ProfileDto,
|
||||
ProfileListDto, ProjectDto, ProjectListDto, ReadAgentContextResponseDto, ReattachChatDto,
|
||||
ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk,
|
||||
@ -1037,8 +1038,10 @@ pub async fn launch_agent(
|
||||
// M5c handshake guard (`serve_peer`) compares against; the `--requester` is the
|
||||
// launching agent's id. A missing executable path (should not happen) degrades
|
||||
// to no runtime ⇒ apply_mcp_config writes the minimal declaration.
|
||||
let mcp_runtime = std::env::current_exe().ok().map(|exe| McpRuntime {
|
||||
exe: exe.to_string_lossy().into_owned(),
|
||||
// L'exe vient de `idea_exe_path()` (privilégie `$APPIMAGE`, sinon `current_exe`)
|
||||
// pour que chemin GUI et chemin `ask` écrivent un `command` identique et stable.
|
||||
let mcp_runtime = crate::mcp_endpoint::idea_exe_path().map(|exe| McpRuntime {
|
||||
exe,
|
||||
endpoint: crate::mcp_endpoint::mcp_endpoint(&project.id)
|
||||
.as_cli_arg()
|
||||
.to_owned(),
|
||||
@ -1203,6 +1206,55 @@ pub async fn agent_send(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `submit_agent_input` — the human **Envoyer** path (cadrage C4 §4.2).
|
||||
///
|
||||
/// Routes the operator's text to [`OrchestratorService::submit_human_input`], which
|
||||
/// enqueues a `from_human` ticket in the **same FIFO** the inter-agent delegations
|
||||
/// use (serialised per agent). Fire-and-forget: the human watches the terminal for
|
||||
/// the answer. The mediator emits `AgentBusyChanged` at the source.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id or unwired mediator,
|
||||
/// `NOT_FOUND` if the project or agent is unknown, `PROCESS` on a launch/PTY failure).
|
||||
#[tauri::command]
|
||||
pub async fn submit_agent_input(
|
||||
request: SubmitAgentInputRequestDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), ErrorDto> {
|
||||
let project = resolve_project(&request.project_id, &state).await?;
|
||||
let agent_id = parse_agent_id(&request.agent_id)?;
|
||||
state
|
||||
.orchestrator_service
|
||||
.submit_human_input(&project, agent_id, request.text)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// `interrupt_agent` — the **Interrompre** path (cadrage C4 §4.2).
|
||||
///
|
||||
/// Routes to [`OrchestratorService::interrupt_agent`], which `preempt`s the agent's
|
||||
/// running turn (best-effort interrupt to its PTY). Not an enqueue; resolves no
|
||||
/// ticket. Idempotent on an idle agent.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id or unwired mediator,
|
||||
/// `NOT_FOUND` if the project or agent is unknown).
|
||||
#[tauri::command]
|
||||
pub async fn interrupt_agent(
|
||||
request: InterruptAgentRequestDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), ErrorDto> {
|
||||
let project = resolve_project(&request.project_id, &state).await?;
|
||||
let agent_id = parse_agent_id(&request.agent_id)?;
|
||||
state
|
||||
.orchestrator_service
|
||||
.interrupt_agent(&project, agent_id)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// `reattach_agent_chat` — re-bind a view to a **still-living** structured session
|
||||
/// without re-sending or re-spawning it (ARCHITECTURE §17.6/§17.7).
|
||||
///
|
||||
|
||||
@ -1186,6 +1186,31 @@ impl From<LaunchAgentOutput> for TerminalSessionDto {
|
||||
}
|
||||
}
|
||||
|
||||
/// Request DTO for `submit_agent_input` (cadrage C4 §4.2): the human Envoyer path.
|
||||
/// The frontend's [`InputGateway`](../../../frontend/src/ports) sends
|
||||
/// `{ request: { projectId, agentId, text } }`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SubmitAgentInputRequestDto {
|
||||
/// Id of the owning project.
|
||||
pub project_id: String,
|
||||
/// Id of the agent to send the human input to.
|
||||
pub agent_id: String,
|
||||
/// The text the operator typed (enqueued as a `from_human` ticket).
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// Request DTO for `interrupt_agent` (cadrage C4 §4.2): the Interrompre path. The
|
||||
/// frontend sends `{ request: { projectId, agentId } }`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InterruptAgentRequestDto {
|
||||
/// Id of the owning project.
|
||||
pub project_id: String,
|
||||
/// Id of the agent whose running turn to preempt.
|
||||
pub agent_id: String,
|
||||
}
|
||||
|
||||
/// Request DTO for `change_agent_profile` (§15.1): hot-swap an agent's runtime
|
||||
/// profile, optionally relaunching its live session in place.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
|
||||
@ -47,6 +47,15 @@ pub enum DomainEventDto {
|
||||
/// Exit code.
|
||||
code: i32,
|
||||
},
|
||||
/// An agent's busy/idle state changed (cadrage C4 §4.2). The frontend dims
|
||||
/// "Envoyer" while `busy` is `true`.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
AgentBusyChanged {
|
||||
/// Agent id.
|
||||
agent_id: String,
|
||||
/// `true` when a turn is in flight, `false` when idle.
|
||||
busy: bool,
|
||||
},
|
||||
/// A target agent produced a synchronous reply to an inter-agent `ask` (§17.4).
|
||||
#[serde(rename_all = "camelCase")]
|
||||
AgentReplied {
|
||||
@ -212,6 +221,10 @@ impl From<&DomainEvent> for DomainEventDto {
|
||||
agent_id: agent_id.to_string(),
|
||||
code: *code,
|
||||
},
|
||||
DomainEvent::AgentBusyChanged { agent_id, busy } => Self::AgentBusyChanged {
|
||||
agent_id: agent_id.to_string(),
|
||||
busy: *busy,
|
||||
},
|
||||
DomainEvent::AgentReplied {
|
||||
agent_id,
|
||||
reply_len,
|
||||
|
||||
@ -166,6 +166,8 @@ pub fn run() {
|
||||
commands::launch_agent,
|
||||
commands::change_agent_profile,
|
||||
commands::agent_send,
|
||||
commands::submit_agent_input,
|
||||
commands::interrupt_agent,
|
||||
commands::reattach_agent_chat,
|
||||
commands::close_agent_session,
|
||||
commands::list_resumable_agents,
|
||||
|
||||
@ -41,7 +41,8 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use domain::ProjectId;
|
||||
use application::{McpRuntime, McpRuntimeProvider};
|
||||
use domain::{AgentId, Project, ProjectId};
|
||||
|
||||
/// The loopback address of a project's MCP endpoint — the value [`mcp_endpoint`]
|
||||
/// returns. Deterministic per [`ProjectId`]; the single source of truth shared by
|
||||
@ -127,6 +128,48 @@ pub fn mcp_endpoint(project_id: &ProjectId) -> McpEndpoint {
|
||||
McpEndpoint { addr }
|
||||
}
|
||||
|
||||
/// Chemin du binaire IdeA à inscrire comme `command` dans la déclaration MCP.
|
||||
///
|
||||
/// Privilégie `$APPIMAGE` (chemin **stable** du `.AppImage`, qui survit aux
|
||||
/// redémarrages) car sous AppImage `current_exe()` pointe sur un montage éphémère
|
||||
/// `/tmp/.mount_*` qui disparaît au redémarrage ⇒ `ENOENT` au respawn du pont MCP.
|
||||
/// Hors AppImage `$APPIMAGE` est absent ⇒ on retombe sur `current_exe()`. `None` si
|
||||
/// aucun des deux n'est résolvable (ne devrait pas arriver) ⇒ déclaration minimale.
|
||||
pub(crate) fn idea_exe_path() -> Option<String> {
|
||||
std::env::var("APPIMAGE")
|
||||
.ok()
|
||||
.or_else(|| std::env::current_exe().ok().map(|p| p.to_string_lossy().into_owned()))
|
||||
}
|
||||
|
||||
/// Implémentation app-tauri du port [`McpRuntimeProvider`] : fournit à
|
||||
/// l'orchestrateur (couche `application`) les **faits OS/runtime** nécessaires pour
|
||||
/// écrire la déclaration MCP réelle quand il (re)lance une cible sur le chemin
|
||||
/// `ask` (`ensure_live_pty`).
|
||||
///
|
||||
/// Sans état : tout est dérivé du `Project` et de l'`AgentId` reçus + de
|
||||
/// l'environnement (`$APPIMAGE`/`current_exe`). C'est le **seul** détenteur légitime
|
||||
/// de ces faits (la couche `application` ne doit pas dépendre de `current_exe` /
|
||||
/// `$APPIMAGE` / `mcp_endpoint`, cadrage v5 §0.3 / §7) ; seules des **chaînes**
|
||||
/// traversent la frontière via [`McpRuntime`].
|
||||
pub struct AppMcpRuntimeProvider;
|
||||
|
||||
impl McpRuntimeProvider for AppMcpRuntimeProvider {
|
||||
/// `agent_id` = la cible relancée = le `--requester` (c'est elle qui appellera
|
||||
/// `idea_reply`). Réutilise la **même** logique que le chemin GUI
|
||||
/// (`commands.rs::launch_agent`) : même endpoint (source de vérité unique),
|
||||
/// même forme `simple` 32-hex du `project_id`. `None` (exe introuvable) ⇒
|
||||
/// l'orchestrateur dégrade vers la déclaration minimale, jamais d'échec de
|
||||
/// lancement.
|
||||
fn runtime_for(&self, project: &Project, agent_id: AgentId) -> Option<McpRuntime> {
|
||||
Some(McpRuntime {
|
||||
exe: idea_exe_path()?,
|
||||
endpoint: mcp_endpoint(&project.id).as_cli_arg().to_owned(),
|
||||
project_id: project.id.as_uuid().simple().to_string(),
|
||||
requester: agent_id.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@ -170,4 +213,77 @@ mod tests {
|
||||
assert!(path.extension().is_some_and(|e| e == "sock"));
|
||||
assert_eq!(path.parent().unwrap(), unix_runtime_dir());
|
||||
}
|
||||
|
||||
/// `idea_exe_path()` : `$APPIMAGE` posé ⇒ on renvoie sa valeur (chemin stable du
|
||||
/// `.AppImage`, qui survit aux redémarrages) ; absent ⇒ on retombe sur
|
||||
/// `current_exe()`. Les deux assertions sont regroupées dans **un seul** test pour
|
||||
/// éviter une course inter-tests sur la variable d'env globale du process ; la var
|
||||
/// est sauvegardée/restaurée pour ne pas polluer les autres tests.
|
||||
#[test]
|
||||
fn idea_exe_path_prefers_appimage_then_falls_back_to_current_exe() {
|
||||
let saved = std::env::var_os("APPIMAGE");
|
||||
|
||||
// APPIMAGE posé ⇒ valeur exacte renvoyée.
|
||||
std::env::set_var("APPIMAGE", "/opt/idea/IdeA.AppImage");
|
||||
assert_eq!(
|
||||
idea_exe_path().as_deref(),
|
||||
Some("/opt/idea/IdeA.AppImage"),
|
||||
"$APPIMAGE doit primer"
|
||||
);
|
||||
|
||||
// APPIMAGE absent ⇒ repli sur current_exe() (présent dans un binaire de test).
|
||||
std::env::remove_var("APPIMAGE");
|
||||
let fallback = idea_exe_path();
|
||||
let expected = std::env::current_exe()
|
||||
.ok()
|
||||
.map(|p| p.to_string_lossy().into_owned());
|
||||
assert_eq!(fallback, expected, "repli attendu sur current_exe()");
|
||||
assert!(fallback.is_some(), "current_exe() résolvable dans le test");
|
||||
|
||||
// Restauration de l'état initial de la variable.
|
||||
match saved {
|
||||
Some(v) => std::env::set_var("APPIMAGE", v),
|
||||
None => std::env::remove_var("APPIMAGE"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `AppMcpRuntimeProvider::runtime_for` : cohérence des champs avec les sources de
|
||||
/// vérité — endpoint identique à `mcp_endpoint(project.id).as_cli_arg()`,
|
||||
/// `project_id` en forme `simple` 32-hex, `requester == agent_id.to_string()`.
|
||||
#[test]
|
||||
fn app_provider_runtime_for_matches_sources_of_truth() {
|
||||
use domain::{AgentId, ProjectId, Project};
|
||||
use domain::project::ProjectPath;
|
||||
use domain::remote::RemoteRef;
|
||||
|
||||
let project = Project::new(
|
||||
ProjectId::from_uuid(Uuid::parse_str("abcdef01-2345-6789-abcd-ef0123456789").unwrap()),
|
||||
"demo",
|
||||
ProjectPath::new("/home/me/proj").unwrap(),
|
||||
RemoteRef::local(),
|
||||
1_700_000_000_000,
|
||||
)
|
||||
.unwrap();
|
||||
let agent_id = AgentId::from_uuid(Uuid::from_u128(42));
|
||||
|
||||
// On s'assure que current_exe/$APPIMAGE est résolvable (sinon runtime_for None).
|
||||
let rt = AppMcpRuntimeProvider
|
||||
.runtime_for(&project, agent_id)
|
||||
.expect("runtime_for doit produire un McpRuntime (exe résolvable)");
|
||||
|
||||
// Endpoint = même source de vérité que le listener.
|
||||
assert_eq!(
|
||||
rt.endpoint,
|
||||
mcp_endpoint(&project.id).as_cli_arg(),
|
||||
"endpoint cohérent avec mcp_endpoint()"
|
||||
);
|
||||
// project_id en forme simple 32-hex (hyphen-free), consommée par la garde M5c.
|
||||
assert_eq!(rt.project_id, project.id.as_uuid().simple().to_string());
|
||||
assert_eq!(rt.project_id.len(), 32, "forme simple 32-hex");
|
||||
assert!(!rt.project_id.contains('-'), "pas de tirets");
|
||||
// requester = l'id de la cible relancée.
|
||||
assert_eq!(rt.requester, agent_id.to_string());
|
||||
// exe non vide.
|
||||
assert!(!rt.exe.is_empty(), "exe renseigné");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user