diff --git a/.ideai/agents/devbackend.md b/.ideai/agents/devbackend.md index d612ec6..a3e18f7 100644 --- a/.ideai/agents/devbackend.md +++ b/.ideai/agents/devbackend.md @@ -51,9 +51,10 @@ Le workspace Cargo multi-crate, sens des dépendances **strict** (`Présentation ## 5. Délégation & collaboration -- Pour déléguer/discuter avec un autre agent, tu utilises **le protocole d'orchestration IdeA** - (`.ideai/requests//`), **jamais** les subagents natifs du fournisseur. *(Tant que - l'orchestration v3 n'est pas livrée, Main relaie manuellement.)* +- Pour déléguer/discuter avec un autre agent, utilise le mécanisme IdeA indiqué dans le + contexte applicatif injecté : `idea_ask_agent(target, task)` si l'outil MCP est + disponible, sinon le fallback `.ideai/requests//`. Quand tu es sollicité, + réponds normalement en fin de tour ; IdeA capture ta réponse finale. - Ta source de vérité d'architecture est `architect.md`. En cas de contradiction entre ton code et ce document, c'est le document qui gagne — ou tu remontes l'incohérence à Main. diff --git a/.ideai/agents/devfrontend.md b/.ideai/agents/devfrontend.md index e1b8dc4..4d3e8ac 100644 --- a/.ideai/agents/devfrontend.md +++ b/.ideai/agents/devfrontend.md @@ -51,9 +51,10 @@ contrat IPC de ton côté. ## 5. Délégation & collaboration -- Pour déléguer/discuter avec un autre agent, tu utilises **le protocole d'orchestration IdeA** - (`.ideai/requests//`), **jamais** les subagents natifs du fournisseur. *(Tant que - l'orchestration v3 n'est pas livrée, Main relaie manuellement.)* +- Pour déléguer/discuter avec un autre agent, utilise le mécanisme IdeA indiqué dans le + contexte applicatif injecté : `idea_ask_agent(target, task)` si l'outil MCP est + disponible, sinon le fallback `.ideai/requests//`. Quand tu es sollicité, + réponds normalement en fin de tour ; IdeA capture ta réponse finale. - Source de vérité d'architecture : `architect.md`. Contradiction code↔doc ⇒ le doc gagne, ou tu remontes à Main. diff --git a/.ideai/agents/git.md b/.ideai/agents/git.md index e3d2009..5763bc6 100644 --- a/.ideai/agents/git.md +++ b/.ideai/agents/git.md @@ -109,11 +109,11 @@ tu le dis. ## 5. Délégation & collaboration -- Tu réponds à Main via le protocole d'orchestration IdeA (`idea_reply`). Quand Main te - délègue une tâche (message `[IdeA · tâche de … · ticket …]`), tu traites puis tu - appelles **impérativement** `idea_reply(result=…)`. +- Quand Main te délègue une tâche via IdeA, tu la traites puis tu termines ton tour avec + ta réponse normale. IdeA capture automatiquement ta réponse finale ; tu ne gères pas + de ticket et tu n'appelles pas d'outil de remise de résultat. - Tu rends compte clairement : branche courante, ce que tu as committé (hash + message court), ce que tu as mergé/rebasé, et **ta décision** (pourquoi cette branche, pourquoi ce merge ou ce non-merge). - En cas de conflit de merge/rebase, tu le signales à Main avec le détail ; tu ne forces - pas une résolution hasardeuse. \ No newline at end of file + pas une résolution hasardeuse. diff --git a/.ideai/agents/main.md b/.ideai/agents/main.md index dfa8b47..28607bb 100644 --- a/.ideai/agents/main.md +++ b/.ideai/agents/main.md @@ -25,12 +25,12 @@ Exception limitée : tu peux modifier les fichiers de contexte, mémoire, docume Pour déléguer, utilise uniquement les outils IdeA natifs : - `idea_list_agents` pour identifier les agents disponibles. -- `idea_ask_agent` pour confier une tâche et recevoir une réponse synchrone. +- `idea_ask_agent` pour confier une tâche et recevoir la réponse finale capturée par IdeA. - `idea_launch_agent` pour lancer ou rattacher un agent si nécessaire. N'utilise jamais les subagents natifs du fournisseur IA pour ce projet. -Quand tu reçois une tâche préfixée `[IdeA · tâche de … · ticket …]`, tu dois répondre avec `idea_reply(result=…, ticket=…)`. Une réponse texte seule ne débloque pas l'agent appelant. +Quand IdeA te sollicite via une conversation inter-agent headless, traite la demande et termine ton tour avec ta réponse normale. Ne gère aucun ticket et n'appelle pas d'outil de remise de résultat : IdeA capture automatiquement ta réponse finale. --- @@ -108,4 +108,4 @@ Si la demande utilisateur contredit le cycle, rappelle brièvement la règle et --- -*Dernière mise à jour : 2026-06-20* \ No newline at end of file +*Dernière mise à jour : 2026-06-20* diff --git a/.ideai/agents/qa.md b/.ideai/agents/qa.md index 438039b..70acc68 100644 --- a/.ideai/agents/qa.md +++ b/.ideai/agents/qa.md @@ -55,9 +55,10 @@ Quand c'est rouge, ton rapport au dev (via Main) contient : ## 5. Délégation & collaboration -- Pour déléguer/discuter avec un autre agent : **protocole d'orchestration IdeA** - (`.ideai/requests//`), **jamais** de subagent natif fournisseur. *(En attendant - l'orchestration v3, Main relaie.)* +- Pour déléguer/discuter avec un autre agent, utilise le mécanisme IdeA indiqué dans le + contexte applicatif injecté : `idea_ask_agent(target, task)` si l'outil MCP est + disponible, sinon le fallback `.ideai/requests//`. Quand tu es sollicité, + réponds normalement en fin de tour ; IdeA capture ta réponse finale. - Source de vérité d'architecture : `architect.md`. Tes tests valident la conformité du code à ce document. @@ -70,7 +71,7 @@ Trois chantiers (cadence **A+B ensemble, puis C**). Points de vigilance test : sur agent inconnu, etc.). - **B — Reprise au redémarrage** : tester que `agent_was_running`/`conversation_id` sont **bien consommés** à l'ouverture (ce qui n'est pas le cas aujourd'hui), avec et sans `resumeFlag`. -- **C — Orchestration v3** : tester le routage `ask_agent` (réponse synchrone corrélée), le repli +- **C — Orchestration v3** : tester le routage `ask_agent` (réponse finale capturée), le repli fichier quand un profil ne supporte pas MCP, la non-régression du protocole `.ideai/requests`. Tu interviens **après** le cadrage d'`Architect`, en binôme avec le dev du lot concerné, jusqu'au diff --git a/.ideai/memory/MEMORY.md b/.ideai/memory/MEMORY.md index ef99f9a..bc181e0 100644 --- a/.ideai/memory/MEMORY.md +++ b/.ideai/memory/MEMORY.md @@ -42,3 +42,4 @@ - [mcp-functional-tests-t1-t9-green-live-2026-06-24](mcp-functional-tests-t1-t9-green-live-2026-06-24.md) — memory note mcp-functional-tests-t1-t9-green-live-2026-06-24 - [mcp-t10a-harness-interrupt-does-not-cancel-rendezvous](mcp-t10a-harness-interrupt-does-not-cancel-rendezvous.md) — memory note mcp-t10a-harness-interrupt-does-not-cancel-rendezvous - [mcp-t10b-pending-reboot-verification](mcp-t10b-pending-reboot-verification.md) — memory note mcp-t10b-pending-reboot-verification +- [headless-interagent-conversation-objective](headless-interagent-conversation-objective.md) — memory note headless-interagent-conversation-objective diff --git a/.ideai/memory/headless-interagent-conversation-objective.md b/.ideai/memory/headless-interagent-conversation-objective.md new file mode 100644 index 0000000..c61b981 --- /dev/null +++ b/.ideai/memory/headless-interagent-conversation-objective.md @@ -0,0 +1,40 @@ +--- +name: headless-interagent-conversation-objective +description: memory note headless-interagent-conversation-objective +metadata: + type: project +--- +# Objectif chantier — remplacer la conversation inter-agent MCP par headless robuste + +## Intention produit + +Le projet veut se séparer du MCP pour la **conversation inter-agent**, car le rendez-vous MCP a provoqué trop de blocages, wedges, busy fantômes et pertes de résultats. MCP reste conservé pour les autres outils IdeA : mémoire, liste d'agents, contexte, workstate et fonctions non conversationnelles. + +Le nouveau mécanisme doit utiliser les modes **headless** fournis par les modèles/CLIs comme interface de communication entre agents, tout en gardant le modèle mental : **1 agent = 1 employé**. + +## Règles fonctionnelles validées + +- Un agent n'a qu'une seule conversation canonique et une seule identité opérationnelle, qu'il soit utilisé via la cellule CLI par l'utilisateur ou via headless par un autre agent. +- Quand un agent B travaille pour un agent A, aucun autre agent ni l'utilisateur ne peut lui parler tant que B n'a pas fini. +- L'utilisateur doit pouvoir voir que B est occupé et, si possible, suivre le travail headless en temps réel. La solidité prime sur cette UI temps réel. +- L'utilisateur doit pouvoir cancel le travail d'un agent occupé. +- Si l'utilisateur annule A dans la CLI alors que A attend B, l'annulation doit cascader vers B. +- B ne peut être annulé que par l'agent qui lui parle ou par l'utilisateur, pas par un autre agent tiers. +- Si plusieurs agents veulent parler à B, les demandes attendent en FIFO simple jusqu'à ce que B soit libre. +- Le headless est seulement l'interface de communication agent-agent : mêmes mémoire, contexte, historique, permissions, cwd et outils qu'en usage CLI interactif. +- Un historique reconstitué doit être accessible depuis la cellule, via un bouton, avec toutes les conversations de la session dans un historique unique. +- L'architecture reste hexagonale : conversation canonique par modèle/adapters, pas de dépendance directe dispersée aux formats natifs. + +## Priorité de conception + +Priorité 1 : robustesse et absence de blocage durable. + +Le design doit privilégier des garanties mécaniques simples : processus headless borné, fin par exit process, timeout, cancel explicite, nettoyage d'état idempotent, queue FIFO observable, et résultat synthétique en cas d'échec. + +Priorité 2 : observabilité et UX. + +L'affichage temps réel du travail headless est souhaité si le mode headless permet de streamer stdout/stderr ou événements structurés, mais ne doit pas fragiliser le protocole. À défaut, fournir statut occupé, bouton cancel, historique final et diagnostic exploitable. + +## Décision de périmètre + +Le MCP n'est pas retiré globalement. Il est retiré uniquement du chemin critique de conversation inter-agent. Les outils IdeA existants peuvent rester exposés aux agents via MCP tant qu'ils ne servent pas au rendez-vous conversationnel. \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index e2d4982..e5a4d33 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,9 +21,12 @@ Les agents peuvent être lancés depuis un dossier d'exécution isolé `.ideai/r Les agents collaborent via les outils IdeA natifs : - `idea_list_agents` pour lister les agents. -- `idea_ask_agent` pour déléguer une tâche et attendre la réponse. +- `idea_ask_agent` pour déléguer une tâche et recevoir la réponse finale capturée par IdeA. - `idea_launch_agent` pour lancer ou rattacher un agent. -- `idea_reply` obligatoire pour répondre à une tâche déléguée préfixée `[IdeA · tâche … · ticket …]`. + +Quand un agent est sollicité via IdeA, il répond normalement en fin de tour. IdeA capture +automatiquement cette réponse finale ; les agents ne gèrent pas de ticket et n'appellent +pas d'outil de remise de résultat. Ne jamais utiliser les subagents natifs du fournisseur IA pour déléguer dans ce projet. @@ -90,4 +93,4 @@ Consulter la mémoire projet selon le besoin au lieu de recopier tous les détai --- -*Dernière mise à jour : 2026-06-20* \ No newline at end of file +*Dernière mise à jour : 2026-06-20* diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 4f84154..c5de06e 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -1069,7 +1069,9 @@ pub fn list_live_agents( std::sync::Arc::clone(&state.terminal_sessions), std::sync::Arc::clone(&state.structured_sessions), ); - Ok(LiveAgentListDto::from_pairs(live.live_agents())) + Ok(LiveAgentListDto::from_snapshots( + live.live_agent_snapshots(), + )) } /// `attach_live_agent` — rebind an already-running agent session to a visible @@ -1116,9 +1118,15 @@ pub async fn stop_live_agent( ) -> Result { let project = resolve_project(&request.project_id, &state).await?; let agent_id = parse_agent_id(&request.agent_id)?; + let dependencies = state + .orchestrator_service + .active_wait_dependencies(agent_id); // Backstop no-reply : arrêter l'observateur de fin de tour de l'agent (le handle est // droppé ⇒ polling stoppé) avant de démonter sa session. state.stop_turn_watch(agent_id); + for dependency in dependencies { + state.stop_turn_watch(dependency); + } state .stop_live_agent .execute(StopLiveAgentInput { project, agent_id }) diff --git a/crates/app-tauri/src/dto.rs b/crates/app-tauri/src/dto.rs index 9dc9693..664c0cb 100644 --- a/crates/app-tauri/src/dto.rs +++ b/crates/app-tauri/src/dto.rs @@ -12,8 +12,8 @@ use application::{ AgentTicketState, AppError, AttachLiveAgentOutput, ConversationPreviewStatus, ConversationTurnWorkPreview, ConversationWorkSummary, CreateProjectInput, CreateProjectOutput, GitGraphOutput, HealthInput, HealthReport, LayoutKind, ListProjectsOutput, LiveSessionKind, - OpenProjectOutput, ProjectWorkState, StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus, - TurnPage, TurnSource, TurnView, + LiveSessionSnapshot, OpenProjectOutput, ProjectWorkState, StopLiveAgentOutput, + TicketWorkSource, TicketWorkStatus, TurnPage, TurnSource, TurnView, }; use domain::{AgentBusyState, PageCursor, PageDirection, Project, ProjectId, TurnRole}; @@ -1460,6 +1460,8 @@ pub struct LiveAgentDto { /// The live PTY session id, used to reattach a newly-opened cell without /// respawning the agent. pub session_id: String, + /// Runtime family that owns the session (`pty`/`structured`). + pub kind: LiveWorkSessionKindDto, } /// Response DTO for `list_live_agents` (transparent array on the wire). @@ -1468,7 +1470,7 @@ pub struct LiveAgentDto { pub struct LiveAgentListDto(pub Vec); impl LiveAgentListDto { - /// Builds the wire list from the registry's `(AgentId, NodeId, SessionId)` tuples. + /// Builds the wire list from the registry's live-session snapshots. /// /// De-duplicates by `agent_id`: the "one live session per agent" invariant /// guarantees an agent is live in at most one registry (PTY **or** @@ -1476,16 +1478,17 @@ impl LiveAgentListDto { /// twice; the UI contract is a dup-free set (each agent appears once), so we /// keep the first occurrence and drop any later one for the same agent. #[must_use] - pub fn from_pairs(pairs: Vec<(AgentId, NodeId, domain::SessionId)>) -> Self { + pub fn from_snapshots(pairs: Vec) -> Self { let mut seen = std::collections::HashSet::new(); Self( pairs .into_iter() - .filter(|(agent_id, _, _)| seen.insert(*agent_id)) - .map(|(agent_id, node_id, session_id)| LiveAgentDto { - agent_id: agent_id.to_string(), - node_id: node_id.to_string(), - session_id: session_id.to_string(), + .filter(|snapshot| seen.insert(snapshot.agent_id)) + .map(|snapshot| LiveAgentDto { + agent_id: snapshot.agent_id.to_string(), + node_id: snapshot.node_id.to_string(), + session_id: snapshot.session_id.to_string(), + kind: snapshot.kind.into(), }) .collect(), ) diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index 45210a7..b6bcd6f 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -28,14 +28,13 @@ use application::{ OrchestratorService, PermissionProjectorRegistry, ProposeContext, ReadAgentContext, ReadContext, ReadConversationPage, ReadMemory, ReadMemoryIndex, ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts, ReconcileLiveState, ReconcileLiveStateInput, RecordTurn, - RecordTurnProvider, ReferenceProfiles, - RenameLayout, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks, - RotateConversationLog, SaveEmbedderProfile, SaveProfile, SessionLimitService, SetActiveLayout, - SnapshotRunningAgents, StopLiveAgent, StructuredSessions, SuggestedThisSession, - SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent, UpdateAgentContext, - UpdateAgentPermissions, UpdateLiveState, UpdateMemory, UpdateProjectContext, - UpdateProjectPermissions, UpdateSkill, UpdateTemplate, WriteMemory, WriteToTerminal, - AGENT_MEMORY_RECALL_BUDGET, + RecordTurnProvider, ReferenceProfiles, RenameLayout, ResizeTerminal, ResolveAgentPermissions, + ResolveMemoryLinks, RotateConversationLog, SaveEmbedderProfile, SaveProfile, + SessionLimitService, SetActiveLayout, SnapshotRunningAgents, StopLiveAgent, StructuredSessions, + SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent, + UpdateAgentContext, UpdateAgentPermissions, UpdateLiveState, UpdateMemory, + UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateTemplate, WriteMemory, + WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, }; use domain::ports::{ AgentContextStore, AgentRuntime, AgentSessionFactory, Clock, Embedder, EmbedderEnvInspector, @@ -59,8 +58,7 @@ use infrastructure::{ FsProjectStore, FsProviderSessionStore, FsSkillStore, FsTemplateStore, Git2Repository, HeuristicHandoffSummarizer, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall, - OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, - StructuredSessionFactory, + OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, StructuredSessionFactory, SystemClock, SystemMillisClock, TokioBroadcastEventBus, TokioScheduler, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, @@ -235,10 +233,7 @@ impl AppReconcileLiveState { /// /// # Errors /// [`AppError`] si le projet est inconnu (registre) ou si le store échoue. - pub(crate) async fn execute( - &self, - input: ReconcileLiveStateInput, - ) -> Result<(), AppError> { + pub(crate) async fn execute(&self, input: ReconcileLiveStateInput) -> Result<(), AppError> { let project = self.projects.load_project(input.project_id).await?; let store = Arc::new(FsLiveStateStore::new(&project.root)); let uc = ReconcileLiveState::new( @@ -928,15 +923,9 @@ impl AppState { // LaunchAgent shares the SAME pty_port and terminal_sessions as the terminal // use cases — indispensable for the PtyBridge to work correctly. // - // 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) + // The human-facing launcher intentionally stays PTY-only: when the user opens an + // agent cell, they keep the native Claude/Codex CLI and its commands. Inter-agent + // delegation gets its own launcher below, wired to structured/headless sessions. // --- Permission projectors (lot LP3-5) --- // UN seul registre, source unique de vérité, injecté à l'identique dans @@ -989,6 +978,40 @@ impl AppState { }) as Arc), ); + // Inter-agent launcher: same context, memory, permissions and live-state + // injection as the human launcher, but with the structured factory wired. It is + // used only by OrchestratorService::ask_agent when a delegated target must be + // started headlessly; UI launches still go through `launch_agent` above. + let orchestrator_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, + Arc::clone(&memory_recall_port), + Some(Arc::clone(&check_embedder_suggestion)), + ) + .with_permission_store(Arc::clone(&permission_store_port)) + .with_handoff_provider( + Arc::new(AppHandoffProvider) as Arc + ) + .with_provider_session_provider(Arc::new(AppProviderSessionProvider) + as Arc) + .with_permission_projectors(Arc::clone(&permission_projectors)) + .with_live_state_lean(Arc::new(AppLiveStateLeanProvider { + clock: Arc::clone(&clock) as Arc, + }) as Arc) + .with_structured( + Arc::clone(&session_factory), + Arc::clone(&structured_sessions), + ), + ); + // 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 // *composes* the launcher above for the in-place relaunch (no duplication). @@ -1034,9 +1057,11 @@ impl AppState { // Backstop no-reply : observateur de fin de tour transcript (Claude `turn_duration`) // — lit le même `/.claude/projects//` que l'inspecteur, via // le même `FileSystem`. Armé par agent supporté au lancement (cf. `arm_turn_watch`). - let turn_watcher: Arc = Arc::new( - infrastructure::ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs_port), home_dir.clone()), - ); + let turn_watcher: Arc = + Arc::new(infrastructure::ClaudeTranscriptTurnWatcher::new( + Arc::clone(&fs_port), + home_dir.clone(), + )); let inspect_conversation = Arc::new(InspectConversation::new( Arc::clone(&contexts_port), Arc::clone(&profile_store_port), @@ -1259,10 +1284,6 @@ impl AppState { // (rebind de la cellule-vue, zéro spawn) et stop (kill PTY via la primitive // `CloseTerminal` existante / shutdown structuré). Aucune création de session. let attach_live_agent = Arc::new(AttachLiveAgent::new(Arc::clone(&live_sessions))); - let stop_live_agent = Arc::new(StopLiveAgent::new( - Arc::clone(&live_sessions), - Arc::clone(&close_terminal), - )); // --- Limites de session des agents (ARCHITECTURE §21, LS7) --- // Service pur-ports « détecter → planifier → reprendre » câblé sur l'existant : @@ -1334,7 +1355,7 @@ impl AppState { let orchestrator_service = Arc::new( OrchestratorService::new( Arc::clone(&create_agent), - Arc::clone(&launch_agent), + Arc::clone(&orchestrator_launch_agent), Arc::clone(&list_agents), Arc::clone(&close_terminal), Arc::clone(&update_agent_context), @@ -1412,7 +1433,8 @@ impl AppState { ); let cwd = domain::project::ProjectPath::new(run_dir).ok()?; infrastructure::transcript_activity_token(fs.as_ref(), &home, &cwd).await - }) as std::pin::Pin> + Send>> + }) + as std::pin::Pin> + Send>> }) as application::AskLivenessProbe }) // Plafond absolu du rendez-vous délégué (réglage projet via @@ -1422,19 +1444,20 @@ impl AppState { std::env::var("IDEA_ASK_RENDEZVOUS_CEILING_MS") .ok() .and_then(|v| v.trim().parse::().ok()), - )), - // NB (régression corrigée) : on ne câble PAS `.with_structured(...)` ici. - // Décision produit lot B-2 (« Option 1 Terminal + MCP », cf. construction - // de `LaunchAgent` plus haut) : la fabrique structurée est décâblée, donc - // AUCUN agent n'a de session structurée vivante — tous tournent en PTY brut - // et la délégation passe par les outils MCP (`idea_ask_agent`/`idea_reply`). - // Si l'orchestrateur recevait `with_structured`, `ask_agent` emprunterait la - // branche structurée (`ensure_structured_session`) qui ne peut jamais aboutir - // (le launcher ne crée plus de session structurée) ⇒ erreur systématique - // « aucune session structurée vivante après lancement ». On laisse donc - // `self.structured = None` pour que `ask_agent` retombe sur le chemin PTY+MCP - // fonctionnel. `drain_with_readiness` (readiness/heartbeat lot 1) reste dormant - // tant que la voie structurée n'est pas réactivée au composition root. + )) + // Conversation inter-agent headless : le service voit le même registre que le + // launcher orchestrateur ci-dessus. Une cible à `structured_adapter` est donc + // démarrée/drainée via `AgentSession::send` et son `Final`, sans dépendre de + // `idea_reply`; les autres outils MCP restent câblés par ailleurs. + .with_structured(Arc::clone(&structured_sessions)), + ); + + let stop_live_agent = Arc::new( + StopLiveAgent::new(Arc::clone(&live_sessions), Arc::clone(&close_terminal)) + .with_cascade( + Arc::clone(&input_mediator), + Arc::clone(&orchestrator_service), + ), ); // --- Windows (L10) --- @@ -1627,59 +1650,10 @@ impl AppState { service_for_ready.release_agent_cold_start(AgentId::from_uuid(uuid)); } }); - // Borne du rendez-vous `idea_ask_agent` (filet serveur, plancher universel FINI) : - // réglage projet optionnel via `IDEA_ASK_RENDEZVOUS_TIMEOUT_MS`. Absent / invalide / - // `0` ⇒ défaut fini (600 s) — jamais (quasi-)infini, sinon une cible silencieuse - // wedge l'appelant. - let ask_timeout = infrastructure::resolve_ask_rendezvous_timeout( - std::env::var("IDEA_ASK_RENDEZVOUS_TIMEOUT_MS") - .ok() - .and_then(|v| v.trim().parse::().ok()), - ); - // Plafond absolu du rendez-vous (réglage projet optionnel via - // `IDEA_ASK_RENDEZVOUS_CEILING_MS`, défaut 4 h) : borne dure que la fenêtre - // d'inactivité réarmée ne dépasse jamais, même contre une cible perpétuellement active. - let ask_ceiling = infrastructure::resolve_ask_rendezvous_ceiling( - std::env::var("IDEA_ASK_RENDEZVOUS_CEILING_MS") - .ok() - .and_then(|v| v.trim().parse::().ok()), - ); - // Sonde d'activité de la cible (signe de vie du rendez-vous) : l'McpServer (infra) ne - // connaît la cible que par son NOM ; la composition root est la seule à savoir - // résoudre nom→AgentId puis dériver le run-dir transcript Claude. La sonde renvoie un - // jeton monotone = octets cumulés des `.jsonl` de la cible (croît même pendant un seul - // long tour sans `turn_duration`). `None` ⇒ cible/transcript introuvable ⇒ traité comme - // « pas de progrès » par le watchdog. - let service_for_probe = Arc::clone(&self.orchestrator_service); - let fs_for_probe = Arc::clone(&self.fs_port); - let home_for_probe = self.home_dir.clone(); - let project_for_probe = project.clone(); - let activity_probe: infrastructure::AskActivityProbe = Arc::new(move |target: String| { - let service = Arc::clone(&service_for_probe); - let fs = Arc::clone(&fs_for_probe); - let home = home_for_probe.clone(); - let project = project_for_probe.clone(); - Box::pin(async move { - let agent_id = service - .resolve_agent_id_by_name(&project, &target) - .await - .ok() - .flatten()?; - let run_dir = format!( - "{}/.ideai/run/{agent_id}", - project.root.as_str().trim_end_matches(['/', '\\']) - ); - let cwd = domain::project::ProjectPath::new(run_dir).ok()?; - infrastructure::transcript_activity_token(fs.as_ref(), &home, &cwd).await - }) - }); let handle = McpServerHandle::start( McpServer::new(Arc::clone(&self.orchestrator_service), project.clone()) .with_events(events) - .with_ready_sink(ready_sink) - .with_ask_rendezvous_timeout(ask_timeout) - .with_ask_rendezvous_ceiling(ask_ceiling) - .with_activity_probe(activity_probe), + .with_ready_sink(ready_sink), endpoint, listener, project_id, @@ -3368,8 +3342,6 @@ mod mcp_serve_peer_tests { let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect(); for expected in [ "idea_list_agents", - "idea_ask_agent", - "idea_reply", "idea_launch_agent", "idea_stop_agent", "idea_update_context", @@ -3390,10 +3362,12 @@ mod mcp_serve_peer_tests { "missing tool {expected}; got {names:?}" ); } + assert!(!names.contains(&"idea_ask_agent")); + assert!(!names.contains(&"idea_reply")); assert_eq!( tools.len(), - 14, - "exactly the fourteen idea_* tools (7 base + 4 FileGuard C7 + idea_skill_read + 2 live-state LS4); got {names:?}" + 12, + "exactly the twelve non-conversation idea_* tools; got {names:?}" ); drop(client); // EOF ⇒ serve loop ends diff --git a/crates/app-tauri/tests/dto_agents.rs b/crates/app-tauri/tests/dto_agents.rs index 6d1d2d9..fa1cc59 100644 --- a/crates/app-tauri/tests/dto_agents.rs +++ b/crates/app-tauri/tests/dto_agents.rs @@ -10,8 +10,8 @@ use app_tauri_lib::dto::{ use application::AppError; use application::{ AgentTicketState, AgentWorkState, CreateAgentOutput, InspectConversationOutput, - LaunchAgentOutput, ListAgentsOutput, LiveSessionKind, LiveWorkSession, ProjectWorkState, - TicketWorkSource, TicketWorkStatus, + LaunchAgentOutput, ListAgentsOutput, LiveSessionKind, LiveSessionSnapshot, LiveWorkSession, + ProjectWorkState, TicketWorkSource, TicketWorkStatus, }; use domain::ids::{AgentId, NodeId, ProfileId, SessionId}; use domain::ports::ConversationDetails; @@ -187,13 +187,19 @@ fn live_agent_list_dto_serialises_camelcase_array() { let agent_a = AgentId::from_uuid(Uuid::from_u128(11)); let node_a = NodeId::from_uuid(Uuid::from_u128(21)); let session_a = domain::SessionId::from_uuid(Uuid::from_u128(31)); - let dto = LiveAgentListDto::from_pairs(vec![(agent_a, node_a, session_a)]); + let dto = LiveAgentListDto::from_snapshots(vec![LiveSessionSnapshot { + agent_id: agent_a, + node_id: node_a, + session_id: session_a, + kind: LiveSessionKind::Pty, + }]); let v = serde_json::to_value(&dto).unwrap(); let arr = v.as_array().expect("transparent array"); assert_eq!(arr.len(), 1); assert_eq!(arr[0]["agentId"], agent_a.to_string()); assert_eq!(arr[0]["nodeId"], node_a.to_string()); assert_eq!(arr[0]["sessionId"], session_a.to_string()); + assert_eq!(arr[0]["kind"], "pty"); // No snake_case leak. assert!(arr[0].get("agent_id").is_none()); assert!(arr[0].get("node_id").is_none()); diff --git a/crates/app-tauri/tests/list_live_agents_r0b.rs b/crates/app-tauri/tests/list_live_agents_r0b.rs index 57dc070..2ed8e25 100644 --- a/crates/app-tauri/tests/list_live_agents_r0b.rs +++ b/crates/app-tauri/tests/list_live_agents_r0b.rs @@ -5,7 +5,7 @@ //! //! Ces tests reproduisent **exactement** ce que fait la commande //! `list_live_agents` : construire un `LiveSessions` à partir des deux registres -//! partagés et passer `live_agents()` à `LiveAgentListDto::from_pairs`. Ils +//! partagés et passer `live_agent_snapshots()` à `LiveAgentListDto::from_snapshots`. Ils //! exercent donc le câblage de l'agrégateur **et** la dé-duplication par agent //! portée par le DTO (contrat de liveness pour l'UI). 100 % fakes, sans process. @@ -71,7 +71,7 @@ fn insert_pty(pty: &TerminalSessions, s: SessionId, a: AgentId, n: NodeId) { /// Reproduit le corps de la commande `list_live_agents` (hors validation d'id). fn dto_for(pty: &Arc, structured: &Arc) -> LiveAgentListDto { let live = LiveSessions::new(Arc::clone(pty), Arc::clone(structured)); - LiveAgentListDto::from_pairs(live.live_agents()) + LiveAgentListDto::from_snapshots(live.live_agent_snapshots()) } // =========================================================================== @@ -90,6 +90,8 @@ fn pty_live_agent_is_listed() { assert_eq!(dto.0[0].agent_id, a.to_string()); assert_eq!(dto.0[0].node_id, nid(100).to_string()); assert_eq!(dto.0[0].session_id, sid(1).to_string()); + let json = serde_json::to_value(&dto).unwrap(); + assert_eq!(json[0]["kind"], "pty"); } // =========================================================================== @@ -112,6 +114,8 @@ fn structured_live_agent_is_listed() { assert_eq!(dto.0[0].agent_id, a.to_string()); assert_eq!(dto.0[0].node_id, nid(200).to_string()); assert_eq!(dto.0[0].session_id, sid(2).to_string()); + let json = serde_json::to_value(&dto).unwrap(); + assert_eq!(json[0]["kind"], "structured"); } // =========================================================================== @@ -140,6 +144,15 @@ fn both_kinds_live_listed_without_duplicates() { let mut uniq = ids.clone(); uniq.dedup(); assert_eq!(uniq.len(), dto.0.len(), "aucun doublon d'agent"); + let json = serde_json::to_value(&dto).unwrap(); + let mut kinds = json + .as_array() + .unwrap() + .iter() + .map(|row| row["kind"].as_str().unwrap()) + .collect::>(); + kinds.sort_unstable(); + assert_eq!(kinds, vec!["pty", "structured"]); } // =========================================================================== @@ -167,6 +180,8 @@ fn same_agent_in_both_registries_is_deduplicated() { // On garde la première occurrence (PTY, listé en premier par l'agrégateur). assert_eq!(dto.0[0].node_id, nid(100).to_string()); assert_eq!(dto.0[0].session_id, sid(1).to_string()); + let json = serde_json::to_value(&dto).unwrap(); + assert_eq!(json[0]["kind"], "pty"); } // =========================================================================== diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index dafcf67..24fed26 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -2732,30 +2732,25 @@ pub(crate) fn compose_convention_file( out.push_str("---\n\n"); out.push_str("# Orchestration IdeA\n\n"); if mcp_enabled { - // Surface MCP (cadrage v3, D3) : l'alternative native aux subagents est - // exposée comme outils typés `idea_*`. L'interdiction des subagents natifs - // du fournisseur est **conservée** ; seule la voie de délégation change. + // Surface MCP : outils IdeA natifs. `idea_ask_agent` est une porte d'entrée + // ergonomique vers l'orchestrateur headless, pas un protocole ask/reply exposé + // aux modèles. out.push_str( - "Pour déléguer une tâche à un autre agent, n'utilise jamais les subagents \ - natifs du fournisseur IA. Utilise les outils IdeA natifs : \ - `idea_ask_agent` (déléguer une tâche et recevoir la réponse), \ - `idea_launch_agent` (lancer/réattacher un agent) et `idea_list_agents` \ - (lister les agents du projet). IdeA lancera ou réattachera l'agent cible \ - avec son propre AI Profile, son contexte et sa mémoire.\n\n", + "La conversation inter-agent est gérée par IdeA en headless. Pour contacter \ + un autre agent, utilise l'outil MCP IdeA `idea_ask_agent(target, task)` quand \ + il est disponible : IdeA lance ou réattache la cible via son profil \ + structured/headless, capture sa réponse finale et te la renvoie inline. \ + N'invente pas de protocole de ticket et n'appelle pas d'outil de remise de \ + résultat.\n\n", ); - // Protocole de délégation (Option 1, B-5) : côté agent SOLLICITÉ. Une tâche - // déléguée arrive dans ton terminal préfixée `[IdeA · tâche de … · ticket …]`. - // Tu DOIS y répondre via l'outil `idea_reply`, jamais en texte libre — sinon - // l'agent qui t'a sollicité reste bloqué (sa réponse ne lui parviendra pas). + // Conversation inter-agent : le transport est géré par IdeA en headless. Les + // agents n'ont plus à connaître un protocole de reply ni à corréler des tickets. out.push_str( - "Quand tu reçois une tâche déléguée par IdeA (un message préfixé \ - `[IdeA · tâche de … · ticket …]`), traite-la puis appelle \ - **impérativement** l'outil `idea_reply(ticket=…, result=…)` pour rendre ton \ - résultat — **même pour une réponse triviale** (un simple `pong`). Ne réponds \ - **JAMAIS** uniquement en texte/prose dans le terminal : tant que tu n'as pas \ - appelé `idea_reply`, l'agent qui t'a sollicité reste **bloqué** et sa réponse \ - ne lui parviendra pas. Terminer ton tour sans `idea_reply` est un échec du \ - protocole, pas une réponse.\n\n", + "Quand IdeA te sollicite via une conversation inter-agent headless, traite \ + simplement la demande et termine ton tour avec ta réponse normale. N'ajoute \ + pas de protocole de ticket, n'appelle pas d'outil de remise de résultat : IdeA \ + capture automatiquement la réponse finale du modèle et la transmet à l'agent \ + demandeur.\n\n", ); // Capacités IdeA (feature skill-awareness) : briefing à HAUTE ALTITUDE, // télégraphique, injecté à CHAQUE lancement (coût token → pas d'exemples ni de @@ -3379,22 +3374,18 @@ mod tests { #[test] fn compose_convention_file_mcp_prose_points_to_idea_tools_and_keeps_subagent_ban() { - // mcp_enabled = true ⇒ prose exposes the native `idea_*` tools while keeping - // the ban on the provider's native subagents (cadrage v3, Décision 3). + // mcp_enabled = true ⇒ prose exposes the remaining native `idea_*` tools while + // keeping the ban on the provider's native subagents (cadrage v3, Décision 3). let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], true); // Native IdeA orchestration tools surfaced. assert!( - doc.contains("idea_ask_agent"), - "MCP prose must mention idea_ask_agent" - ); - assert!(doc.contains("idea_launch_agent")); - assert!(doc.contains("idea_list_agents")); - // Native-subagent ban preserved. - assert!( - doc.contains("n'utilise jamais les subagents"), - "MCP prose must keep the native-subagent ban" + doc.contains("idea_context_read"), + "MCP prose must mention remaining IdeA tools" ); + assert!(doc.contains("idea_memory_read")); + assert!(doc.contains("idea_ask_agent")); + assert!(!doc.contains("idea_reply")); // It does NOT fall back to the file protocol wording. assert!( !doc.contains(".ideai/requests"), @@ -3403,20 +3394,23 @@ mod tests { } #[test] - fn compose_convention_file_mcp_prose_carries_the_idea_reply_delegation_protocol() { - // B-5 — the solicited-agent side of the protocol: a delegated task arrives as - // `[IdeA · tâche …]` and MUST be answered via `idea_reply`, never plain text. + fn compose_convention_file_mcp_prose_does_not_carry_idea_reply_protocol() { + // Inter-agent conversation is headless: the model's final answer is captured by + // IdeA. Agents must not be instructed to call a reply tool or echo tickets. let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], true); assert!( - doc.contains("idea_reply"), - "MCP prose must instruct answering via idea_reply" + !doc.contains("idea_reply"), + "MCP prose must not instruct answering via idea_reply" ); assert!( - doc.contains("[IdeA · tâche"), - "MCP prose must describe the delegated-task prefix it answers to" + doc.contains("capture automatiquement la réponse finale") + || doc.contains("capture sa réponse finale"), + "MCP prose must describe final-answer capture" + ); + assert!( + !doc.contains("[IdeA · tâche"), + "MCP prose must not describe the old delegated-task prefix" ); - // Negative: the non-MCP (file-protocol) prose must NOT carry the idea_reply - // instruction (zero regression on the file path). let file_doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], false); assert!(!file_doc.contains("idea_reply")); diff --git a/crates/application/src/agent/mod.rs b/crates/application/src/agent/mod.rs index 0dd82a8..95b0d98 100644 --- a/crates/application/src/agent/mod.rs +++ b/crates/application/src/agent/mod.rs @@ -23,8 +23,7 @@ pub use structured::{ }; pub use catalogue::{ - reference_profile_id, reference_profiles, - selectable_reference_profiles, CODEX_SUBMIT_DELAY_MS, + reference_profile_id, reference_profiles, selectable_reference_profiles, CODEX_SUBMIT_DELAY_MS, }; pub use inspect::{InspectConversation, InspectConversationInput, InspectConversationOutput}; pub use lifecycle::{ diff --git a/crates/application/src/orchestrator/rendezvous.rs b/crates/application/src/orchestrator/rendezvous.rs index 7411b1b..7cff587 100644 --- a/crates/application/src/orchestrator/rendezvous.rs +++ b/crates/application/src/orchestrator/rendezvous.rs @@ -284,11 +284,23 @@ mod tests { /// `Some(0)` ⇒ "pas d'override" (jamais d'effondrement instantané). #[test] fn resolvers_apply_override_else_finite_default() { - assert_eq!(resolve_rendezvous_window(Some(1234)), Duration::from_millis(1234)); - assert_eq!(resolve_rendezvous_window(Some(0)), DEFAULT_RENDEZVOUS_WINDOW); + assert_eq!( + resolve_rendezvous_window(Some(1234)), + Duration::from_millis(1234) + ); + assert_eq!( + resolve_rendezvous_window(Some(0)), + DEFAULT_RENDEZVOUS_WINDOW + ); assert_eq!(resolve_rendezvous_window(None), DEFAULT_RENDEZVOUS_WINDOW); - assert_eq!(resolve_rendezvous_ceiling(Some(9999)), Duration::from_millis(9999)); - assert_eq!(resolve_rendezvous_ceiling(Some(0)), DEFAULT_RENDEZVOUS_CEILING); + assert_eq!( + resolve_rendezvous_ceiling(Some(9999)), + Duration::from_millis(9999) + ); + assert_eq!( + resolve_rendezvous_ceiling(Some(0)), + DEFAULT_RENDEZVOUS_CEILING + ); assert_eq!(resolve_rendezvous_ceiling(None), DEFAULT_RENDEZVOUS_CEILING); } } diff --git a/crates/application/src/orchestrator/service.rs b/crates/application/src/orchestrator/service.rs index cb3edd8..99f05ee 100644 --- a/crates/application/src/orchestrator/service.rs +++ b/crates/application/src/orchestrator/service.rs @@ -22,7 +22,7 @@ use tokio::sync::Mutex as AsyncMutex; use domain::conversation::{ConversationParty, ConversationRegistry, SessionRef, WaitForGraph}; use domain::conversation_log::{ConversationTurn, TurnId, TurnRole}; use domain::input::{InputMediator, InputSource, SubmitConfig}; -use domain::mailbox::{Ticket, TicketId, TurnResolution}; +use domain::mailbox::{Ticket, TicketId}; use domain::ports::{Clock, EventBus, ProfileStore, PtyHandle}; use domain::project::ProjectPath; use domain::{ @@ -315,6 +315,11 @@ pub struct OrchestratorService { /// `ask` A→B, retirée au reply/timeout (RAII via le garde de tour). Sert à /// **refuser** une délégation ré-entrante (A→B→…→A) avant deadlock. wait_for: StdMutex, + /// Snapshot opérationnel des mêmes arêtes d'attente, exposé au chemin d'arrêt + /// utilisateur : si A est stoppé pendant qu'il attend B, IdeA stoppe aussi B. + /// Séparé de [`WaitForGraph`] qui reste un objet domaine minimal de détection de + /// cycle, sans API de traversal. + active_waits: StdMutex>, /// Bus d'événements pour publier [`DomainEvent::AgentReplied`] à l'issue d'un /// `ask` réussi (§17.4). Injecté via [`Self::with_events`] ; `None` ⇒ pas de /// publication (l'`ask` fonctionne quand même). @@ -459,6 +464,7 @@ impl OrchestratorService { mailbox: None, conversations: None, wait_for: StdMutex::new(WaitForGraph::new()), + active_waits: StdMutex::new(Vec::new()), events: None, ask_locks: StdMutex::new(HashMap::new()), mcp_runtime_provider: None, @@ -537,6 +543,46 @@ impl OrchestratorService { Arc::clone(locks.entry(*agent_id).or_default()) } + /// Returns the transitive set of agents currently waited on by `agent`. + /// + /// Used by user-driven cancellation: stopping A while A waits on B should also + /// stop B, and then any agent B itself waits on. The snapshot is best-effort and + /// lock-bounded; callers perform the actual interruption/stop outside the mutex. + #[must_use] + pub fn active_wait_dependencies(&self, agent: AgentId) -> Vec { + let edges = self + .active_waits + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + Self::active_wait_dependencies_from_edges(&edges, agent) + } + + /// Returns the transitive dependencies of `agent` in an active wait-edge snapshot. + #[must_use] + fn active_wait_dependencies_from_edges( + edges: &[(AgentId, AgentId)], + agent: AgentId, + ) -> Vec { + let mut out = Vec::new(); + let mut stack: Vec = edges + .iter() + .filter_map(|(from, to)| (*from == agent).then_some(*to)) + .collect(); + while let Some(next) = stack.pop() { + if out.contains(&next) { + continue; + } + out.push(next); + stack.extend( + edges + .iter() + .filter_map(|(from, to)| (*from == next).then_some(*to)), + ); + } + out + } + /// Branche le **médiateur d'entrée** (cadrage C3 §5.2) pour servir /// `agent.message`/[`OrchestratorCommand::AskAgent`] et /// `agent.reply`/[`OrchestratorCommand::Reply`]. Le `mailbox` est le moteur de @@ -1203,32 +1249,28 @@ impl OrchestratorService { }) } - /// `agent.message` / `idea_ask_agent`: the **inter-agent delegation rendezvous** - /// (Option 1 « Terminal + MCP », lot B-3). + /// `agent.message` / `idea_ask_agent`: the **inter-agent delegation rendezvous**. /// - /// The target's human-facing view is now a **raw native terminal** (PTY REPL), and - /// delegation flows through the terminal's single FIFO input plus the MCP mailbox: + /// The MCP tool and the file watcher both enter here, but they are only entrypoints + /// into the same application orchestration path: /// /// 1. Resolve the target by name and acquire its **per-agent turn lock** so two /// `ask`s for the same target serialise FIFO (1 agent = 1 employee). - /// 2. Ensure the target is **live in the PTY registry** — reusing its terminal if - /// it is already running, otherwise launching it in the background (a normal - /// PTH launch: a live PTY *is* the channel now, not an error as before). - /// 3. **Enqueue a ticket** in the [`AgentMailbox`] (registering the reply slot) - /// **then write** the task into the target's terminal, prefixed with the asking - /// agent + ticket id so the target knows to answer via `idea_reply`. - /// 4. **Await** the [`domain::mailbox::PendingReply`] bounded by [`ASK_AGENT_TIMEOUT`]: - /// the target's later `idea_reply(result)` lands in [`Self::reply`] → - /// `mailbox.resolve`, waking this await. On timeout the ticket is retired from - /// the head ([`AgentMailbox::cancel_head`]) — **the target stays alive** — and a - /// typed timeout is returned (retry possible). - /// 5. Return the reply as [`OrchestratorOutcome::reply`] and publish + /// 2. Resolve the requester/target conversation and reject wait-for cycles before + /// enqueueing anything. + /// 3. Ensure the target has a structured/headless [`domain::ports::AgentSession`], + /// launching it through the profile adapter if it is cold. + /// 4. Drive the turn directly through [`domain::ports::AgentSession::send`] and + /// drain until the structured `Final` is captured. The target does **not** see + /// a ticket and does **not** call `idea_reply`. + /// 5. Return the captured final answer as [`OrchestratorOutcome::reply`] and publish /// [`DomainEvent::AgentReplied`]. /// /// # Errors /// - [`AppError::NotFound`] if the target agent is unknown; - /// - [`AppError::Invalid`] if the mailbox/PTY channel is not wired; - /// - [`AppError::Process`] on a launch/PTY-write failure, or on the await timeout + /// - [`AppError::Invalid`] if structured/headless orchestration is not wired, or if + /// the target profile cannot be driven as a structured session; + /// - [`AppError::Process`] on launch/session failure, or on the await timeout /// (turn timeout *or* queue-wait timeout — same typed error). async fn ask_agent( &self, @@ -1237,29 +1279,12 @@ impl OrchestratorService { task: String, requester: Option, ) -> Result { - let (input, mailbox) = match (&self.input, &self.mailbox) { - (Some(i), Some(m)) => (i, m), - _ => { - return Err(AppError::Invalid( - "la messagerie inter-agents (idea_ask_agent) n'est pas disponible : \ - médiateur d'entrée non câblé" - .to_owned(), - )) - } - }; - let agent = self .find_agent_by_name(project, &target) .await? .ok_or_else(|| AppError::NotFound(format!("agent {target}")))?; let agent_id = agent.id; - // F2 — garde profil : refuser **immédiatement** une cible dont le profil ne - // sait pas consommer le pont `idea_*` matérialisé via `.mcp.json`, plutôt que - // de laisser le round-trip échouer en timeout muet (300s). - self.guard_mcp_bridge_supported(&agent.profile_id, &target) - .await?; - // Détection de cycle (cadrage C3 §6) : si l'ask vient d'un **agent** A vers la // cible B, refuser AVANT tout enqueue si poser l'arête A→B fermerait un cycle // d'attente (B attend déjà …→A). Pur, sans I/O ⇒ jamais de deadlock. @@ -1323,234 +1348,37 @@ impl OrchestratorService { // Poser l'arête d'attente A→B (retirée en fin de tour par le RAII `_edge`). let _edge = requester.map(|from| WaitEdgeGuard::new(self, from, agent_id)); - // ── Chemin **structuré** (readiness/heartbeat lot 1) ────────────────────── - // Une cible à `structured_adapter` n'a **pas** de PTY : `ensure_live_pty` - // échouerait, et le tour ne pourrait se débloquer que par un `idea_reply` - // explicite (cause racine du blocage `Busy`). Quand le registre structuré est - // câblé et que la cible a une session vivante, on draine son tour via - // `drain_with_readiness` : le `Final` déterministe réveille le `pending` (valeur - // de retour) **et** marque l'agent `Idle` (`mark_idle`). On enregistre tout de - // même un ticket dans la FIFO pour la comptabilité busy et pour préserver - // `idea_reply` comme signal **alternatif** (premier arrivé gagne). - if let Some(structured) = self.structured.as_ref() { - // Lot 1b — auto-lancement d'une cible **froide** structurée : si le registre - // est câblé, qu'aucune session ne vit encore pour la cible, mais que son - // profil porte un `structured_adapter`, on **démarre** sa session via le - // launcher (qui route §17.4 vers `launch_structured` et l'insère dans CE - // même registre, avec la conf MCP matérialisée) plutôt que de tomber dans - // `ensure_live_pty` — chemin PTY qui échouerait pour une cible sans PTY. - // Une cible SANS `structured_adapter` (agent PTY/TUI legacy) conserve le - // chemin `ensure_live_pty` ci-dessous (zéro régression). - if let Some(session) = self - .ensure_structured_session(project, agent_id, &agent.profile_id, structured) - .await? - { - return self - .ask_structured( - project, - agent_id, - &target, - conversation_id, - requester, - task, - session.as_ref(), - ) - .await; - } - } - - // 1. Garantir la cible vivante en PTY pour CE fil ; lier sa session à la - // conversation, et brancher son handle d'entrée sur le médiateur (livraison). - let (handle, cold_launch) = self - .ensure_live_pty(project, agent_id, conversation_id, &target) - .await?; - // Résout la config de soumission du profil cible + s'il déclare un pont MCP. - let (submit, has_mcp) = self.submit_and_mcp_for_agent(project, agent_id).await; - // Gate cold-launch : un agent froid n'est pas encore prêt à recevoir son 1er tour. - // On le diffère s'il existe un signal pour le libérer — la connexion du pont MCP - // de l'agent (`InputMediator::release_cold_start`, déclenchée par l'McpServer sur - // `initialize`). C'est désormais le **seul** signal de readiness (le watcher - // prompt-ready PTY a été supprimé). Sans pont MCP ⇒ pas de gate (livraison - // immédiate, sinon blocage indéfini). - let gate_cold_start = cold_launch && has_mcp; - // Diagnostics : décision de gate du premier tour. `gate_cold_start=false` sur une - // cible froide SANS pont MCP livrerait immédiatement ; un `true` diffère la - // livraison jusqu'au signal MCP-initialize. - crate::diag!( - "[rendezvous] gate decision: target={target} (agent {agent_id}) cold_launch={cold_launch} \ - has_mcp={has_mcp} gate_cold_start={gate_cold_start}", - ); - if gate_cold_start { - input.mark_starting(agent_id); - } - input.bind_handle_with_submit(agent_id, handle.clone(), submit); - - // 2. Enregistrer le ticket (slot de réponse) + livrer le tour via le médiateur - // (écriture sérialisée dans le PTY — plus d'écriture ad hoc ici). Le ticket - // porte la source (Human/Agent) et la conversation cible. - let requester_label = self.requester_label(project, requester).await; - let ticket_id = TicketId::new_random(); - // Checkpoint Prompt (P6b, best-effort) : persister l'invite AVANT que `task` ne - // soit déplacé dans le `Ticket`. Source = origine de la requête (agent demandeur - // `Some(from)` ⇒ Agent, sinon Humain) — **même** `InputSource` que le ticket. - let prompt_source = match requester { - Some(from) => InputSource::agent(from), - None => InputSource::Human, - }; - self.record_turn_best_effort( - &project.root, + // ── Chemin **structuré/headless uniquement** ────────────────────────────── + // La conversation inter-agent ne passe plus par le PTY ni par le rendez-vous + // `idea_reply` MCP. La cible doit être pilotable via `AgentSession::send`; son + // `Final` est la seule réponse normale du tour. Les profils legacy PTY/TUI sont + // refusés au lieu de retomber sur l'ancien chemin MCP fragile. + let structured = self.structured.as_ref().ok_or_else(|| { + AppError::Invalid( + "la conversation inter-agent headless n'est pas disponible : registre \ + de sessions structurées non câblé" + .to_owned(), + ) + })?; + let session = self + .ensure_structured_session(project, agent_id, &agent.profile_id, structured) + .await? + .ok_or_else(|| { + AppError::Invalid(format!( + "la cible '{target}' ne peut pas recevoir de conversation inter-agent : \ + son profil ne déclare pas d'adaptateur structured/headless" + )) + })?; + self.ask_structured( + project, + agent_id, + &target, conversation_id, - prompt_source, - TurnRole::Prompt, - task.clone(), + requester, + task, + session.as_ref(), ) - .await; - // Live-state (lot LS3) : distiller l'intent AVANT que `task` ne soit déplacé - // dans le `Ticket`. Posé sur la cible après l'enqueue (délégation acceptée). - let working_intent = Self::distill_intent(&task); - let ticket = match requester { - Some(from) => { - Ticket::from_agent(ticket_id, from, conversation_id, requester_label, task) - } - None => Ticket::from_human(ticket_id, conversation_id, requester_label, task), - }; - // Timeout de tour piloté par profil (lot 2) : `turn_timeout_ms` de la cible si - // défini, sinon le défaut [`ASK_AGENT_TIMEOUT`]. Arme aussi le seuil de stall sur - // le médiateur AVANT l'enqueue (consommé au start_turn). - let turn_timeout = self.turn_timeout_for(project, agent_id).await; - let pending = input.enqueue(agent_id, ticket); - // Auto-update live-state (lot LS3), best-effort : la cible passe `Working` sur - // cette transition d'`ask` acceptée. N'altère jamais le succès de la délégation. - self.mark_target_working_best_effort(&project.root, agent_id, ticket_id, working_intent) - .await; - // Rendezvous beacon (diagnostics) : l'ask est désormais en attente du - // `idea_reply` (ou prompt-ready) de la cible. Si la cible termine son tour en - // texte SANS appeler `idea_reply`, ce beacon « ask started » n'aura pas de - // « ask resolved » correspondant avant l'expiration du `turn_timeout` — la - // signature exacte du blocage Main→cible. - let started = Instant::now(); - crate::diag!( - "[rendezvous] ask started: requester={} -> target={target} (agent {agent_id}) \ - conversation={conversation_id} ticket={ticket_id} cold_launch={cold_launch} \ - gate_cold_start={gate_cold_start} turn_timeout_ms={}", - requester.map_or_else(|| "user".to_owned(), |a| a.to_string()), - turn_timeout.as_millis(), - ); - // Garde RAII de fin de tour, armé JUSTE après l'enqueue (la cible est maintenant - // `Busy`). Quel que soit le chemin de sortie — erreur, timeout, ou **futur - // abandonné (drop)** — son `Drop` ramène la cible `Idle` et retire le ticket - // fantôme de la FIFO. C'est le fix de la cause racine (cf. [`BusyTurnGuard`]). - let busy_guard = - BusyTurnGuard::new(Arc::clone(input), Arc::clone(mailbox), agent_id, ticket_id); - // Delivery is the mediator's responsibility (`InputMediator::enqueue` writes the - // turn into the bound handle). The service no longer writes the PTY directly — - // no ad-hoc `[IdeA · tâche …]` line here, no `\r` band-aid (cadrage C3 §5.1). - - // 3. Attendre la réponse, bornée par la **fenêtre d'inactivité réarmable** - // (signe de vie = sonde de transcript) au lieu d'un timeout plat : un long - // tour unique qui progresse n'est plus coupé à `turn_timeout`. Vrai silence - // ⇒ timeout typé (comme avant) ; plafond atteint malgré progrès ⇒ erreur - // typée distincte. Canal fermé ⇒ le garde retire le ticket au Drop. - let verdict = self - .run_ask_with_watchdog(pending, turn_timeout, &project.root, agent_id, &target, started) - .await; - match verdict { - WatchdogOutcome::Resolved(Ok(TurnResolution::Replied(result))) => { - // Checkpoint Response (P6b, best-effort) : persister la réponse AVANT de - // déplacer `result` dans `reply_outcome`. Source = la **cible** (c'est - // elle qui a rendu le tour) ; même conversation que le Prompt. - self.record_turn_best_effort( - &project.root, - conversation_id, - InputSource::agent(agent_id), - TurnRole::Response, - result.clone(), - ) - .await; - // Auto-memory harvest (Lot E1), APRÈS l'append/handoff de la réponse : - // parse les blocs ` ```idea-memory ` et persiste les notes valides. - // Best-effort strict — n'altère jamais ce succès de délégation. - self.harvest_memory_best_effort(&project.root, &result) - .await; - // Auto-update live-state (lot LS3), best-effort : la cible vient de rendre - // son résultat ⇒ `Done` + `last_delegation` = ticket résolu. - self.mark_target_done_best_effort(&project.root, agent_id, ticket_id) - .await; - // Succès : désarmer le garde AVANT de retourner. Le `mark_idle` propre - // sur cette branche est porté par le médiateur (prompt-ready / idea_reply - // qui a résolu le `pending`) ; on ne veut ni re-`cancel_head` un ticket - // déjà résolu, ni libérer un busy state qui ne nous appartient plus. - busy_guard.disarm(); - crate::diag!( - "[rendezvous] ask resolved: target={target} (agent {agent_id}) \ - ticket={ticket_id} after_ms={} reply_len={}", - started.elapsed().as_millis(), - result.len(), - ); - Ok(self.reply_outcome(agent_id, &target, result)) - } - // La cible est revenue à son prompt SANS `idea_reply` : la fenêtre de grâce a - // expiré et le médiateur a complété le tour « sans réponse ». Le ticket est - // déjà retiré (`complete_without_reply`) et la cible déjà `Idle` (prompt-ready - // `mark_idle`) ⇒ on **désarme** le garde (pas de `cancel_head`/`mark_idle` - // redondant ni de beacon « busy-guard freed » trompeur) et on renvoie une - // erreur typée claire/retryable, en ~G au lieu du timeout long. On repasse - // aussi la live-state de la cible à `Done` (best-effort) : le tour est conclu - // sans réponse, donc `idea_workstate_read` ne doit pas la voir `Working` - // (busy fantôme) comme sur la branche succès. - WatchdogOutcome::Resolved(Ok(TurnResolution::ReturnedToPromptNoReply)) => { - busy_guard.disarm(); - self.mark_target_done_best_effort(&project.root, agent_id, ticket_id) - .await; - crate::diag!( - "[rendezvous] ask returned-to-prompt-no-reply: target={target} \ - (agent {agent_id}) ticket={ticket_id} after_ms={}", - started.elapsed().as_millis(), - ); - Err(AppError::TargetReturnedNoReply(target)) - } - // Plafond absolu atteint alors que la cible **progresse encore** (sonde de vie - // croissante) : verdict DISTINCT, non un faux timeout muet. Le garde fait - // `cancel_head` + `mark_idle` au Drop ; on réconcilie aussi la live-state à - // `Done` pour qu'un abandon ne laisse pas un busy fantôme. - WatchdogOutcome::CeilingActive => { - self.mark_target_done_best_effort(&project.root, agent_id, ticket_id) - .await; - crate::diag!( - "[rendezvous] ask CEILING (still active): target={target} \ - (agent {agent_id}) ticket={ticket_id} after_ms={}", - started.elapsed().as_millis(), - ); - Err(AppError::TargetCeilingActive(target)) - } - // Erreur / timeout : on laisse le garde faire `cancel_head` + `mark_idle` au - // Drop (retrait des `cancel_head` redondants — `cancel_head` reste idempotent). - WatchdogOutcome::Resolved(Err(_cancelled)) => { - crate::diag!( - "[rendezvous] ask channel-closed: target={target} (agent {agent_id}) \ - ticket={ticket_id} after_ms={}", - started.elapsed().as_millis(), - ); - Err(AppError::Process(format!( - "agent {target} : canal de réponse fermé avant un résultat" - ))) - } - WatchdogOutcome::NoReply => { - // Vrai silence (aucun progrès sur une fenêtre) : sémantique identique à - // l'ancien timeout plat. On réconcilie la live-state à `Done` pour qu'un - // abandon ne laisse pas la cible `Working` (busy fantôme) ; le garde fait - // `cancel_head` + `mark_idle` au Drop. - self.mark_target_done_best_effort(&project.root, agent_id, ticket_id) - .await; - crate::diag!( - "[rendezvous] ask TIMEOUT: target={target} (agent {agent_id}) \ - ticket={ticket_id} after_ms={} (la cible n'a jamais appelé idea_reply \ - ni atteint son prompt-ready, et aucun progrès observé sur la fenêtre)", - started.elapsed().as_millis(), - ); - Err(AppError::from(domain::ports::AgentSessionError::Timeout)) - } - } + .await } /// Chemin `ask` **structuré** (readiness/heartbeat lot 1) : la cible a un @@ -1562,8 +1390,8 @@ impl OrchestratorService { /// n'a pas). C'est le fix de la cause racine du blocage `Busy`. /// /// On enregistre tout de même un ticket dans la FIFO (`enqueue`) pour la comptabilité - /// busy et pour **préserver `idea_reply` comme signal alternatif** : on attend la - /// **première** des deux issues (réponse de la session OU résolution du `pending`). + /// busy et l'exclusion mutuelle, mais il n'est plus une source de réponse : le ticket + /// est retiré quand le [`domain::ports::ReplyEvent::Final`] structured a été reçu. /// Le checkpoint Prompt/Response best-effort est conservé à l'identique du chemin PTY. #[allow(clippy::too_many_arguments)] async fn ask_structured( @@ -1601,7 +1429,8 @@ impl OrchestratorService { ) .await; - // Ticket dans la FIFO : comptabilité busy + `idea_reply` comme signal alternatif. + // Ticket dans la FIFO : comptabilité busy + exclusion mutuelle. La réponse ne + // viendra plus de cette mailbox, seulement du `Final` structured/headless. let requester_label = self.requester_label(project, requester).await; let ticket_id = TicketId::new_random(); let ticket = match requester { @@ -1617,7 +1446,7 @@ impl OrchestratorService { // Timeout de tour piloté par profil (lot 2) + armement du seuil de stall, AVANT // l'enqueue qui démarre le tour (le médiateur arme alors sa fenêtre de vivacité). let turn_timeout = self.turn_timeout_for(project, agent_id).await; - let pending = input.enqueue(agent_id, ticket); + let _pending = input.enqueue_silent(agent_id, ticket); // Auto-update live-state (lot LS3), best-effort : la cible passe `Working` sur // cette transition d'`ask` acceptée (chemin structuré). `task` est encore vivant // ici (utilisé par le drain plus bas), on le distille directement. @@ -1636,8 +1465,8 @@ impl OrchestratorService { BusyTurnGuard::new(Arc::clone(input), Arc::clone(mailbox), agent_id, ticket_id); // Rendezvous beacon (chemin structuré) : équivalent du « ask started » du chemin - // PTY. La cible n'a pas de PTY ; le tour se débloque sur le `Final` de sa session - // OU sur un `idea_reply`. Sans l'un des deux avant `turn_timeout`, le drain expire. + // PTY. La cible n'a pas de PTY ; le tour se débloque uniquement sur le `Final` + // de sa session structured/headless. let started = Instant::now(); crate::diag!( "[rendezvous] ask started (structured): requester={} -> target={target} \ @@ -1651,35 +1480,12 @@ impl OrchestratorService { // est **non borné** (`None`) : la borne de tour est désormais portée par la // **fenêtre d'inactivité réarmable** autour de l'attente (signe de vie = sonde de // transcript), pas par un timeout plat interne — un long tour unique qui progresse - // n'est plus coupé à `turn_timeout`. On attend la **première** issue : le tour - // structuré OU un `idea_reply` explicite. + // n'est plus coupé à `turn_timeout`. Aucun `idea_reply` ne peut résoudre ce tour. let drain = drain_with_readiness(session, &task, None, input.as_ref(), agent_id); - // L'attente du rendez-vous (drain OU reply), rendue comme `Result` - // pour être enveloppée par le watchdog (au lieu de `return` directs dans le `select!`). - let wait = async { - tokio::select! { - biased; - drained = drain => match drained { - Ok(content) => Ok(content), - Err(err) => Err(AppError::from(err)), - }, - replied = pending => match replied { - Ok(TurnResolution::Replied(content)) => { - // La session draine encore en arrière-plan ; on fait avancer la FIFO. - input.mark_idle(agent_id); - Ok(content) - } - Ok(TurnResolution::ReturnedToPromptNoReply) => { - input.mark_idle(agent_id); - Err(AppError::TargetReturnedNoReply(target.to_owned())) - } - Err(_cancelled) => Err(AppError::Process(format!( - "agent {target} : canal de réponse fermé avant un résultat" - ))), - }, - } - }; + // L'attente du rendez-vous structured, rendue comme `Result` + // pour être enveloppée par le watchdog. + let wait = async { drain.await.map_err(AppError::from) }; // Borne par la fenêtre d'inactivité (réarmée sur signe de vie) sous plafond absolu. let result = match self @@ -1733,8 +1539,10 @@ impl OrchestratorService { } }; - // Succès : désarmer le garde (le `mark_idle` propre est déjà porté par le `Final` - // de la session ou par le bras `replied`) — pas de double `cancel_head`. + // Succès : le `Final` a rendu la réponse. On retire explicitement le ticket de + // comptabilité (aucun `idea_reply` ne le fera), puis on désarme le garde RAII. + mailbox.cancel_head(agent_id, ticket_id); + input.mark_idle(agent_id); busy_guard.disarm(); // Checkpoint Response (best-effort), AVANT de déplacer `result`. @@ -2107,15 +1915,14 @@ impl OrchestratorService { /// - `Ok(Some(session))` si la cible a déjà une session vivante, **ou** si son /// profil porte un `structured_adapter` et qu'on vient de la (re)lancer ; /// - `Ok(None)` si la cible n'est **pas** structurée (profil sans `structured_adapter`, - /// ou profil introuvable) ⇒ l'appelant retombe sur le chemin PTY `ensure_live_pty`. + /// ou profil introuvable) ⇒ `AskAgent` refuse la conversation inter-agent. /// - /// L'auto-lancement réutilise le **même** [`LaunchAgent`] que le chemin PTV - /// ([`Self::ensure_live_pty`]) avec le **même** `mcp_runtime` matérialisé : pour un - /// profil structuré, le launcher route §17.4 vers `launch_structured`, démarre la - /// session via la fabrique et l'insère dans le registre [`StructuredSessions`] - /// **partagé** (le même `Arc` que `self.structured`, câblé au composition root). - /// La conf MCP est donc matérialisée comme pour une cellule chat lancée à la main, - /// si bien que la cible voit les outils `idea_*` pour répondre. + /// L'auto-lancement réutilise le **même** [`LaunchAgent`] que les lancements UI : + /// pour un profil structuré, le launcher route §17.4 vers `launch_structured`, + /// démarre la session via la fabrique et l'insère dans le registre + /// [`StructuredSessions`] **partagé**. Le runtime MCP peut encore être matérialisé + /// pour les outils non conversationnels, mais il ne participe plus à la résolution + /// de la réponse inter-agent. async fn ensure_structured_session( &self, project: &Project, @@ -2129,7 +1936,8 @@ impl OrchestratorService { } // Cible froide : ne (re)lancer que si le profil sait être piloté en mode - // structuré. Sinon (agent PTY/TUI legacy, ou profil introuvable) ⇒ chemin PTY. + // structuré. Sinon (agent PTY/TUI legacy, ou profil introuvable) ⇒ refus par + // l'appelant. let is_structured = self .profiles .list() @@ -2141,9 +1949,26 @@ impl OrchestratorService { return Ok(None); } + // Une cible peut déjà être vivante dans le registre PTY parce qu'elle a été + // ouverte depuis la surface humaine historique (cellule/menu), alors que le + // chemin inter-agent actuel exige une session `AgentSession` headless. Si on + // laisse ce PTY en place, `LaunchAgent` applique correctement l'invariant + // « 1 session vivante/agent » et rend le PTY existant, donc aucune session + // structurée n'est insérée et l'ask échoue avec « aucune session structurée ». + // + // Le rendez-vous inter-agent est propriétaire du canal headless : on retire + // d'abord l'éventuelle session PTY de la cible, puis on relance via le launcher + // structuré partagé. Le node hôte est conservé best-effort pour que la surface + // puisse se rattacher au même emplacement si elle observe l'événement de relance. + let previous_node = self.sessions.node_for_agent(&agent_id); + if let Some(session_id) = self.sessions.session_for_agent(&agent_id) { + self.close_terminal + .execute(CloseTerminalInput { session_id }) + .await?; + } + // Démarrer la session via le launcher (route §17.4 → `launch_structured`, - // insère dans CE registre). Mêmes faits MCP que le chemin PTY pour que le pont - // `idea_*` de la cible se branche. `conversation_id: None` ⇒ le launcher dérive + // insère dans CE registre). `conversation_id: None` ⇒ le launcher dérive // l'id de paire (User↔agent) ou réutilise celui de la cellule (P8a), comme pour // un lancement direct utilisateur. self.launch_agent @@ -2152,7 +1977,7 @@ impl OrchestratorService { agent_id, rows: DEFAULT_ROWS, cols: DEFAULT_COLS, - node_id: None, + node_id: previous_node, conversation_id: None, mcp_runtime: self .mcp_runtime_provider @@ -2386,55 +2211,6 @@ impl OrchestratorService { .find(|a| a.name.eq_ignore_ascii_case(name))) } - /// Garde F2 : vérifie que le profil de la cible **sait consommer** le pont - /// `idea_*` matérialisé par IdeA, et renvoie sinon une [`AppError::Invalid`] - /// **immédiate** (au lieu d'un timeout 300s muet sur le round-trip). - /// - /// **Critère retenu** (le plus robuste aujourd'hui) : le pont est honoré ssi le - /// profil porte une capacité MCP en stratégie `ConfigFile` ciblant `.mcp.json` - /// **ET** que son adaptateur structuré est `Claude`. En effet IdeA matérialise le - /// serveur MCP sous forme d'un fichier `.mcp.json` dans le run dir, ce que **seul** - /// Claude Code lit réellement ; Codex déclare pourtant la même stratégie - /// `ConfigFile(.mcp.json)` mais lit en pratique `~/.codex/config.toml` ⇒ le pont - /// n'est jamais branché et la cible ne peut pas appeler `idea_reply`. On exige donc - /// l'adaptateur `Claude` plutôt qu'une simple présence de capacité MCP, ce qui - /// exclut Codex de fait et reste valable pour tout futur profil non-Claude. - /// - /// Profil introuvable ⇒ on **n'interdit pas** (laisse le flux suivre son cours - /// comme avant) : la garde ne fait que transformer un échec connu en erreur typée. - async fn guard_mcp_bridge_supported( - &self, - profile_id: &ProfileId, - target: &str, - ) -> Result<(), AppError> { - let Some(profile) = self - .profiles - .list() - .await? - .into_iter() - .find(|p| &p.id == profile_id) - else { - return Ok(()); - }; - - // Source de vérité UNIQUE (domaine) : un profil supporte le pont `idea_*` ssi - // IdeA matérialise réellement sa config MCP pour la CLI qu'il pilote — Claude - // via `.mcp.json`, Codex via `config.toml`/`CODEX_HOME`. Tout autre couple - // (y compris MCP absent) ⇒ repli fichier, pont non branché. - if profile.materializes_idea_bridge() { - return Ok(()); - } - - Err(AppError::Invalid(format!( - "la cible '{target}' (profil '{}', adaptateur {:?}) ne supporte pas encore le \ - pont idea_* : la délégation inter-agents passe par un serveur MCP qu'IdeA \ - matérialise pour la CLI cible, et ce profil ne déclare pas un couple \ - (adaptateur × stratégie MCP) pris en charge. Cible un agent dont le profil \ - expose le pont MCP (Claude ou Codex).", - profile.name, profile.structured_adapter - ))) - } - /// Resolves the target agent profile's **submit config** /// (`submit_sequence`/`submit_delay_ms`, ARCHITECTURE §20.3) **and** whether it /// declares an **MCP bridge**, in a single profile lookup. The submit config is @@ -2623,6 +2399,7 @@ impl OrchestratorService { /// service outlives the guard. struct WaitEdgeGuard<'a> { graph: &'a StdMutex, + active_waits: &'a StdMutex>, from: AgentId, to: AgentId, } @@ -2636,8 +2413,18 @@ impl<'a> WaitEdgeGuard<'a> { .unwrap_or_else(std::sync::PoisonError::into_inner); g.add_edge(from, to); } + { + let mut waits = service + .active_waits + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if !waits.contains(&(from, to)) { + waits.push((from, to)); + } + } Self { graph: &service.wait_for, + active_waits: &service.active_waits, from, to, } @@ -2651,6 +2438,11 @@ impl Drop for WaitEdgeGuard<'_> { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); g.remove_edge(self.from, self.to); + let mut waits = self + .active_waits + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + waits.retain(|edge| *edge != (self.from, self.to)); } } @@ -2737,6 +2529,20 @@ mod tests { assert_eq!(submit.delay_ms, Some(900)); } + #[test] + fn active_wait_dependencies_are_transitive_and_deduplicated() { + let edges = vec![ + (aid(1), aid(2)), + (aid(2), aid(3)), + (aid(1), aid(3)), + (aid(9), aid(10)), + ]; + + let deps = OrchestratorService::active_wait_dependencies_from_edges(&edges, aid(1)); + + assert_eq!(deps, vec![aid(3), aid(2)]); + } + // --- BusyTurnGuard (RAII de fin de tour) ------------------------------- // // Fakes minimaux pour observer ce que le garde appelle à son Drop : un médiateur diff --git a/crates/application/src/workstate/actions.rs b/crates/application/src/workstate/actions.rs index ca44db2..316ee02 100644 --- a/crates/application/src/workstate/actions.rs +++ b/crates/application/src/workstate/actions.rs @@ -9,9 +9,11 @@ use std::sync::Arc; +use domain::input::InputMediator; use domain::{AgentId, NodeId, Project, SessionId}; use crate::error::AppError; +use crate::orchestrator::OrchestratorService; use crate::terminal::{CloseTerminal, CloseTerminalInput, LiveSessionKind, LiveSessions}; /// Input for [`AttachLiveAgent::execute`]. @@ -125,13 +127,36 @@ pub struct StopLiveAgentOutput { pub struct StopLiveAgent { live: Arc, close: Arc, + input: Option>, + waits: Option>, } impl StopLiveAgent { /// Builds the use case from the live-session registry and the close primitive. #[must_use] pub fn new(live: Arc, close: Arc) -> Self { - Self { live, close } + Self { + live, + close, + input: None, + waits: None, + } + } + + /// Wires cancellation helpers used for user-driven stop cascades. + /// + /// Stopping A while A waits on B should stop B too. The orchestrator service is + /// only used as a read-only provider of active wait edges; the input mediator is + /// used to preempt the running turn before the session is torn down. + #[must_use] + pub fn with_cascade( + mut self, + input: Arc, + waits: Arc, + ) -> Self { + self.input = Some(input); + self.waits = Some(waits); + self } /// Tears down the agent's live session. @@ -143,20 +168,40 @@ impl StopLiveAgent { &self, input: StopLiveAgentInput, ) -> Result { + self.stop_dependencies(input.agent_id).await; + if let Some(mediator) = &self.input { + mediator.preempt(input.agent_id); + } + self.stop_one(input.agent_id).await + } + + async fn stop_dependencies(&self, agent_id: AgentId) { + let Some(waits) = &self.waits else { + return; + }; + for dep in waits.active_wait_dependencies(agent_id) { + if let Some(mediator) = &self.input { + mediator.preempt(dep); + } + let _ = self.stop_one(dep).await; + } + } + + async fn stop_one(&self, agent_id: AgentId) -> Result { // PTY first: delegate to the existing close primitive (removes + kills). - if let Some(session_id) = self.live.pty.session_for_agent(&input.agent_id) { + if let Some(session_id) = self.live.pty.session_for_agent(&agent_id) { self.close .execute(CloseTerminalInput { session_id }) .await?; return Ok(StopLiveAgentOutput { - agent_id: input.agent_id, + agent_id, session_id, kind: LiveSessionKind::Pty, }); } // Structured: remove from the registry first (so the uniqueness guard no // longer sees a live session), then shut the session down out of the lock. - if let Some(session_id) = self.live.structured.session_id_for_agent(&input.agent_id) { + if let Some(session_id) = self.live.structured.session_id_for_agent(&agent_id) { if let Some(session) = self.live.structured.remove(&session_id) { session .shutdown() @@ -164,14 +209,13 @@ impl StopLiveAgent { .map_err(|e| AppError::Process(e.to_string()))?; } return Ok(StopLiveAgentOutput { - agent_id: input.agent_id, + agent_id, session_id, kind: LiveSessionKind::Structured, }); } Err(AppError::NotFound(format!( - "running session for agent {}", - input.agent_id + "running session for agent {agent_id}" ))) } } diff --git a/crates/application/src/workstate/mod.rs b/crates/application/src/workstate/mod.rs index cfb7dbd..459ef9f 100644 --- a/crates/application/src/workstate/mod.rs +++ b/crates/application/src/workstate/mod.rs @@ -13,11 +13,11 @@ pub use actions::{ AttachLiveAgent, AttachLiveAgentInput, AttachLiveAgentOutput, StopLiveAgent, StopLiveAgentInput, StopLiveAgentOutput, }; -pub use reconcile::{ReconcileLiveState, ReconcileLiveStateInput}; pub use live::{ GetLiveStateLean, LeanLiveEntry, LeanLiveState, UpdateLiveState, UpdateLiveStateInput, LIVE_STATE_MAX_ENTRIES, LIVE_STATE_TTL_MS, }; +pub use reconcile::{ReconcileLiveState, ReconcileLiveStateInput}; use std::collections::{HashMap, HashSet}; use std::sync::Arc; diff --git a/crates/application/src/workstate/reconcile.rs b/crates/application/src/workstate/reconcile.rs index cf5beee..47c4ca8 100644 --- a/crates/application/src/workstate/reconcile.rs +++ b/crates/application/src/workstate/reconcile.rs @@ -173,7 +173,11 @@ mod tests { run(store.clone(), HashSet::new(), 999).await; let after = store.load().await.unwrap(); - assert_eq!(after.entries.len(), 1, "still one keyed row (upsert, not append)"); + assert_eq!( + after.entries.len(), + 1, + "still one keyed row (upsert, not append)" + ); let row = &after.entries[0]; assert_eq!(row.status, WorkStatus::Idle, "orphan downgraded to idle"); assert_eq!(row.progress.as_deref(), Some(STALE_AT_RESTART_MARKER)); @@ -225,6 +229,9 @@ mod tests { let row = &store.load().await.unwrap().entries[0]; assert_eq!(row.status, WorkStatus::Idle); - assert_eq!(row.updated_at_ms, 3, "idle row never reconciled, not restamped"); + assert_eq!( + row.updated_at_ms, 3, + "idle row never reconciled, not restamped" + ); } } diff --git a/crates/application/tests/orchestrator_service.rs b/crates/application/tests/orchestrator_service.rs index 1b32add..b184065 100644 --- a/crates/application/tests/orchestrator_service.rs +++ b/crates/application/tests/orchestrator_service.rs @@ -955,11 +955,13 @@ const TEST_GUARD: Duration = Duration::from_secs(10); struct TestMailbox { queues: Mutex)>>>, + completions: Arc, } impl TestMailbox { fn new() -> Self { Self { queues: Mutex::new(HashMap::new()), + completions: Arc::new(CompletionBus::default()), } } fn pending(&self, agent: &AgentId) -> usize { @@ -978,7 +980,51 @@ impl TestMailbox { .map(|q| q.iter().map(|(t, _)| t.id).collect()) .unwrap_or_default() } + fn completions(&self) -> Arc { + Arc::clone(&self.completions) + } } + +#[derive(Clone)] +enum TestCompletion { + Replied(String), + NoReply, + Cancelled, +} + +#[derive(Default)] +struct CompletionBus { + by_agent: Mutex>>, + notify: tokio::sync::Notify, +} + +impl CompletionBus { + fn push(&self, agent: AgentId, completion: TestCompletion) { + self.by_agent + .lock() + .unwrap() + .entry(agent) + .or_default() + .push_back(completion); + self.notify.notify_waiters(); + } + + async fn next(&self, agent: AgentId) -> TestCompletion { + loop { + if let Some(item) = self + .by_agent + .lock() + .unwrap() + .get_mut(&agent) + .and_then(VecDeque::pop_front) + { + return item; + } + self.notify.notified().await; + } + } +} + impl AgentMailbox for TestMailbox { fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply { let (tx, rx) = tokio::sync::oneshot::channel::(); @@ -1001,6 +1047,8 @@ impl AgentMailbox for TestMailbox { .ok_or(MailboxError::NoPendingRequest(agent))?; queue.pop_front().expect("non-empty") }; + self.completions + .push(agent, TestCompletion::Replied(result.clone())); let _ = slot.1.send(TurnResolution::Replied(result)); Ok(()) } @@ -1022,6 +1070,8 @@ impl AgentMailbox for TestMailbox { .ok_or(MailboxError::NoPendingRequest(agent))?; queue.remove(pos).expect("found position") }; + self.completions + .push(agent, TestCompletion::Replied(result.clone())); let _ = slot.1.send(TurnResolution::Replied(result)); Ok(()) } @@ -1030,6 +1080,7 @@ impl AgentMailbox for TestMailbox { if let Some(queue) = q.get_mut(&agent) { if queue.front().map(|(t, _)| t.id) == Some(ticket_id) { queue.pop_front(); + self.completions.push(agent, TestCompletion::Cancelled); } } } @@ -1044,6 +1095,7 @@ impl AgentMailbox for TestMailbox { return; // receiver gone (human submit / timed-out caller): preserve head. } let (_, tx) = queue.pop_front().expect("head just matched"); + self.completions.push(agent, TestCompletion::NoReply); let _ = tx.send(TurnResolution::ReturnedToPromptNoReply); } } @@ -1200,6 +1252,92 @@ struct AskFixture { live: Arc, } +struct CompletionSession { + id: SessionId, + agent: AgentId, + completions: Arc, +} + +#[async_trait] +impl AgentSession for CompletionSession { + fn id(&self) -> SessionId { + self.id + } + fn conversation_id(&self) -> Option { + None + } + async fn send(&self, _prompt: &str) -> Result { + match self.completions.next(self.agent).await { + TestCompletion::Replied(content) => { + Ok(Box::new(vec![ReplyEvent::Final { content }].into_iter())) + } + TestCompletion::NoReply => Err(AgentSessionError::Io( + "target returned without a structured final".to_owned(), + )), + TestCompletion::Cancelled => Err(AgentSessionError::Io( + "structured test turn cancelled".to_owned(), + )), + } + } + async fn shutdown(&self) -> Result<(), AgentSessionError> { + Ok(()) + } +} + +#[derive(Clone)] +struct CompletionFactory { + completions: Arc, + agents_by_path: HashMap, + next_id: Arc>, +} + +impl CompletionFactory { + fn new(completions: Arc, agents_by_path: HashMap) -> Self { + Self { + completions, + agents_by_path, + next_id: Arc::new(Mutex::new(7000)), + } + } +} + +#[async_trait] +impl AgentSessionFactory for CompletionFactory { + fn supports(&self, profile: &AgentProfile) -> bool { + profile.structured_adapter.is_some() + } + async fn start( + &self, + _profile: &AgentProfile, + ctx: &PreparedContext, + _cwd: &ProjectPath, + _session: &SessionPlan, + _sandbox: Option<&domain::sandbox::SandboxPlan>, + ) -> Result, AgentSessionError> { + let id = { + let mut n = self.next_id.lock().unwrap(); + let id = SessionId::from_uuid(Uuid::from_u128(*n)); + *n += 1; + id + }; + let agent = self + .agents_by_path + .get(&ctx.relative_path) + .copied() + .ok_or_else(|| { + AgentSessionError::Start(format!( + "test completion session cannot resolve agent for {}", + ctx.relative_path + )) + })?; + Ok(Arc::new(CompletionSession { + id, + agent, + completions: Arc::clone(&self.completions), + })) + } +} + /// Builds an orchestrator wired with a real [`InMemoryMailbox`] + PTY (B-3) and a /// plain (non-structured) [`LaunchAgent`] — so a dead target is launched as a PTY, /// exactly like production after B-2. @@ -1238,6 +1376,22 @@ fn ask_fixture_full( let pty = FakePty::new(sid(777)); let bus = SpyBus::default(); let mailbox = Arc::new(TestMailbox::new()); + let structured = Arc::new(StructuredSessions::new()); + let agents_by_path: HashMap = contexts + .manifest() + .entries + .iter() + .map(|e| (e.md_path.clone(), e.agent_id)) + .collect(); + let completion_factory = CompletionFactory::new(mailbox.completions(), agents_by_path); + for (idx, entry) in contexts.manifest().entries.iter().enumerate() { + let session = Arc::new(CompletionSession { + id: SessionId::from_uuid(Uuid::from_u128(8_000 + idx as u128)), + agent: entry.agent_id, + completions: mailbox.completions(), + }) as Arc; + structured.insert(session, entry.agent_id, nid(8_000 + idx as u128)); + } let memories = Arc::new(HarvestMemories { saved: Mutex::new(Vec::new()), fail_save: fail_memory, @@ -1252,19 +1406,25 @@ fn ask_fixture_full( Arc::new(SeqIds::new()), Arc::new(bus.clone()), )); - let launch = Arc::new(LaunchAgent::new( - Arc::new(contexts.clone()), - Arc::clone(&profiles) as Arc, - Arc::new(FakeRuntime), - Arc::new(FakeFs), - Arc::new(pty.clone()), - Arc::new(FakeSkills), - Arc::clone(&sessions), - Arc::new(bus.clone()), - Arc::new(SeqIds::new()), - Arc::new(FakeRecall), - None, - )); + let launch = Arc::new( + LaunchAgent::new( + Arc::new(contexts.clone()), + Arc::clone(&profiles) as Arc, + Arc::new(FakeRuntime), + Arc::new(FakeFs), + Arc::new(pty.clone()), + Arc::new(FakeSkills), + Arc::clone(&sessions), + Arc::new(bus.clone()), + Arc::new(SeqIds::new()), + Arc::new(FakeRecall), + None, + ) + .with_structured( + Arc::new(completion_factory) as Arc, + Arc::clone(&structured), + ), + ); let list = Arc::new(ListAgents::new(Arc::new(contexts.clone()))); let close = Arc::new(CloseTerminal::new( Arc::new(pty.clone()), @@ -1297,6 +1457,7 @@ fn ask_fixture_full( ) .with_conversations(conversations) .with_events(Arc::new(bus.clone())) + .with_structured(Arc::clone(&structured)) .with_memory_harvest(Arc::new(HarvestMemoryFromTurn::new( Arc::clone(&memories) as Arc, Arc::new(bus.clone()), @@ -1378,8 +1539,8 @@ fn reply_cmd_ticket(from: AgentId, ticket: TicketId, result: &str) -> Orchestrat // --- B-3: ask blocks on the mailbox, reply unblocks it --------------------- -/// A live-PTY target: `ask` writes the prefixed task into its terminal and AWAITS; -/// a matching `idea_reply` resolves the head ticket and the ask returns its content. +/// A structured target: `ask` enqueues a bookkeeping ticket, sends the turn to the +/// headless session, and returns the session's `Final`. The PTY is not used. #[tokio::test] async fn ask_live_pty_target_writes_task_and_returns_reply() { let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); @@ -1392,21 +1553,12 @@ async fn ask_live_pty_target_writes_task_and_returns_reply() { // The ask enqueues a ticket and blocks awaiting the reply. await_until(|| fx.mailbox.pending(&aid(1)) == 1).await; - // The task was written into the target's terminal, prefixed for idea_reply. + // The PTY is no longer part of the inter-agent conversation path. let writes = fx.pty.writes_for(sid(800)); assert_eq!( writes.len(), - 1, - "exactly one task write to the live terminal" - ); - assert!(writes[0].contains("Analyse §17"), "task body written"); - assert!( - writes[0].contains("[IdeA · tâche"), - "delegated-task prefix present" - ); - assert!( - writes[0].contains("idea_reply") || writes[0].contains("ticket"), - "carries ticket/idea_reply cue" + 0, + "inter-agent conversation must not write to the PTY" ); // No PTY spawned: the live terminal was reused. assert!( @@ -3750,6 +3902,7 @@ impl AgentSessionFactory for CountingFactory { struct StructuredAskFixture { service: Arc, structured: Arc, + sessions: Arc, factory: CountingFactory, pty: FakePty, } @@ -3831,6 +3984,7 @@ fn structured_ask_fixture(contexts: FakeContexts) -> StructuredAskFixture { StructuredAskFixture { service, structured, + sessions, factory, pty, } @@ -3909,3 +4063,33 @@ async fn ask_warm_structured_target_reuses_session_without_relaunch() { "cible chaude : la session est réutilisée, aucune relance" ); } + +/// Régression live : une cible structurée peut déjà être vivante en PTY parce qu'elle +/// a été ouverte depuis la surface humaine/menu historique. Le chemin `ask` headless +/// doit alors libérer ce PTY puis créer la session structurée, au lieu de laisser le +/// garde d'unicité de `LaunchAgent` rendre le PTY existant et finir par +/// « aucune session structurée vivante après lancement ». +#[tokio::test] +async fn ask_structured_target_live_as_pty_migrates_to_headless_session() { + let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); + let fx = structured_ask_fixture(FakeContexts::with_agent(&agent, "# persona")); + seed_live_pty(&fx.sessions, aid(1), sid(800)); + + let out = fx + .service + .dispatch(&project(), cmd(ASK_JSON)) + .await + .expect("ask ok"); + + assert_eq!(out.reply.as_deref(), Some("structured: Analyse §17")); + assert_eq!(fx.factory.start_count(), 1, "headless session started"); + assert_eq!(fx.pty.kills(), vec![sid(800)], "legacy PTY was stopped"); + assert!( + fx.structured.session_for_agent(&aid(1)).is_some(), + "target now has a structured session" + ); + assert!( + fx.sessions.session_for_agent(&aid(1)).is_none(), + "target no longer has a PTY session" + ); +} diff --git a/crates/domain/src/input.rs b/crates/domain/src/input.rs index 6e92cbe..ab3df71 100644 --- a/crates/domain/src/input.rs +++ b/crates/domain/src/input.rs @@ -164,6 +164,21 @@ pub trait InputMediator: Send + Sync { /// authority of the FIFO/busy state and correlation only. fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply; + /// Headless/system enqueue: appends `ticket` to the same FIFO and marks the agent + /// busy, but does **not** deliver any text to the human terminal surface. + /// + /// This is for inter-agent structured/headless turns where the real transport is + /// [`crate::ports::AgentSession::send`] and the answer is its final response. The + /// mailbox ticket remains useful for queue/workstate accounting and cancellation, + /// but publishing [`crate::events::DomainEvent::DelegationReady`] here would leak + /// the same task into the user's CLI cell. + /// + /// Default keeps compatibility for simple mediators; production overrides it to + /// suppress delivery. + fn enqueue_silent(&self, agent: AgentId, ticket: Ticket) -> PendingReply { + self.enqueue(agent, ticket) + } + /// Registers (or refreshes) the live input [`PtyHandle`] of `agent` so a later /// [`InputMediator::enqueue`] delivers the turn through it (cadrage C3 §5.2). /// diff --git a/crates/domain/src/live_state.rs b/crates/domain/src/live_state.rs index 3e63e92..c6c55bd 100644 --- a/crates/domain/src/live_state.rs +++ b/crates/domain/src/live_state.rs @@ -427,7 +427,11 @@ mod tests { // Nothing is live ⇒ aid(1) is an orphan. let rewritten = state.reconcile_orphans(|_| false, 999); - assert_eq!(rewritten.len(), 1, "the single active-but-dead row is an orphan"); + assert_eq!( + rewritten.len(), + 1, + "the single active-but-dead row is an orphan" + ); let row = &rewritten[0]; assert_eq!(row.agent_id, aid(1)); assert_eq!(row.status, WorkStatus::Idle, "downgraded to idle"); @@ -441,15 +445,12 @@ mod tests { #[test] fn reconcile_orphans_covers_working_waiting_blocked_only() { let mut state = LiveState::default(); - state.upsert( - LiveEntry::new(aid(1), None, "w", WorkStatus::Working, None, None, 1).unwrap(), - ); - state.upsert( - LiveEntry::new(aid(2), None, "a", WorkStatus::Waiting, None, None, 1).unwrap(), - ); - state.upsert( - LiveEntry::new(aid(3), None, "b", WorkStatus::Blocked, None, None, 1).unwrap(), - ); + state + .upsert(LiveEntry::new(aid(1), None, "w", WorkStatus::Working, None, None, 1).unwrap()); + state + .upsert(LiveEntry::new(aid(2), None, "a", WorkStatus::Waiting, None, None, 1).unwrap()); + state + .upsert(LiveEntry::new(aid(3), None, "b", WorkStatus::Blocked, None, None, 1).unwrap()); // idle/done are never orphans, even when the agent is dead. state.upsert(LiveEntry::new(aid(4), None, "i", WorkStatus::Idle, None, None, 1).unwrap()); state.upsert(LiveEntry::new(aid(5), None, "d", WorkStatus::Done, None, None, 1).unwrap()); @@ -479,20 +480,36 @@ mod tests { let rewritten = state.reconcile_orphans(|a| *a == aid(1), 50); assert_eq!(rewritten.len(), 1); - assert_eq!(rewritten[0].agent_id, aid(2), "only the dead agent is reconciled"); + assert_eq!( + rewritten[0].agent_id, + aid(2), + "only the dead agent is reconciled" + ); } #[test] fn reconcile_orphans_does_not_mutate_self() { let mut state = LiveState::default(); state.upsert( - LiveEntry::new(aid(1), Some(tid(1)), "x", WorkStatus::Working, None, None, 1).unwrap(), + LiveEntry::new( + aid(1), + Some(tid(1)), + "x", + WorkStatus::Working, + None, + None, + 1, + ) + .unwrap(), ); let before = state.clone(); let _ = state.reconcile_orphans(|_| false, 999); - assert_eq!(state, before, "reconcile_orphans is a pure read (returns rows to upsert)"); + assert_eq!( + state, before, + "reconcile_orphans is a pure read (returns rows to upsert)" + ); } #[test] diff --git a/crates/infrastructure/src/input/mod.rs b/crates/infrastructure/src/input/mod.rs index d1a247b..5a6571f 100644 --- a/crates/infrastructure/src/input/mod.rs +++ b/crates/infrastructure/src/input/mod.rs @@ -762,29 +762,16 @@ impl MediatedInbox { pub fn mailbox(&self) -> Arc { Arc::clone(&self.mailbox) } - } -/// Composes the line delivered into the target's terminal for a delegated turn. +/// Composes the text delivered into a human-facing terminal turn. /// -/// The `[IdeA · tâche de · ticket ]` header is the protocol signal -/// (cadrage B-5, agent context) that marks the message as an IdeA delegation the -/// target must answer via `idea_reply` — echoing `ticket` for multi-thread -/// correlation — rather than as a free-text human prompt. -/// -/// A one-line **imperative reminder** follows the header (durcissement -/// comportemental, finding A) : the single most common wedge is a target that ends -/// its turn with a *prose* answer and never calls `idea_reply`, leaving the asker -/// parked. Reminding it inline — on every delegated task, even trivial ones — attacks -/// that behavioural cause at the central point where the turn is composed (no per-agent -/// duplication). The raw `task` then follows so the agent reads the request unchanged. +/// Inter-agent conversation no longer uses this path: structured/headless asks send the +/// raw task through `AgentSession::send` and capture the model `Final`. Therefore this +/// terminal delivery must not inject any inter-agent ticket/reply protocol. fn delegation_preamble(requester: &str, ticket: TicketId, task: &str) -> String { - format!( - "[IdeA · tâche de {requester} · ticket {ticket}]\n\ - ⚠️ Termine IMPÉRATIVEMENT ce tour par un appel `idea_reply(ticket=\"{ticket}\", \ - result=…)`, même pour une réponse triviale. Ne réponds JAMAIS uniquement en prose \ - dans le terminal : sans `idea_reply`, l'agent demandeur reste bloqué.\n{task}" - ) + let _ = (requester, ticket); + task.to_owned() } fn write_delegation_chunks( @@ -866,14 +853,6 @@ impl InputMediator for MediatedInbox { busy: true, }); let submit = self.submit().get(&agent).cloned().unwrap_or_default(); - // Préfixe de délégation : c'est le **signal** qui dit à la cible - // « ceci est une tâche IdeA, réponds via `idea_reply` (jamais en - // texte) en renvoyant ce `ticket` ». Sans lui la cible traite la - // ligne comme une invite humaine et répond dans le terminal, et - // l'agent demandeur reste bloqué jusqu'au timeout. La tâche brute - // reste dans le `Ticket` (mailbox/historique) ; seul le texte livré - // au PTY porte le préfixe. Format aligné sur l'instruction injectée - // dans le contexte de l'agent (`[IdeA · tâche de … · ticket …]`). let text = delegation_preamble(&ticket.requester, ticket_id, &ticket.task); // **Fix race cold-launch** : si l'agent est en démarrage à froid (inscrit // par `mark_starting` quand un pont MCP le libérera), ON DIFFÈRE la @@ -916,6 +895,33 @@ impl InputMediator for MediatedInbox { self.mailbox.enqueue(agent, ticket) } + fn enqueue_silent(&self, agent: AgentId, ticket: Ticket) -> PendingReply { + let ticket_id = ticket.id; + // Headless/system turns share the same FIFO and busy/liveness accounting as + // terminal-delivered turns, but their prompt is sent through AgentSession::send. + // Do not publish DelegationReady here: that would leak the task into the + // human-facing terminal/write portal while the headless turn is already running. + let now_ms = self.clock.now_ms(); + let started_turn = self.tracker.start_turn( + agent, + AgentBusyState::Busy { + ticket: ticket_id, + since_ms: now_ms, + }, + ); + if started_turn { + let stall_after_ms = self.stall().get(&agent).copied().flatten(); + self.tracker.arm_liveness(agent, stall_after_ms, now_ms); + if let Some(events) = &self.tracker.events { + events.publish(DomainEvent::AgentBusyChanged { + agent_id: agent, + busy: true, + }); + } + } + self.mailbox.enqueue(agent, ticket) + } + fn bind_handle(&self, agent: AgentId, handle: PtyHandle) { eprintln!( "[input-mediator] bind handle agent={agent} handle={}", @@ -1162,17 +1168,10 @@ mod tests { assert_eq!(ready.len(), 1, "exactly one DelegationReady on turn start"); let tid = TicketId::from_uuid(uuid::Uuid::from_u128(10)); assert_eq!(ready[0].0, tid); - // The delivered text carries the delegation prefix (the signal to answer via - // `idea_reply`) followed by the raw task on the next line. assert_eq!(ready[0].1, delegation_preamble("User", tid, "do the thing")); assert!( - ready[0].1.starts_with("[IdeA · tâche de User · ticket "), - "delivered text must carry the delegation prefix: {:?}", - ready[0].1 - ); - assert!( - ready[0].1.ends_with("\ndo the thing"), - "raw task must follow the prefix line: {:?}", + ready[0].1 == "do the thing", + "delivered text must be the raw task: {:?}", ready[0].1 ); @@ -1189,11 +1188,25 @@ mod tests { inbox.enqueue(a, ticket(12, "next turn")); let ready = bus.delegation_ready(); assert_eq!(ready.len(), 2, "new turn ⇒ a new DelegationReady"); + assert_eq!(ready[1].1, "next turn"); + } + + #[test] + fn enqueue_silent_marks_busy_without_delivery() { + let bus = Arc::new(RecordingBus::default()); + let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1))) + .with_events(Arc::clone(&bus) as Arc); + let a = agent(1); + + inbox.enqueue_silent(a, ticket(10, "headless turn")); + + assert!(inbox.busy_state(a).is_busy()); + assert_eq!(bus.busy_events(), vec![(a, true)]); assert!( - ready[1].1.ends_with("\nnext turn"), - "second turn delivers its own prefixed task: {:?}", - ready[1].1 + bus.delegation_ready().is_empty(), + "headless bookkeeping must not leak a prompt to the terminal" ); + assert_eq!(inbox.mailbox.pending(&a), 1); } #[test] @@ -1548,7 +1561,10 @@ mod tests { .await .expect("must NOT hang until the long timeout") .expect("a turn outcome, not a closed channel"); - assert_eq!(res, domain::mailbox::TurnResolution::ReturnedToPromptNoReply); + assert_eq!( + res, + domain::mailbox::TurnResolution::ReturnedToPromptNoReply + ); assert_eq!( inbox.mailbox().pending(&a), 0, @@ -1589,7 +1605,10 @@ mod tests { let pending = inbox.enqueue(a, ticket(10, "task")); inbox.turn_ended(a); // turn_ended a marqué Idle et armé la grâce ; on répond pendant la grâce. - assert!(!inbox.busy_state(a).is_busy(), "turn_ended marks idle (grace armed)"); + assert!( + !inbox.busy_state(a).is_busy(), + "turn_ended marks idle (grace armed)" + ); inbox.mailbox().resolve(a, "in-time".to_owned()).unwrap(); let res = tokio::time::timeout(Duration::from_secs(2), pending) @@ -1616,7 +1635,10 @@ mod tests { .await .expect("no hang") .expect("outcome"); - assert_eq!(res, domain::mailbox::TurnResolution::ReturnedToPromptNoReply); + assert_eq!( + res, + domain::mailbox::TurnResolution::ReturnedToPromptNoReply + ); // A late idea_reply now has no head to resolve ⇒ typed NoPendingRequest. assert!( @@ -1685,11 +1707,7 @@ mod tests { 1, "release_cold_start ⇒ exactement une DelegationReady" ); - assert!( - ready[0].1.ends_with("\ncold task"), - "le texte différé porte la tâche brute: {:?}", - ready[0].1 - ); + assert_eq!(ready[0].1, "cold task"); } /// **Régression de la race cold-start (ordre INVERSE)** : `release_cold_start` @@ -1724,11 +1742,7 @@ mod tests { 1, "release AVANT enqueue ⇒ exactement une DelegationReady (jamais zéro, jamais deux)" ); - assert!( - ready[0].1.ends_with("\ncold task"), - "le texte livré porte la tâche brute: {:?}", - ready[0].1 - ); + assert_eq!(ready[0].1, "cold task"); assert!( inbox.busy_state(a).is_busy(), "le tour froid livré ⇒ l'agent reste Busy (idle viendra d'idea_reply)" @@ -1798,7 +1812,7 @@ mod tests { 1, "agent chaud ⇒ DelegationReady immédiate (zéro régression)" ); - assert!(ready[0].1.ends_with("\nwarm task")); + assert_eq!(ready[0].1, "warm task"); } /// (c) Cas limite : si on ne marque PAS `starting` (l'orchestrateur n'arme pas le @@ -1849,7 +1863,7 @@ mod tests { 2, "second tour livré immédiatement (plus de gate)" ); - assert!(ready[1].1.ends_with("\nsecond")); + assert_eq!(ready[1].1, "second"); } // ==================================================================== diff --git a/crates/infrastructure/src/inspector/claude.rs b/crates/infrastructure/src/inspector/claude.rs index 0cba927..981ff60 100644 --- a/crates/infrastructure/src/inspector/claude.rs +++ b/crates/infrastructure/src/inspector/claude.rs @@ -24,9 +24,7 @@ use std::sync::Arc; use async_trait::async_trait; use serde::Deserialize; -use domain::ports::{ - ConversationDetails, FileSystem, FsError, InspectError, SessionInspector, -}; +use domain::ports::{ConversationDetails, FileSystem, FsError, InspectError, SessionInspector}; use domain::profile::AgentProfile; use domain::project::ProjectPath; diff --git a/crates/infrastructure/src/inspector/claude_turn_watcher.rs b/crates/infrastructure/src/inspector/claude_turn_watcher.rs index 5a19fe0..3a7d2fb 100644 --- a/crates/infrastructure/src/inspector/claude_turn_watcher.rs +++ b/crates/infrastructure/src/inspector/claude_turn_watcher.rs @@ -203,7 +203,10 @@ mod tests { .ok_or_else(|| FsError::NotFound(path.0.clone())) } async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> { - self.files.lock().unwrap().insert(path.0.clone(), data.to_vec()); + self.files + .lock() + .unwrap() + .insert(path.0.clone(), data.to_vec()); Ok(()) } async fn exists(&self, path: &RemotePath) -> Result { @@ -282,7 +285,8 @@ mod tests { let fs = Arc::new(FakeFs::default()); // Baseline: one pre-existing turn end ⇒ must NOT fire for it. fs.set(&transcript_path("engine-1.jsonl"), &line("turn_duration")); - let watcher = ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs) as Arc, "/home/me"); + let watcher = + ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs) as Arc, "/home/me"); let (log, cb) = count_calls(); let cwd = ProjectPath::new("/run/a").expect("path"); let _h = watcher.watch(agent(1), None, cwd, cb); @@ -307,20 +311,25 @@ mod tests { &transcript_path("engine-1.jsonl"), &format!("{}{}", line("turn_duration"), line("turn_duration")), ); - let watcher = ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs) as Arc, "/home/me"); + let watcher = + ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs) as Arc, "/home/me"); let (log, cb) = count_calls(); let cwd = ProjectPath::new("/run/a").expect("path"); let _h = watcher.watch(agent(1), None, cwd, cb); // No new turn end appended ⇒ no fire, ever (baseline absorbs the existing ones). tokio::time::sleep(Duration::from_millis(120)).await; - assert!(log.lock().unwrap().is_empty(), "baseline must absorb pre-existing turn ends"); + assert!( + log.lock().unwrap().is_empty(), + "baseline must absorb pre-existing turn ends" + ); } #[tokio::test] async fn cold_start_missing_folder_then_first_turn_fires() { let fs = Arc::new(FakeFs::default()); - let watcher = ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs) as Arc, "/home/me"); + let watcher = + ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs) as Arc, "/home/me"); let (log, cb) = count_calls(); let cwd = ProjectPath::new("/run/a").expect("path"); let _h = watcher.watch(agent(1), None, cwd, cb); @@ -337,7 +346,8 @@ mod tests { #[tokio::test] async fn dropping_handle_stops_polling() { let fs = Arc::new(FakeFs::default()); - let watcher = ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs) as Arc, "/home/me"); + let watcher = + ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs) as Arc, "/home/me"); let (log, cb) = count_calls(); let cwd = ProjectPath::new("/run/a").expect("path"); let h = watcher.watch(agent(1), None, cwd, cb); @@ -347,6 +357,9 @@ mod tests { tokio::time::sleep(Duration::from_millis(40)).await; fs.set(&transcript_path("engine-1.jsonl"), &line("turn_duration")); tokio::time::sleep(Duration::from_millis(120)).await; - assert!(log.lock().unwrap().is_empty(), "a dropped handle stops firing"); + assert!( + log.lock().unwrap().is_empty(), + "a dropped handle stops firing" + ); } } diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index efe9ac5..e19f9e2 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -48,12 +48,11 @@ pub use fs::LocalFileSystem; pub use git::Git2Repository; pub use id::UuidGenerator; pub use input::{MediatedInbox, MillisClock, SystemMillisClock}; -pub use inspector::{transcript_activity_token, ClaudeTranscriptInspector, ClaudeTranscriptTurnWatcher}; -pub use mailbox::InMemoryMailbox; -pub use orchestrator::mcp::{ - resolve_ask_rendezvous_ceiling, resolve_ask_rendezvous_timeout, AskActivityProbe, McpServer, - MemoryTransport, StdioTransport, +pub use inspector::{ + transcript_activity_token, ClaudeTranscriptInspector, ClaudeTranscriptTurnWatcher, }; +pub use mailbox::InMemoryMailbox; +pub use orchestrator::mcp::{McpServer, MemoryTransport, StdioTransport}; pub use orchestrator::{ process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle, REQUESTS_SUBDIR, @@ -78,7 +77,6 @@ pub use store::{ AdaptiveMemoryRecall, EmbedderEnvProbe, FsEmbedderProfileStore, FsEmbedderPromptStore, FsLiveStateStore, FsMemoryStore, FsPermissionStore, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall, - OnnxModelInfo, StubEmbedder, VectorMemoryRecall, - DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, + OnnxModelInfo, StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, }; diff --git a/crates/infrastructure/src/orchestrator/mcp/jsonrpc.rs b/crates/infrastructure/src/orchestrator/mcp/jsonrpc.rs index 5031d3e..68abb9f 100644 --- a/crates/infrastructure/src/orchestrator/mcp/jsonrpc.rs +++ b/crates/infrastructure/src/orchestrator/mcp/jsonrpc.rs @@ -121,25 +121,6 @@ pub mod error_codes { pub const INVALID_PARAMS: i32 = -32_602; /// Internal server error (a dispatched IdeA command failed). pub const INTERNAL_ERROR: i32 = -32_603; - - /// **Server-defined** (JSON-RPC reserved range −32000..=−32099): the synchronous - /// `idea_ask_agent` rendezvous expired against the server-side safety net without - /// the target ever rendering a reply. **Distinct** from [`INTERNAL_ERROR`] on - /// purpose — it is not an internal fault but a *typed, retryable* outcome - /// (semantics mirror [`application::AppError::TargetReturnedNoReply`]): the caller - /// may re-ask, reminding the target to answer via `idea_reply`. The accompanying - /// `data` carries `{ "code": "TARGET_RETURNED_NO_REPLY", "retryable": true }`. - pub const RENDEZVOUS_NO_REPLY: i32 = -32_001; - - /// **Server-defined** (JSON-RPC reserved range -32000..=-32099): the synchronous - /// `idea_ask_agent` rendezvous hit the **absolute ceiling** while the target was - /// **still actively working** (its transcript kept growing). **Distinct** from - /// [`RENDEZVOUS_NO_REPLY`]: the target is *not* silent -- it simply outran the hard - /// ceiling. It is therefore **non-retryable** (a blind re-ask would pile a second - /// heavy turn on a target already mid-task); the caller must inspect the target's - /// in-progress work/branch before re-soliciting lightly. The accompanying `data` - /// carries `{ "code": "RENDEZVOUS_CEILING_ACTIVE", "retryable": false }`. - pub const RENDEZVOUS_CEILING_ACTIVE: i32 = -32_002; } /// Errors a [`Transport`] may surface. diff --git a/crates/infrastructure/src/orchestrator/mcp/mod.rs b/crates/infrastructure/src/orchestrator/mcp/mod.rs index c775be4..711622a 100644 --- a/crates/infrastructure/src/orchestrator/mcp/mod.rs +++ b/crates/infrastructure/src/orchestrator/mcp/mod.rs @@ -36,8 +36,6 @@ pub mod transport; pub use jsonrpc::{ JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError, JSONRPC_VERSION, }; -pub use server::{ - resolve_ask_rendezvous_ceiling, resolve_ask_rendezvous_timeout, AskActivityProbe, McpServer, -}; +pub use server::McpServer; pub use tools::{catalogue, map_tool_call, tool_returns_reply, ToolDef, ToolMapError}; pub use transport::{MemoryTransport, StdioTransport}; diff --git a/crates/infrastructure/src/orchestrator/mcp/server.rs b/crates/infrastructure/src/orchestrator/mcp/server.rs index b743dd6..1d271d1 100644 --- a/crates/infrastructure/src/orchestrator/mcp/server.rs +++ b/crates/infrastructure/src/orchestrator/mcp/server.rs @@ -4,8 +4,7 @@ //! another entry door onto the *same* [`OrchestratorService::dispatch`]. Where the //! watcher reads a JSON file, this server reads a JSON-RPC `tools/call`; both build //! an [`OrchestratorCommand`] and return its [`OrchestratorOutcome`] unchanged. It -//! invents **no semantics** and **never re-routes** the reply — for `idea_ask_agent` -//! it returns `outcome.reply` inline, exactly what `dispatch` produced. +//! invents **no semantics** and **never re-routes** replies. //! //! It implements the strict MCP minimum over JSON-RPC 2.0: //! - `initialize` → advertise protocol version + tool capability, @@ -17,10 +16,8 @@ //! at the composition root (M3), exactly like the watcher — no application logic is //! duplicated here. -use std::future::Future; -use std::pin::Pin; use std::sync::Arc; -use std::time::{Duration, Instant}; +use std::time::Instant; use application::OrchestratorService; use domain::{DomainEvent, OrchestrationSource, Project}; @@ -36,72 +33,6 @@ use super::tools::{self, ToolMapError}; /// The MCP protocol version this server speaks (advertised on `initialize`). const MCP_PROTOCOL_VERSION: &str = "2024-11-05"; -/// **Inactivity window (silence budget)** for the synchronous `idea_ask_agent` -/// rendezvous — the size of one *no-progress* probe window, NOT an absolute cap. -/// -/// `idea_ask_agent` is the only tool whose `tools/call` *blocks* awaiting another -/// agent's `idea_reply` (a synchronous rendezvous, resolved deep in the application -/// layer). Rather than a flat outer timeout (which wrongly fired on a single long -/// delegated turn that never returns to its prompt nor emits `turn_duration`), the -/// adapter runs an **inactivity watchdog**: it races the dispatch against this window -/// and, on each window expiry, probes whether the target made progress (its transcript -/// grew). Progress ⇒ the window is re-armed (extension) up to the absolute -/// [`ASK_RENDEZVOUS_CEILING`]; genuine silence ⇒ the no-reply error fires. 600 s by -/// default, overridable per project via `IDEA_ASK_RENDEZVOUS_TIMEOUT_MS`. Every other -/// tool (`idea_reply`, `idea_list_agents`, …) is left untouched — they don't rendezvous. -/// -/// **Fallback (no activity probe).** When no probe is wired (legacy call sites, tests -/// that shrink the bound), this degrades to the previous *flat* timeout: the first -/// window expiry with no probe is treated as a no-reply expiry — zero regression. -const ASK_RENDEZVOUS_TIMEOUT: Duration = Duration::from_secs(600); - -/// **Absolute ceiling (hard cap)** of the `idea_ask_agent` rendezvous, so an extending -/// inactivity window can never park a `tools/call` forever even against a target that -/// stays *busy* indefinitely. Reaching it while the target is still actively working -/// yields the distinct, **non-retryable** [`rendezvous_ceiling_active_error`] (never a -/// blind-retry suggestion). Generous by default (4 h), overridable per project via -/// `IDEA_ASK_RENDEZVOUS_CEILING_MS`. -const ASK_RENDEZVOUS_CEILING: Duration = Duration::from_secs(4 * 60 * 60); - -/// A monotonic **activity probe** of a target agent, keyed by its display name. -/// -/// Returns an opaque, monotonically non-decreasing **activity token** (in practice the -/// cumulative byte size of the target's transcript `.jsonl` files): a strictly larger -/// token between two probes means the target made progress (it is alive and working, -/// even within a single long turn that never emits `turn_duration`). `None` ⇒ the -/// target/transcript could not be resolved (cold start, unknown name) — treated as "no -/// observable progress" so a truly stuck call still expires. Async because resolving the -/// token reads the filesystem; built at the composition root (the only layer that knows -/// the name→run-dir mapping), keeping this adapter free of `AgentId`. -pub type AskActivityProbe = - Arc Pin> + Send>> + Send + Sync>; - -/// Resolves the effective `idea_ask_agent` **inactivity window** from an optional -/// configured override in **milliseconds**: `Some(ms)` ⇒ that window, `None`/absent ⇒ -/// the [`ASK_RENDEZVOUS_TIMEOUT`] default (600 s). `Some(0)` is treated as "no override" -/// so a misconfigured zero can never collapse the window to an instant expiry. Pure and -/// unit-testable without wiring. -#[must_use] -pub fn resolve_ask_rendezvous_timeout(override_ms: Option) -> Duration { - match override_ms { - Some(ms) if ms > 0 => Duration::from_millis(u64::from(ms)), - _ => ASK_RENDEZVOUS_TIMEOUT, - } -} - -/// Resolves the effective `idea_ask_agent` **absolute ceiling** from an optional -/// configured override in **milliseconds**: `Some(ms)` ⇒ that ceiling, `None`/absent ⇒ -/// the [`ASK_RENDEZVOUS_CEILING`] default (4 h). `Some(0)` is treated as "no override". -/// Pure and unit-testable without wiring. -#[must_use] -pub fn resolve_ask_rendezvous_ceiling(override_ms: Option) -> Duration { - match override_ms { - Some(ms) if ms > 0 => Duration::from_millis(u64::from(ms)), - _ => ASK_RENDEZVOUS_CEILING, - } -} - - /// The IdeA MCP server: an entry adapter over [`OrchestratorService::dispatch`]. /// /// Cheap to clone the dependencies it holds; one instance serves one project's @@ -130,21 +61,6 @@ pub struct McpServer { /// (fix race cold-launch, signal MCP). L'infra ne connaît pas `AgentId` : la /// composition root parse le `requester`. `None` ⇒ no-op. ready_sink: Option>, - /// **Inactivity window (silence budget)** of the `idea_ask_agent` rendezvous (see - /// [`ASK_RENDEZVOUS_TIMEOUT`]). One probe window, re-armed on observed progress. - /// Carried per instance so tests can shrink it to a few milliseconds without - /// polluting the public API; production code uses the resolved default. - ask_rendezvous_timeout: Duration, - /// **Absolute ceiling** of the `idea_ask_agent` rendezvous (see - /// [`ASK_RENDEZVOUS_CEILING`]): the extending inactivity window never parks a call - /// past this. Per instance so tests can shrink it; production uses the resolved - /// default (4 h). - ask_rendezvous_ceiling: Duration, - /// Optional monotonic **activity probe** of the target (see [`AskActivityProbe`]), - /// injected by the composition root. Drives window extension: progress between two - /// probes re-arms the window. `None` ⇒ no observability ⇒ the rendezvous degrades to - /// a single flat window (legacy behaviour, zero regression). - activity_probe: Option, } impl McpServer { @@ -159,9 +75,6 @@ impl McpServer { events: None, requester: String::new(), ready_sink: None, - ask_rendezvous_timeout: ASK_RENDEZVOUS_TIMEOUT, - ask_rendezvous_ceiling: ASK_RENDEZVOUS_CEILING, - activity_probe: None, } } @@ -202,51 +115,9 @@ impl McpServer { events: self.events.clone(), requester: requester.into(), ready_sink: self.ready_sink.clone(), - ask_rendezvous_timeout: self.ask_rendezvous_timeout, - ask_rendezvous_ceiling: self.ask_rendezvous_ceiling, - activity_probe: self.activity_probe.clone(), } } - /// Overrides the `idea_ask_agent` **inactivity window** (silence budget, default - /// [`ASK_RENDEZVOUS_TIMEOUT`] = 600 s). - /// - /// A genuine **configuration knob** (project/profile level, modelled on the - /// application's `turn_timeout_ms` / `resolve_turn_timeout`): the composition root - /// resolves an optional override with [`resolve_ask_rendezvous_timeout`] and applies - /// it here. This is the size of one *no-progress* window: as long as the activity - /// probe sees the target advancing, the window is re-armed up to the absolute - /// [`ASK_RENDEZVOUS_CEILING`]. Absent override ⇒ the 600 s default. Tests also use it - /// to shrink the window to milliseconds. - #[must_use] - pub fn with_ask_rendezvous_timeout(mut self, timeout: Duration) -> Self { - self.ask_rendezvous_timeout = timeout; - self - } - - /// Overrides the `idea_ask_agent` **absolute ceiling** (hard cap, default - /// [`ASK_RENDEZVOUS_CEILING`] = 4 h) past which the extending inactivity window never - /// parks a call, even against a perpetually-busy target. The composition root resolves - /// an optional override with [`resolve_ask_rendezvous_ceiling`]. Tests use it to shrink - /// the ceiling to milliseconds. Additive: callers that don't set it keep the default. - #[must_use] - pub fn with_ask_rendezvous_ceiling(mut self, ceiling: Duration) -> Self { - self.ask_rendezvous_ceiling = ceiling; - self - } - - /// Attaches the monotonic **activity probe** (see [`AskActivityProbe`]) driving the - /// inactivity-window extension: between two window expiries, a strictly larger token - /// means the target is alive and working, so the window is re-armed instead of - /// expiring. Built by the composition root (the only layer mapping a display name to a - /// run-dir transcript). Additive: without a probe the rendezvous degrades to a single - /// flat window (legacy behaviour, zero regression). - #[must_use] - pub fn with_activity_probe(mut self, probe: AskActivityProbe) -> Self { - self.activity_probe = Some(probe); - self - } - /// Serves JSON-RPC messages from `transport`, tagging every processed /// `tools/call` with `requester` as the delegating agent (cadrage v5 §1.4). /// @@ -264,16 +135,14 @@ impl McpServer { /// method yields a JSON-RPC error response, **never a panic** and never a /// dropped connection. Notifications (no `id`) are processed without a reply. /// - /// **Full-duplex / non-blocking (anti-wedge).** A request whose handling blocks — - /// notably `idea_ask_agent`, which awaits another agent's `idea_reply` in a - /// synchronous rendezvous — must **not** stall the read loop: otherwise a single - /// in-flight ask parks the whole connection and every later call (even a - /// rendezvous-free `idea_list_agents`) is never even read. So each inbound message - /// is handled on its own spawned task that owns a cheap per-call clone of the - /// server; finished responses are funnelled back through an `mpsc` channel and - /// written by the same loop. Responses carry their JSON-RPC `id`, so out-of-order - /// completion is fine (the full-duplex bridge correlates by id). Notifications - /// (`handle_raw` ⇒ `None`) emit nothing. + /// **Full-duplex / non-blocking.** A slow request must **not** stall the read + /// loop: otherwise a single long-running tool call parks the whole connection and + /// every later call is never even read. So each inbound message is handled on its + /// own spawned task that owns a cheap per-call clone of the server; finished + /// responses are funnelled back through an `mpsc` channel and written by the same + /// loop. Responses carry their JSON-RPC `id`, so out-of-order completion is fine + /// (the full-duplex bridge correlates by id). Notifications (`handle_raw` ⇒ + /// `None`) emit nothing. pub async fn serve(&self, transport: &mut T) { let (tx, mut rx) = mpsc::unbounded_channel::>>(); // Number of spawned handler tasks not yet observed on `rx`. While `> 0`, an @@ -433,8 +302,7 @@ impl McpServer { /// `tools/call`: map the tool to an [`OrchestratorCommand`], `dispatch` it, and /// fold the [`OrchestratorOutcome`](application::OrchestratorOutcome) into an MCP - /// tool result. The `idea_ask_agent` reply is returned **inline** — never - /// re-routed. + /// tool result. async fn tools_call(&self, params: Value) -> Result { let name = params .get("name") @@ -445,8 +313,6 @@ impl McpServer { // Diagnostics begin beacon (best-effort, jamais le corps task/result) : trace // l'entrée d'un `tools/call`, son tool, le peer demandeur et la cible/longueurs. - // C'est l'amorce de la trace `[mcp]` begin → (armed) → (expired) → end qui - // permet de voir un `idea_ask_agent` entrer sans jamais ressortir (blocage). let started = Instant::now(); let requester_label = if self.requester.is_empty() { "mcp".to_owned() @@ -467,8 +333,8 @@ impl McpServer { task_len={task_len}", ); - // `idea_reply` needs the connected peer's identity as `from`; every other tool - // ignores `requester`. The handshake-provided requester is the source of truth. + // The handshake-provided requester is still passed to the mapper for tools that + // need peer identity. let command = match tools::map_tool_call(&name, &arguments, &self.requester) { Ok(command) => command, Err(e) => { @@ -481,39 +347,7 @@ impl McpServer { } }; - // `idea_ask_agent` is the only tool that blocks on a synchronous rendezvous (the - // target's `idea_reply`). Bound *only* that path with the **inactivity watchdog** - // (server-side safety net, see [`ASK_RENDEZVOUS_TIMEOUT`] / [`ASK_RENDEZVOUS_CEILING`]): - // race the dispatch against an inactivity window; on each window expiry, probe - // whether the target progressed (transcript grew). Progress ⇒ re-arm the window - // (extension) up to the absolute ceiling; genuine silence ⇒ the retryable no-reply - // error; ceiling reached while still active ⇒ the distinct non-retryable ceiling - // error. Every other tool dispatches unbounded — they never rendezvous. - let dispatch = self.service.dispatch(&self.project, command); - let result = if name == "idea_ask_agent" { - application::diag!( - "[mcp] ask armed requester={requester_label} target={arg_target} \ - task_len={task_len} window_ms={} ceiling_ms={}", - self.ask_rendezvous_timeout.as_millis(), - self.ask_rendezvous_ceiling.as_millis(), - ); - match self - .run_ask_rendezvous(dispatch, &arg_target, &requester_label, started) - .await - { - // RISQUE #1 (Architect) : sur NoReply/CeilingActive, le futur `dispatch` a déjà - // été **droppé** dans `run_ask_rendezvous` (il le possède et le pin), ce Drop - // libère le `BusyTurnGuard` RAII et purge le `Busy`/ticket de la cible (aucune - // fuite `Busy`). - AskRendezvousOutcome::Resolved(result) => result, - AskRendezvousOutcome::NoReply => return Err(rendezvous_no_reply_error(&arg_target)), - AskRendezvousOutcome::CeilingActive => { - return Err(rendezvous_ceiling_active_error(&arg_target)) - } - } - } else { - dispatch.await - }; + let result = self.service.dispatch(&self.project, command).await; // Surface the processed delegation on the bus, tagged as the MCP door — the // twin of the file watcher's publish. `ok` mirrors the dispatch outcome; the @@ -572,77 +406,6 @@ impl McpServer { }); } } - - /// Drives the `idea_ask_agent` synchronous rendezvous under the **inactivity - /// watchdog** (cf. [`ASK_RENDEZVOUS_TIMEOUT`] / [`ASK_RENDEZVOUS_CEILING`]). - /// - /// Thin adapter over the testable free function [`run_inactivity_watchdog`]: it - /// supplies this server's window, ceiling and (optional) activity probe, and maps the - /// watchdog verdict to an [`AskRendezvousOutcome`]. With no probe wired the watchdog - /// degrades to a single flat window (legacy behaviour, zero regression). - async fn run_ask_rendezvous( - &self, - dispatch: impl Future>, - target: &str, - requester_label: &str, - started: Instant, - ) -> AskRendezvousOutcome { - let probe = self.activity_probe.clone(); - let target_owned = target.to_owned(); - let probe_fn = move || { - let probe = probe.clone(); - let target = target_owned.clone(); - async move { - match &probe { - Some(p) => p(target).await, - None => None, - } - } - }; - // DRY : on réutilise l'**unique** implémentation du watchdog (couche application), - // la même que celle qui gouverne réellement le tour délégué dans le service. Ici - // c'est le filet de sécurité externe de l'adaptateur MCP. - let window_ms = self.ask_rendezvous_timeout.as_millis(); - let ceiling_ms = self.ask_rendezvous_ceiling.as_millis(); - let (t_ext, r_ext) = (target.to_owned(), requester_label.to_owned()); - let (t_exp, r_exp) = (target.to_owned(), requester_label.to_owned()); - let (t_ceil, r_ceil) = (target.to_owned(), requester_label.to_owned()); - match application::run_inactivity_watchdog( - dispatch, - self.ask_rendezvous_timeout, - self.ask_rendezvous_ceiling, - started, - self.activity_probe.is_some(), - probe_fn, - move |elapsed| { - application::diag!( - "[mcp] ask window extended (signe de vie) requester={r_ext} target={t_ext} \ - window_ms={window_ms} elapsed_ms={}", - elapsed.as_millis(), - ); - }, - move |elapsed| { - application::diag!( - "[mcp] ask EXPIRED (no progress) requester={r_exp} target={t_exp} \ - window_ms={window_ms} elapsed_ms={}", - elapsed.as_millis(), - ); - }, - move |elapsed| { - application::diag!( - "[mcp] ask CEILING active requester={r_ceil} target={t_ceil} \ - ceiling_ms={ceiling_ms} elapsed_ms={}", - elapsed.as_millis(), - ); - }, - ) - .await - { - application::WatchdogOutcome::Resolved(r) => AskRendezvousOutcome::Resolved(r), - application::WatchdogOutcome::NoReply => AskRendezvousOutcome::NoReply, - application::WatchdogOutcome::CeilingActive => AskRendezvousOutcome::CeilingActive, - } - } } /// Builds an MCP `tools/call` result with a single text content block. @@ -653,71 +416,6 @@ fn tool_result_text(text: &str, is_error: bool) -> Value { }) } -/// Builds the JSON-RPC error returned when the `idea_ask_agent` rendezvous expires -/// against the server-side safety net (the `Elapsed` branch of the outer `timeout`). -/// -/// Mirrors [`application::AppError::TargetReturnedNoReply`] one-for-one — same message -/// and same machine-readable `code` (`"TARGET_RETURNED_NO_REPLY"`), flagged `retryable` -/// — but as a JSON-RPC protocol error under the **distinct** -/// [`error_codes::RENDEZVOUS_NO_REPLY`] code (never the opaque `INTERNAL_ERROR`), since -/// at expiry no [`OrchestratorOutcome`](application::OrchestratorOutcome) exists to fold -/// into a tool result. The `data` object lets a caller branch without parsing the -/// message. -fn rendezvous_no_reply_error(target: &str) -> JsonRpcError { - let message = format!( - "agent {target} returned to its prompt without calling idea_reply (no answer was \ - rendered) before the rendezvous timeout; retry the request and ensure the agent \ - replies via idea_reply" - ); - JsonRpcError { - code: error_codes::RENDEZVOUS_NO_REPLY, - message, - data: Some(json!({ - "code": "TARGET_RETURNED_NO_REPLY", - "retryable": true, - "target": target, - })), - } -} - -/// Outcome of the inactivity-watchdog rendezvous ([`McpServer::run_ask_rendezvous`]), -/// folded into a `tools/call` result by the caller. -enum AskRendezvousOutcome { - /// The dispatch completed (success or a typed `AppError`) — returned unchanged. - Resolved(Result), - /// The inactivity window elapsed with no observable progress ⇒ the retryable - /// [`rendezvous_no_reply_error`]. Also the flat-window fallback when no probe is wired. - NoReply, - /// The absolute ceiling was reached while the target was still actively working ⇒ the - /// distinct, non-retryable [`rendezvous_ceiling_active_error`]. - CeilingActive, -} - -/// Builds the JSON-RPC error returned when the `idea_ask_agent` rendezvous reaches the -/// **absolute ceiling** while the target is **still actively working** (its transcript -/// kept growing across probes). -/// -/// **Distinct** from [`rendezvous_no_reply_error`]: the target is not silent, so a blind -/// retry would stack a second heavy turn on a target already mid-task. Carried under the -/// dedicated [`error_codes::RENDEZVOUS_CEILING_ACTIVE`] code with `data.retryable = false` -/// so a caller can branch without parsing the message. -fn rendezvous_ceiling_active_error(target: &str) -> JsonRpcError { - let message = format!( - "target {target} is still actively working but the rendezvous ceiling was reached; \ - do not retry blindly — check the target's in-progress work/branch (it may still \ - finish and call idea_reply), then re-solicit lightly if needed" - ); - JsonRpcError { - code: error_codes::RENDEZVOUS_CEILING_ACTIVE, - message, - data: Some(json!({ - "code": "RENDEZVOUS_CEILING_ACTIVE", - "retryable": false, - "target": target, - })), - } -} - /// Maps a [`ToolMapError`] to the right JSON-RPC error code. fn map_err_to_jsonrpc(err: ToolMapError) -> JsonRpcError { match err { @@ -729,49 +427,3 @@ fn map_err_to_jsonrpc(err: ToolMapError) -> JsonRpcError { } } } - -#[cfg(test)] -mod tests { - use super::*; - use std::time::Duration; - - // NOTE: the inactivity-watchdog **algorithm** now lives in (and is unit-tested by) - // the `application` crate (`application::run_inactivity_watchdog`), the single source - // of truth that also governs the delegated turn. This adapter only maps its verdict to - // an `AskRendezvousOutcome`, so we keep here only the adapter-specific concerns: the - // error builders and the override resolvers. - - /// The two error builders carry distinct JSON-RPC codes and the documented - /// `data.retryable` flags (no-reply ⇒ retryable; ceiling-active ⇒ not). - #[test] - fn error_builders_carry_distinct_codes_and_retryable_flags() { - let no_reply = rendezvous_no_reply_error("architect"); - assert_eq!(no_reply.code, error_codes::RENDEZVOUS_NO_REPLY); - assert_eq!(no_reply.data.as_ref().unwrap()["retryable"], serde_json::json!(true)); - assert_eq!( - no_reply.data.as_ref().unwrap()["code"], - serde_json::json!("TARGET_RETURNED_NO_REPLY") - ); - - let ceiling = rendezvous_ceiling_active_error("architect"); - assert_eq!(ceiling.code, error_codes::RENDEZVOUS_CEILING_ACTIVE); - assert_eq!(ceiling.data.as_ref().unwrap()["retryable"], serde_json::json!(false)); - assert_eq!( - ceiling.data.as_ref().unwrap()["code"], - serde_json::json!("RENDEZVOUS_CEILING_ACTIVE") - ); - assert_ne!(no_reply.code, ceiling.code); - } - - /// The resolvers honour an override and fall back to their finite defaults; `Some(0)` - /// is treated as "no override" (never an instant collapse). - #[test] - fn resolvers_apply_override_else_finite_default() { - assert_eq!(resolve_ask_rendezvous_timeout(Some(1234)), Duration::from_millis(1234)); - assert_eq!(resolve_ask_rendezvous_timeout(Some(0)), ASK_RENDEZVOUS_TIMEOUT); - assert_eq!(resolve_ask_rendezvous_timeout(None), ASK_RENDEZVOUS_TIMEOUT); - assert_eq!(resolve_ask_rendezvous_ceiling(Some(9999)), Duration::from_millis(9999)); - assert_eq!(resolve_ask_rendezvous_ceiling(Some(0)), ASK_RENDEZVOUS_CEILING); - assert_eq!(resolve_ask_rendezvous_ceiling(None), ASK_RENDEZVOUS_CEILING); - } -} diff --git a/crates/infrastructure/src/orchestrator/mcp/tools.rs b/crates/infrastructure/src/orchestrator/mcp/tools.rs index 8b1fb74..c287d0e 100644 --- a/crates/infrastructure/src/orchestrator/mcp/tools.rs +++ b/crates/infrastructure/src/orchestrator/mcp/tools.rs @@ -8,12 +8,17 @@ //! validation — the very same path the filesystem watcher takes. No tool //! validates anything itself; no tool reaches a use case directly. //! -//! ## Agent discovery (`idea_list_agents`) +//! ## Agent discovery (`idea_list_agents`) and delegation (`idea_ask_agent`) //! //! Discovery maps to the [`OrchestratorCommand::ListAgents`] domain variant and is //! served through the **same** `OrchestratorService::dispatch` as every other tool //! (no use-case is reached directly). Its success carries the agent list inline as //! a JSON array in the outcome's reply — see [`tool_returns_reply`]. +//! +//! Delegation maps to [`OrchestratorCommand::AskAgent`]. The MCP tool is only an +//! ergonomic IdeA entrypoint: the application orchestrator drives the target's +//! structured/headless session and returns the target turn's `Final` inline. The +//! target never receives a model-managed ticket and never has to call `idea_reply`. use domain::{OrchestratorCommand, OrchestratorError, OrchestratorRequest}; use serde_json::{json, Value}; @@ -44,9 +49,8 @@ pub enum ToolMapError { } /// Whether a successful call of `tool` carries an inline reply payload back to the -/// caller: `idea_ask_agent` (the target's reply) and `idea_list_agents` (the agent -/// list as a JSON array). `idea_reply` is an **ACK only** (the result is routed to -/// the awaiting requester, not echoed inline), so it is **not** in this set. +/// caller. `idea_ask_agent` is a synchronous delegation tool: its payload is the +/// target agent's captured final answer. #[must_use] pub fn tool_returns_reply(tool: &str) -> bool { matches!( @@ -79,35 +83,20 @@ pub fn catalogue() -> Vec { }, ToolDef { name: "idea_ask_agent", - description: "Ask another IdeA agent a task and wait for its reply (synchronous \ - inter-agent rendezvous). Returns the target agent's answer inline.", + description: "Ask another IdeA agent to handle a task and wait for its final answer. \ + IdeA launches or reattaches the target through its structured/headless \ + profile, captures the target turn's Final, and returns it inline. The \ + target does not call a reply tool or manage tickets.", input_schema: json!({ "type": "object", "properties": { "target": { "type": "string", "description": "Target agent display name." }, - "task": { "type": "string", "description": "The task/message to send." } + "task": { "type": "string", "description": "Task or question to send to the target agent." } }, "required": ["target", "task"], "additionalProperties": false }), }, - ToolDef { - name: "idea_reply", - description: "Render the result of the task you are currently processing (the one IdeA \ - delegated to you as `[IdeA · tâche … · ticket ]`). Call this — never \ - answer in plain text — so IdeA can hand your answer back to the agent that \ - asked. **Echo the `ticket` id** shown in that prefix so IdeA correlates your \ - reply exactly, even when you handle several requests.", - input_schema: json!({ - "type": "object", - "properties": { - "result": { "type": "string", "description": "The result/answer to deliver to the requester." }, - "ticket": { "type": "string", "description": "The ticket id from the `[IdeA · … · ticket ]` prefix you are answering. Optional, but strongly recommended when you handle more than one request." } - }, - "required": ["result"], - "additionalProperties": false - }), - }, ToolDef { name: "idea_launch_agent", description: "Launch (or attach) an IdeA agent. Fire-and-forget: returns once IdeA \ @@ -275,9 +264,9 @@ pub fn catalogue() -> Vec { /// /// `arguments` is the raw object an MCP client passes under `params.arguments`. /// `requester` is the **connected peer's agent id** (from the loopback handshake, -/// cadrage v5 §1.4): it is injected as the `from` of an `idea_reply` so correlation -/// uses the handshake identity, never a model-managed value. Every other tool -/// ignores it. +/// cadrage v5 §1.4): `idea_ask_agent` carries it as `requestedBy` for A↔B routing +/// and cycle detection. Self-keyed tools also use it as their identity; the model +/// never supplies another agent's id. /// /// # Errors /// - [`ToolMapError::UnknownTool`] for a name outside [`catalogue`], @@ -303,27 +292,6 @@ pub fn map_tool_call( request_type: Some("agent.list".to_owned()), ..base() }, - "idea_ask_agent" => OrchestratorRequest { - request_type: Some("agent.message".to_owned()), - // The **requester** is the connected peer's handshake identity (never a - // tool argument), injected as `requestedBy` so `validate` can route the - // ask on the A↔B conversation and feed the wait-for guard (cadrage C3). - requested_by: Some(requester.to_owned()), - target_agent: s("target"), - task: s("task"), - ..base() - }, - "idea_reply" => OrchestratorRequest { - request_type: Some("agent.reply".to_owned()), - // `from` is the handshake identity, not a tool argument: inject the peer's - // requester id as `requestedBy` so `validate` builds `Reply { from, .. }`. - requested_by: Some(requester.to_owned()), - // The agent echoes the `ticket` it received in the `[IdeA · … · ticket - // ]` prefix ⇒ correlate by ticket (multi-thread); optional (cadrage C3 §3.3). - ticket: s("ticket"), - result: s("result"), - ..base() - }, "idea_launch_agent" => OrchestratorRequest { request_type: Some("agent.run".to_owned()), target_agent: s("target"), @@ -333,6 +301,13 @@ pub fn map_tool_call( node_id: parse_node_id(args.get("nodeId")), ..base() }, + "idea_ask_agent" => OrchestratorRequest { + request_type: Some("agent.message".to_owned()), + requested_by: Some(requester.to_owned()), + target_agent: s("target"), + task: s("task"), + ..base() + }, "idea_stop_agent" => OrchestratorRequest { request_type: Some("agent.stop".to_owned()), target_agent: s("target"), @@ -408,33 +383,7 @@ pub fn map_tool_call( other => return Err(ToolMapError::UnknownTool(other.to_owned())), }; - let command = request.validate()?; - - // Diagnostics mapping beacon (best-effort) pour les deux tools du rendezvous : - // `idea_ask_agent` et `idea_reply`. Longueurs et présence de ticket uniquement — - // jamais le corps `task`/`result` ni de secret. Permet de corréler un `[mcp]` - // begin avec la commande effectivement construite (kind/target/requester). - match name { - "idea_ask_agent" => application::diag!( - "[mcp] mapped kind=ask_agent target={} task_len={} requester={}", - s("target").as_deref().unwrap_or("-"), - s("task").map_or(0, |t| t.len()), - if requester.is_empty() { "-" } else { requester }, - ), - "idea_reply" => application::diag!( - "[mcp] mapped kind=reply result_len={} ticket={} requester={}", - s("result").map_or(0, |r| r.len()), - if s("ticket").is_some() { - "present" - } else { - "absent" - }, - if requester.is_empty() { "-" } else { requester }, - ), - _ => {} - } - - Ok(command) + request.validate().map_err(ToolMapError::Invalid) } /// An all-`None` request to spread over; keeps each arm above to just its fields. @@ -487,21 +436,39 @@ mod tests { } #[test] - fn ask_agent_maps_to_ask_command() { - let cmd = map( - "idea_ask_agent", - &json!({ "target": "Architect", "task": "Analyse §17" }), - ) - .unwrap(); + fn ask_agent_maps_to_headless_inter_agent_command_but_reply_stays_hidden() { + let requester = uuid::Uuid::from_u128(42).to_string(); assert_eq!( - cmd, + map_tool_call( + "idea_ask_agent", + &json!({ "target": "Architect", "task": "Analyse §17" }), + &requester, + ) + .unwrap(), OrchestratorCommand::AskAgent { target: "Architect".to_owned(), task: "Analyse §17".to_owned(), - // `map` passes an empty requester ⇒ no machine requester carried. - requester: None, + requester: Some(domain::AgentId::from_uuid( + uuid::Uuid::parse_str(&requester).unwrap() + )), } ); + assert_eq!( + map_tool_call( + "idea_ask_agent", + &json!({ "target": "Architect", "task": "Analyse §17" }), + "", + ), + Ok(OrchestratorCommand::AskAgent { + target: "Architect".to_owned(), + task: "Analyse §17".to_owned(), + requester: None, + }) + ); + assert_eq!( + map_tool_call("idea_reply", &json!({ "result": "the answer" }), REQ), + Err(ToolMapError::UnknownTool("idea_reply".to_owned())) + ); } #[test] @@ -553,7 +520,7 @@ mod tests { #[test] fn missing_required_field_surfaces_validation_error() { - let err = map("idea_ask_agent", &json!({ "target": "Architect" })); + let err = map("idea_update_context", &json!({ "target": "Architect" })); assert!(matches!(err, Err(ToolMapError::Invalid(_)))); } @@ -574,84 +541,13 @@ mod tests { #[test] fn non_object_arguments_are_rejected() { - let err = map("idea_ask_agent", &json!("nope")); + let err = map("idea_launch_agent", &json!("nope")); assert_eq!( err, - Err(ToolMapError::BadArguments("idea_ask_agent".to_owned())) + Err(ToolMapError::BadArguments("idea_launch_agent".to_owned())) ); } - #[test] - fn reply_maps_with_handshake_requester_as_from() { - // `from` comes from the handshake requester, NOT a tool argument. - let cmd = map_tool_call("idea_reply", &json!({ "result": "the answer" }), REQ).unwrap(); - assert_eq!( - cmd, - OrchestratorCommand::Reply { - from: domain::AgentId::from_uuid(uuid::Uuid::parse_str(REQ).unwrap()), - ticket: None, - result: "the answer".to_owned(), - } - ); - } - - #[test] - fn reply_echoes_ticket_when_provided() { - let tkt = "22222222-2222-2222-2222-222222222222"; - let cmd = map_tool_call( - "idea_reply", - &json!({ "result": "done", "ticket": tkt }), - REQ, - ) - .unwrap(); - assert_eq!( - cmd, - OrchestratorCommand::Reply { - from: domain::AgentId::from_uuid(uuid::Uuid::parse_str(REQ).unwrap()), - ticket: Some(domain::mailbox::TicketId::from_uuid( - uuid::Uuid::parse_str(tkt).unwrap() - )), - result: "done".to_owned(), - } - ); - } - - #[test] - fn ask_agent_injects_handshake_requester() { - let cmd = map_tool_call( - "idea_ask_agent", - &json!({ "target": "B", "task": "go" }), - REQ, - ) - .unwrap(); - assert_eq!( - cmd, - OrchestratorCommand::AskAgent { - target: "B".to_owned(), - task: "go".to_owned(), - requester: Some(domain::AgentId::from_uuid( - uuid::Uuid::parse_str(REQ).unwrap() - )), - } - ); - } - - #[test] - fn reply_without_result_is_a_validation_error() { - let err = map_tool_call("idea_reply", &json!({}), REQ); - assert!(matches!(err, Err(ToolMapError::Invalid(_)))); - } - - #[test] - fn reply_with_blank_or_missing_requester_is_a_validation_error() { - // No handshake identity ⇒ no `from` ⇒ typed validation error (never a panic). - let err = map_tool_call("idea_reply", &json!({ "result": "x" }), ""); - assert!(matches!(err, Err(ToolMapError::Invalid(_)))); - // A non-uuid requester is rejected too. - let err2 = map_tool_call("idea_reply", &json!({ "result": "x" }), "not-a-uuid"); - assert!(matches!(err2, Err(ToolMapError::Invalid(_)))); - } - #[test] fn context_read_maps_with_requester_party() { // Global (no target) with a uuid requester ⇒ Agent party. @@ -721,8 +617,8 @@ mod tests { } #[test] - fn reply_is_not_an_inline_reply_tool() { - // ACK only: the result is routed to the awaiting requester, not echoed. + fn ask_agent_is_an_inline_reply_tool_but_reply_is_not_exposed() { + assert!(tool_returns_reply("idea_ask_agent")); assert!(!tool_returns_reply("idea_reply")); } diff --git a/crates/infrastructure/src/session/codex.rs b/crates/infrastructure/src/session/codex.rs index 45a351c..1b7b6bb 100644 --- a/crates/infrastructure/src/session/codex.rs +++ b/crates/infrastructure/src/session/codex.rs @@ -165,9 +165,9 @@ impl CodexExecSession { /// Format RÉEL vérifié 2026-06-10 (codex 0.137.0) : /// - Conversation neuve : `codex exec --json --skip-git-repo-check /// --sandbox workspace-write --add-dir `. - /// - Reprise (id connu) : `codex exec resume --json - /// --skip-git-repo-check --sandbox workspace-write --add-dir - /// `. + /// - Reprise (id connu) : `codex exec --json + /// --skip-git-repo-check --sandbox workspace-write --add-dir resume + /// `. /// /// **Autonomie d'écriture (D3)** : `--sandbox workspace-write` autorise l'agent à /// écrire dans son workspace. `codex exec` est déjà non-interactif (aucun prompt @@ -178,10 +178,7 @@ impl CodexExecSession { /// à la sandbox Codex pour que les écritures Git touchent le vrai workspace. fn build_spawn_line(&self, prompt: &str) -> SpawnLine { let mut args = vec!["exec".to_owned()]; - if let Some(id) = self.conversation_id.lock().expect("mutex sain").as_ref() { - args.push("resume".to_owned()); - args.push(id.clone()); - } + let conversation_id = self.conversation_id.lock().expect("mutex sain").clone(); args.push("--json".to_owned()); args.push("--skip-git-repo-check".to_owned()); args.push("--sandbox".to_owned()); @@ -190,6 +187,10 @@ impl CodexExecSession { args.push("--add-dir".to_owned()); args.push(root.clone()); } + if let Some(id) = conversation_id { + args.push("resume".to_owned()); + args.push(id); + } args.push(prompt.to_owned()); SpawnLine { command: self.command.clone(), diff --git a/crates/infrastructure/src/session/mod.rs b/crates/infrastructure/src/session/mod.rs index 91ddd28..62c931d 100644 --- a/crates/infrastructure/src/session/mod.rs +++ b/crates/infrastructure/src/session/mod.rs @@ -1020,8 +1020,8 @@ mod tests { let _ = std::fs::remove_file(&argv); } - /// Idem Codex : `codex exec resume --json --skip-git-repo-check ` - /// (format RÉEL : sous-commande `resume`, pas un flag `--resume`). + /// Idem Codex : `codex exec --json --skip-git-repo-check resume `. + /// Les options globales de `exec` doivent précéder la sous-commande `resume`. #[tokio::test] async fn codex_resume_command_carries_resume_subcommand() { let (cmd, argv) = make_recording_fake(&[ @@ -1045,6 +1045,21 @@ mod tests { assert!(args.contains(&"--json"), "vu: {args:?}"); assert!(args.contains(&"--skip-git-repo-check"), "vu: {args:?}"); assert!(args.contains(&"vas-y"), "vu: {args:?}"); + let resume_pos = args.iter().position(|a| *a == "resume").expect("resume"); + let id_pos = args.iter().position(|a| *a == "cx-id").expect("id"); + let json_pos = args.iter().position(|a| *a == "--json").expect("json"); + let skip_pos = args + .iter() + .position(|a| *a == "--skip-git-repo-check") + .expect("skip"); + assert!( + json_pos < resume_pos && resume_pos < id_pos, + "exec options must precede resume, then the session id, vu: {args:?}" + ); + assert!( + skip_pos < resume_pos && resume_pos < id_pos, + "exec options must precede resume, then the session id, vu: {args:?}" + ); let _ = std::fs::remove_file(&cmd); let _ = std::fs::remove_file(&argv); } @@ -1311,7 +1326,7 @@ mod tests { // d'écriture Codex : la commande générée porte EXACTEMENT // [exec, --json, --skip-git-repo-check, --sandbox, workspace-write, // --add-dir, , ] - // (resume en tête pour une reprise). Le flag `--ask-for-approval never` + // (`resume ` après les options `exec` pour une reprise). Le flag `--ask-for-approval never` // a été RETIRÉ : `codex exec` 0.137 ne le connaît pas (`error: unexpected // argument`) et est déjà non-interactif. Ce test verrouille l'argv exact pour // qu'aucune régression ne réintroduise un flag inconnu de la sous-commande. @@ -1358,9 +1373,9 @@ mod tests { let _ = std::fs::remove_file(&argv); } - /// REPRISE (seed d'id) : argv EXACT `[exec, resume, , --json, - /// --skip-git-repo-check, --sandbox, workspace-write, --add-dir, , - /// ]`. Toujours pas de `--ask-for-approval`. + /// REPRISE (seed d'id) : argv EXACT `[exec, --json, --skip-git-repo-check, + /// --sandbox, workspace-write, --add-dir, , resume, , ]`. + /// Toujours pas de `--ask-for-approval`. #[tokio::test] async fn codex_resume_command_carries_exact_args() { let (cmd, argv) = make_recording_fake(&[ @@ -1383,17 +1398,17 @@ mod tests { args, vec![ "exec", - "resume", - "cx-id", "--json", "--skip-git-repo-check", "--sandbox", "workspace-write", "--add-dir", "/project/root", + "resume", + "cx-id", "vas-y", ], - "argv reprise doit être exact (resume en tête, sans --ask-for-approval), vu: {args:?}" + "argv reprise doit être exact (options exec avant resume, sans --ask-for-approval), vu: {args:?}" ); let _ = std::fs::remove_file(&cmd); let _ = std::fs::remove_file(&argv); diff --git a/crates/infrastructure/src/store/profile.rs b/crates/infrastructure/src/store/profile.rs index 5024afd..59f454a 100644 --- a/crates/infrastructure/src/store/profile.rs +++ b/crates/infrastructure/src/store/profile.rs @@ -286,4 +286,3 @@ impl EmbedderProfileStore for FsEmbedderProfileStore { FsEmbedderProfileStore::delete(self, id).await } } - diff --git a/crates/infrastructure/tests/mcp_server.rs b/crates/infrastructure/tests/mcp_server.rs index 69c1379..46bf456 100644 --- a/crates/infrastructure/tests/mcp_server.rs +++ b/crates/infrastructure/tests/mcp_server.rs @@ -8,17 +8,16 @@ //! use (`orchestrator_watcher.rs`), so we assert MCP behaviour against a real //! [`OrchestratorService`] without any I/O: //! -//! 1. `tools/list` advertises the six `idea_*` tools, each with an input schema. +//! 1. `tools/list` advertises the MCP `idea_*` tools, each with an input schema. //! 2. `tools/call` maps every tool to the right `OrchestratorCommand` (observed //! through the fakes: spawn creates an agent, stop closes the session, …). -//! 3. `idea_ask_agent` returns the target's `reply` **inline**. -//! 4. `idea_list_agents` returns the agent list **inline** as a JSON array. -//! 5. A failed IdeA command ⇒ tool result `isError: true` (not a transport error, +//! 3. `idea_list_agents` returns the agent list **inline** as a JSON array. +//! 4. A failed IdeA command ⇒ tool result `isError: true` (not a transport error, //! not a panic); the server stays alive for the next call. -//! 6. Malformed JSON-RPC ⇒ a typed `PARSE_ERROR`, never a panic. -//! 7. Unknown method / unknown tool ⇒ typed errors, never a panic. -//! 8. `initialize` answers the minimal handshake. -//! 9. A `tools/call` missing a required argument is rejected by the **same** +//! 5. Malformed JSON-RPC ⇒ a typed `PARSE_ERROR`, never a panic. +//! 6. Unknown method / unknown tool ⇒ typed errors, never a panic. +//! 7. `initialize` answers the minimal handshake. +//! 8. A `tools/call` missing a required argument is rejected by the **same** //! `OrchestratorRequest::validate` (typed error, **no** dispatch). use std::collections::HashMap; @@ -52,7 +51,7 @@ use application::{ CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents, OrchestratorService, TerminalSessions, UpdateAgentContext, }; -use infrastructure::orchestrator::mcp::jsonrpc::{error_codes, Transport, TransportError}; +use infrastructure::orchestrator::mcp::jsonrpc::error_codes; use infrastructure::{ InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, MemoryTransport, SystemMillisClock, @@ -421,20 +420,6 @@ fn build_service_with_mailbox( (Arc::new(service), mailbox, sessions) } -/// 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 }, - ), - ); -} - fn server(service: Arc) -> McpServer { McpServer::new(service, project()) } @@ -479,7 +464,7 @@ fn result_text(result: &Value) -> &str { // --------------------------------------------------------------------------- #[tokio::test] -async fn tools_list_advertises_the_seven_idea_tools_with_schemas() { +async fn tools_list_advertises_the_idea_tools_with_schemas() { let (service, _s) = build_service(FakeContexts::new()); let server = server(service); @@ -501,7 +486,6 @@ async fn tools_list_advertises_the_seven_idea_tools_with_schemas() { for expected in [ "idea_list_agents", "idea_ask_agent", - "idea_reply", "idea_launch_agent", "idea_stop_agent", "idea_update_context", @@ -522,10 +506,11 @@ async fn tools_list_advertises_the_seven_idea_tools_with_schemas() { "missing tool {expected}; got {names:?}" ); } + assert!(!names.contains(&"idea_reply")); assert_eq!( tools.len(), - 14, - "exactly the fourteen idea_* tools (7 base + idea_skill_read + 4 FileGuard C7 + 2 live-state LS4); got {names:?}" + 13, + "exactly the thirteen exposed idea_* tools; got {names:?}" ); // Every tool advertises an object input schema. @@ -634,76 +619,32 @@ async fn update_context_and_create_skill_calls_succeed() { } // --------------------------------------------------------------------------- -// 3. idea_ask_agent returns the reply inline +// 3. Inter-agent delegation is exposed through MCP; reply protocol is not // --------------------------------------------------------------------------- #[tokio::test] -async fn ask_agent_returns_target_reply_inline() { - // Option 1 (B-3/B-4): `idea_ask_agent` writes the task into the target's live - // terminal and blocks on the mailbox; the target's `idea_reply` (carrying its - // handshake identity as requester) resolves it, and the ask returns the result - // inline. We drive both over the MCP server, the ask on a spawned task. +async fn ask_agent_tool_reaches_shared_validation_before_dispatch() { let contexts = FakeContexts::new(); - let agent_id = contexts.seed_agent("architect"); - let (service, mailbox, sessions) = build_service_with_mailbox(contexts); - seed_live_pty( - &sessions, - agent_id, - SessionId::from_uuid(Uuid::from_u128(4242)), - ); + contexts.seed_agent("architect"); + let (service, _mailbox, _sessions) = build_service_with_mailbox(contexts); + let server = server(service); - let ask_server = server(Arc::clone(&service)); - let ask = tokio::spawn(async move { - let raw = tools_call( - 7, - "idea_ask_agent", - json!({ "target": "architect", "task": "What is the answer?" }), - ); - ask_server.handle_raw(&raw).await.expect("reply owed") - }); - - // Wait until the ask has enqueued its ticket (it is now blocked awaiting a reply). - tokio::time::timeout(std::time::Duration::from_secs(10), async { - while mailbox.pending(&agent_id) == 0 { - tokio::task::yield_now().await; - } - }) - .await - .expect("ask must enqueue a ticket"); - - // The target replies via idea_reply — its identity comes from the handshake - // requester, which `for_requester` injects as the `from` of the Reply command. - let reply_server = server(Arc::clone(&service)).for_requester(agent_id.to_string()); - let reply_raw = tools_call(8, "idea_reply", json!({ "result": "the answer is 42" })); - let reply_resp = reply_server - .handle_raw(&reply_raw) - .await - .expect("reply owed"); - assert_eq!(reply_resp.result.expect("result")["isError"], json!(false)); - - let response = tokio::time::timeout(std::time::Duration::from_secs(10), ask) - .await - .expect("ask completes after reply") - .expect("join ok"); - assert_eq!(response.id, json!(7), "id must be echoed"); + let raw = tools_call(7, "idea_ask_agent", json!({ "target": "architect" })); + let response = server.handle_raw(&raw).await.expect("reply owed"); + let error = response.error.expect("validation error expected"); + assert_eq!(error.code, error_codes::INVALID_PARAMS); assert!( - response.error.is_none(), - "transport error: {:?}", - response.error + error.message.contains("task"), + "shared validation should reject the missing task, got {error:?}" ); - let result = response.result.expect("result"); - assert_eq!(result["isError"], json!(false), "got {result}"); - assert_eq!( - result_text(&result), - "the answer is 42", - "ask reply must be returned inline; got {result}" + assert!( + response.result.is_none(), + "no dispatch on validation failure" ); } -/// `idea_reply` with no in-flight ask for the connected peer ⇒ the IdeA command -/// fails, surfaced as a tool execution error (`isError: true`), never a panic. #[tokio::test] -async fn reply_without_pending_ask_is_a_tool_error() { +async fn reply_tool_is_not_exposed_over_mcp() { let contexts = FakeContexts::new(); let agent_id = contexts.seed_agent("architect"); let (service, _mailbox, _sessions) = build_service_with_mailbox(contexts); @@ -711,9 +652,9 @@ async fn reply_without_pending_ask_is_a_tool_error() { let raw = tools_call(9, "idea_reply", json!({ "result": "orphan" })); let resp = reply_server.handle_raw(&raw).await.expect("reply owed"); - // Mapped + dispatched, but the command failed ⇒ tool error, transport intact. - assert!(resp.error.is_none(), "no transport error: {:?}", resp.error); - assert_eq!(resp.result.expect("result")["isError"], json!(true)); + let error = resp.error.expect("unknown tool error expected"); + assert_eq!(error.code, error_codes::METHOD_NOT_FOUND); + assert!(resp.result.is_none()); } // --------------------------------------------------------------------------- @@ -911,22 +852,18 @@ async fn initialize_fires_ready_sink_with_requester() { // --------------------------------------------------------------------------- #[tokio::test] -async fn ask_agent_missing_task_is_rejected_by_shared_validation_no_dispatch() { +async fn ask_agent_missing_required_argument_is_rejected_before_dispatch() { let contexts = FakeContexts::new(); contexts.seed_agent("architect"); - // If validation wrongly let this through to dispatch, ask_agent would try to - // launch/contact the target and the error would differ. A validation rejection - // here is INVALID_PARAMS, before dispatch. let (service, _mailbox, _sessions) = build_service_with_mailbox(contexts); let server = server(service); let raw = tools_call(5, "idea_ask_agent", json!({ "target": "architect" })); let response = server.handle_raw(&raw).await.expect("reply owed"); - // Rejected at the mapping/validation seam → a JSON-RPC INVALID_PARAMS error, - // NOT a tool result (which would mean dispatch happened). let error = response.error.expect("validation error expected"); assert_eq!(error.code, error_codes::INVALID_PARAMS, "got {error:?}"); + assert!(error.message.contains("task"), "got {error:?}"); assert!(response.result.is_none(), "no dispatch ⇒ no tool result"); } @@ -1096,179 +1033,6 @@ async fn server_without_event_sink_emits_nothing_and_does_not_panic() { assert_eq!(contexts.entries().len(), 1); } -// --------------------------------------------------------------------------- -// 10. Anti-wedge (régression du bug serveur lockstep) + timeout du rendezvous. -// -// Cœur de la régression : un `idea_ask_agent` en attente de son `idea_reply` -// ne doit PLUS parquer toute la connexion. La preuve : pendant qu'un ask est -// bloqué (rendezvous jamais résolu), un second appel (`tools/list`) sur la -// MÊME connexion reçoit sa réponse. L'ancien `serve` lockstep ne lisait plus -// rien tant que `handle_raw` de l'ask n'avait pas rendu → ce test bouclait. -// --------------------------------------------------------------------------- - -/// Transport scriptable qui **reste ouvert** : il livre `inbound` dans l'ordre, -/// puis, une fois la file vide, `recv` reste en attente (au lieu de fermer) tant -/// que le test n'a pas appelé `close()`. Cela laisse la boucle `serve` vivante — -/// donc capable de drainer les réponses des tâches encore en vol — pendant qu'un -/// `idea_ask_agent` est parqué. Les `send` sont capturés sur un canal `mpsc`. -struct GatedTransport { - inbound: std::collections::VecDeque>, - outbound: tokio::sync::mpsc::UnboundedSender>, - close: Arc, -} - -impl GatedTransport { - fn new( - messages: Vec>, - ) -> ( - Self, - tokio::sync::mpsc::UnboundedReceiver>, - Arc, - ) { - let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); - let close = Arc::new(tokio::sync::Notify::new()); - ( - Self { - inbound: messages.into(), - outbound: tx, - close: Arc::clone(&close), - }, - rx, - close, - ) - } -} - -#[async_trait] -impl Transport for GatedTransport { - async fn recv(&mut self) -> Result, TransportError> { - if let Some(next) = self.inbound.pop_front() { - return Ok(next); - } - // File vide : on n'émet PAS Closed tout de suite — on attend le signal de - // fermeture pour ne pas tuer la boucle pendant qu'un ask est encore parqué. - self.close.notified().await; - Err(TransportError::Closed) - } - - async fn send(&mut self, message: &[u8]) -> Result<(), TransportError> { - self.outbound - .send(message.to_vec()) - .map_err(|_| TransportError::Closed) - } -} - -#[tokio::test] -async fn pending_ask_does_not_wedge_the_connection_concurrent_call_still_answered() { - // Un `idea_ask_agent` vers une cible vivante bloque sur la mailbox (aucun - // `idea_reply` ne viendra). Sur la MÊME connexion, un `tools/list` qui suit - // doit recevoir sa réponse SANS attendre la résolution de l'ask. - let contexts = FakeContexts::new(); - let agent_id = contexts.seed_agent("architect"); - let (service, mailbox, sessions) = build_service_with_mailbox(contexts); - seed_live_pty( - &sessions, - agent_id, - SessionId::from_uuid(Uuid::from_u128(909)), - ); - let server = server(service); - - let ask = tools_call( - 1, - "idea_ask_agent", - json!({ "target": "architect", "task": "blocking..." }), - ); - let list = serde_json::to_vec(&json!({ - "jsonrpc": "2.0", "id": 2, "method": "tools/list" - })) - .unwrap(); - - let (transport, mut rx, close) = GatedTransport::new(vec![ask, list]); - let serve = tokio::spawn(async move { - let mut transport = transport; - server.serve(&mut transport).await; - }); - - // L'ask a bien été enqueué (donc il est parqué en attente de reply). - tokio::time::timeout(std::time::Duration::from_secs(10), async { - while mailbox.pending(&agent_id) == 0 { - tokio::task::yield_now().await; - } - }) - .await - .expect("ask must enqueue a ticket"); - - // La réponse au `tools/list` arrive AVANT que l'ask soit résolu : c'est la - // preuve anti-wedge. (Sur l'ancien code lockstep, ce recv timeout-ait.) - let response = tokio::time::timeout(std::time::Duration::from_secs(10), rx.recv()) - .await - .expect("tools/list must be answered while the ask is still pending") - .expect("a response payload"); - let response: Value = serde_json::from_slice(&response).unwrap(); - assert_eq!(response["id"], json!(2), "the answered call is tools/list"); - assert!( - response["result"]["tools"].is_array(), - "tools/list result, got {response}" - ); - - // L'ask est toujours en vol (non résolu, aucun `idea_reply` ne viendra) : la - // boucle restera en attente de sa réponse, c'est attendu. On la ferme et on - // abandonne la tâche serve (le rendezvous parqué ne se résoudra jamais ici). - assert_eq!(mailbox.pending(&agent_id), 1, "ask still pending"); - close.notify_one(); - serve.abort(); - let _ = serve.await; -} - -#[tokio::test] -async fn ask_agent_rendezvous_times_out_with_a_jsonrpc_error() { - // La cible ne répondra jamais. Avec une borne courte injectée, l'ask doit - // finir par renvoyer une ERREUR JSON-RPC propre (filet de sécurité serveur), - // au lieu de pendre indéfiniment. - let contexts = FakeContexts::new(); - let agent_id = contexts.seed_agent("architect"); - let (service, _mailbox, sessions) = build_service_with_mailbox(contexts); - seed_live_pty( - &sessions, - agent_id, - SessionId::from_uuid(Uuid::from_u128(910)), - ); - let server = server(service).with_ask_rendezvous_timeout(std::time::Duration::from_millis(50)); - - let raw = tools_call( - 1, - "idea_ask_agent", - json!({ "target": "architect", "task": "never answered" }), - ); - let response = - tokio::time::timeout(std::time::Duration::from_secs(10), server.handle_raw(&raw)) - .await - .expect("must not hang past the injected timeout") - .expect("reply owed"); - - let error = response.error.expect("a JSON-RPC error on timeout"); - // Filet serveur : code DISTINCT de l'INTERNAL_ERROR opaque, sémantique typée et - // retryable (miroir de `AppError::TargetReturnedNoReply`). - assert_eq!( - error.code, - error_codes::RENDEZVOUS_NO_REPLY, - "rendezvous timeout maps to the distinct typed code, got {error:?}" - ); - assert_ne!( - error.code, - error_codes::INTERNAL_ERROR, - "must NOT be the opaque internal error" - ); - assert!( - error.message.contains("idea_reply"), - "message must steer the caller back to idea_reply, got {error:?}" - ); - let data = error.data.expect("typed data on the rendezvous error"); - assert_eq!(data["code"], "TARGET_RETURNED_NO_REPLY", "got {data:?}"); - assert_eq!(data["retryable"], true, "got {data:?}"); - assert!(response.result.is_none(), "error responses carry no result"); -} - // --------------------------------------------------------------------------- // Diagnostics capture (instrumentation des blocages de délégation) // @@ -1290,57 +1054,6 @@ fn read_diag(path: &std::path::Path) -> String { std::fs::read_to_string(path).unwrap_or_default() } -/// Proves the `[mcp]` rendezvous trace: a wedged `idea_ask_agent` (target that -/// never replies) emits begin → armed → EXPIRED, plus the `[mcp] mapped` beacon. -#[tokio::test] -async fn diag_capture_mcp_ask_begin_armed_expired() { - let log = diag_log_path(); - // Unique target name ⇒ only THIS test's beacons match `target=diag-wedge`. - let contexts = FakeContexts::new(); - let agent_id = contexts.seed_agent("diag-wedge"); - let (service, _mailbox, sessions) = build_service_with_mailbox(contexts); - seed_live_pty( - &sessions, - agent_id, - SessionId::from_uuid(Uuid::from_u128(0xD1A6_0911)), - ); - let server = server(service).with_ask_rendezvous_timeout(std::time::Duration::from_millis(50)); - - let raw = tools_call( - 1, - "idea_ask_agent", - json!({ "target": "diag-wedge", "task": "never answered diag" }), - ); - let response = - tokio::time::timeout(std::time::Duration::from_secs(10), server.handle_raw(&raw)) - .await - .expect("must not hang past the injected timeout") - .expect("reply owed"); - assert!( - response.error.is_some(), - "wedged ask returns a JSON-RPC error" - ); - - let body = read_diag(&log); - assert!( - body.contains("[mcp] mapped kind=ask_agent target=diag-wedge"), - "mapped beacon missing: {body}" - ); - assert!( - body.contains("[mcp] tools_call begin tool=idea_ask_agent") - && body.contains("target=diag-wedge"), - "begin beacon missing: {body}" - ); - assert!( - body.contains("[mcp] ask armed") && body.contains("target=diag-wedge"), - "armed beacon missing: {body}" - ); - assert!( - body.contains("[mcp] ask EXPIRED") && body.contains("target=diag-wedge"), - "EXPIRED beacon missing: {body}" - ); -} - /// Proves the `[rendezvous] idea_reply received` beacon fires BEFORE correlation and /// is followed by `UNMATCHED` when no in-flight ask exists for the emitter. #[tokio::test] diff --git a/frontend/src/features/layout/LayoutGrid.tsx b/frontend/src/features/layout/LayoutGrid.tsx index e48af00..a586320 100644 --- a/frontend/src/features/layout/LayoutGrid.tsx +++ b/frontend/src/features/layout/LayoutGrid.tsx @@ -20,6 +20,7 @@ import { useEffect, useRef, useState } from "react"; import type { Agent } from "@/domain"; import type { LayoutNode } from "@/domain"; +import type { ProjectWorkState } from "@/domain"; import type { ConversationDetails, LiveAgent, @@ -32,6 +33,7 @@ import { useWritePortal, } from "@/features/terminals"; import { useGateways } from "@/app/di"; +import { useProjectWorkState } from "@/features/workstate/useProjectWorkState"; import { leaves, normalizeWeights, resizeAdjacent } from "./layout"; import { useLayout, type LayoutViewModel } from "./useLayout"; @@ -42,10 +44,13 @@ interface LayoutGridProps { cwd: string; /** Active layout id; when provided the grid loads/mutates this layout. */ layoutId?: string; + /** Opens the read-only canonical transcript for a conversation. */ + onOpenConversation?: (conversationId: string) => void; } -export function LayoutGrid({ projectId, cwd, layoutId }: LayoutGridProps) { +export function LayoutGrid({ projectId, cwd, layoutId, onOpenConversation }: LayoutGridProps) { const vm = useLayout(projectId, layoutId); + const work = useProjectWorkState(projectId); if (!vm.layout) { return ( @@ -79,6 +84,9 @@ export function LayoutGrid({ projectId, cwd, layoutId }: LayoutGridProps) { parentSplit={null} projectId={projectId} visibleNodeIds={visibleNodeIds} + workState={work.state} + refreshWorkState={work.refresh} + onOpenConversation={onOpenConversation} /> ); @@ -92,9 +100,22 @@ interface NodeViewProps { parentSplit: { container: string; index: number; siblings: number } | null; projectId: string; visibleNodeIds: Set; + workState: ProjectWorkState | null; + refreshWorkState: () => Promise; + onOpenConversation?: (conversationId: string) => void; } -function NodeView({ node, cwd, vm, parentSplit, projectId, visibleNodeIds }: NodeViewProps) { +function NodeView({ + node, + cwd, + vm, + parentSplit, + projectId, + visibleNodeIds, + workState, + refreshWorkState, + onOpenConversation, +}: NodeViewProps) { switch (node.type) { case "leaf": return ( @@ -109,6 +130,9 @@ function NodeView({ node, cwd, vm, parentSplit, projectId, visibleNodeIds }: Nod parentSplit={parentSplit} projectId={projectId} visibleNodeIds={visibleNodeIds} + workState={workState} + refreshWorkState={refreshWorkState} + onOpenConversation={onOpenConversation} /> ); case "split": @@ -119,6 +143,9 @@ function NodeView({ node, cwd, vm, parentSplit, projectId, visibleNodeIds }: Nod vm={vm} projectId={projectId} visibleNodeIds={visibleNodeIds} + workState={workState} + refreshWorkState={refreshWorkState} + onOpenConversation={onOpenConversation} /> ); case "grid": @@ -129,6 +156,9 @@ function NodeView({ node, cwd, vm, parentSplit, projectId, visibleNodeIds }: Nod vm={vm} projectId={projectId} visibleNodeIds={visibleNodeIds} + workState={workState} + refreshWorkState={refreshWorkState} + onOpenConversation={onOpenConversation} /> ); } @@ -145,6 +175,9 @@ interface LeafViewProps { parentSplit: { container: string; index: number; siblings: number } | null; projectId: string; visibleNodeIds: Set; + workState: ProjectWorkState | null; + refreshWorkState: () => Promise; + onOpenConversation?: (conversationId: string) => void; } /** @@ -191,7 +224,21 @@ function goToCell(nodeId: string): void { }, 1200); } -function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm, parentSplit, projectId, visibleNodeIds }: LeafViewProps) { +function LeafView({ + id, + session, + agent, + conversationId, + agentWasRunning, + cwd, + vm, + parentSplit, + projectId, + visibleNodeIds, + workState, + refreshWorkState, + onOpenConversation, +}: LeafViewProps) { // A cell can be closed only when it lives inside a (binary) split: closing it // collapses the parent split, keeping the *sibling*. Splits are always binary // in this model (a split wraps a leaf into a 2-child container), so the kept @@ -200,7 +247,7 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm // the wrong terminal. The root cell (no parent split) cannot be closed. const canClose = parentSplit !== null && parentSplit.siblings === 2; const siblingIndex = parentSplit ? (parentSplit.index === 0 ? 1 : 0) : 0; - const { agent: agentGateway, system } = useGateways(); + const { agent: agentGateway, input, system } = useGateways(); // The single write-portal of this cell (ARCHITECTURE §20). It owns the human // line counter, the local delegation FIFO, the handshake (b→e) and the overlay @@ -266,6 +313,24 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm // Build the terminal opener based on whether an agent is pinned. const agentId = agent ?? null; + const agentWork = agentId + ? workState?.agents.find((row) => row.agentId === agentId) + : undefined; + const activeTicket = agentWork?.tickets.find((ticket) => ticket.status === "inProgress"); + const busyByWorkState = agentWork?.busy.state === "busy" || Boolean(activeTicket); + const delegatedTicket = activeTicket?.source.kind === "agent" ? activeTicket : undefined; + const historyConversationId = + activeTicket?.conversationId ?? conversationId ?? null; + + async function interruptCurrentTurn(): Promise { + if (!agentId || !input) return; + try { + await input.interrupt(projectId, agentId); + await refreshWorkState(); + } catch (err) { + setBusyNotice({ message: describeNotice(err) }); + } + } /** The live session for `candidate`, if any. */ const liveFor = (candidate: string): LiveAgent | undefined => @@ -308,7 +373,7 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm /** A live session whose previous host cell no longer exists in the layout. */ const backgroundLive = (candidate: string): LiveAgent | undefined => { const live = liveFor(candidate); - return live && live.nodeId !== id && !visibleNodeIds.has(live.nodeId) + return live && live.kind === "pty" && live.nodeId !== id && !visibleNodeIds.has(live.nodeId) ? live : undefined; }; @@ -523,7 +588,10 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm return; } const isBackground = - live && live.nodeId !== id && !visibleNodeIds.has(live.nodeId); + live && + live.kind === "pty" && + live.nodeId !== id && + !visibleNodeIds.has(live.nodeId); if (isBackground) { if (!agentGateway?.attachLiveAgent || !live.sessionId) { setBusyNotice({ @@ -593,6 +661,87 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm )} + {agentId && (busyByWorkState || historyConversationId) && ( +
+ {busyByWorkState && ( + + {delegatedTicket + ? `Busy · ${delegatedTicket.requesterLabel}` + : "Busy"} + + )} + {busyByWorkState && ( + + )} + {historyConversationId && onOpenConversation && ( + + )} +
+ )} {/* Option 1 (Terminal + MCP): every cell — plain or agent — renders the raw xterm {@link TerminalView}. Agent cells are **native terminals** (ARCHITECTURE §20): every human keystroke (Enter included) reaches the @@ -720,9 +869,21 @@ interface SplitViewProps { vm: LayoutViewModel; projectId: string; visibleNodeIds: Set; + workState: ProjectWorkState | null; + refreshWorkState: () => Promise; + onOpenConversation?: (conversationId: string) => void; } -function SplitView({ split, cwd, vm, projectId, visibleNodeIds }: SplitViewProps) { +function SplitView({ + split, + cwd, + vm, + projectId, + visibleNodeIds, + workState, + refreshWorkState, + onOpenConversation, +}: SplitViewProps) { const isRow = split.direction === "row"; const baseWeights = split.children.map((c) => c.weight); const containerRef = useRef(null); @@ -764,6 +925,9 @@ function SplitView({ split, cwd, vm, projectId, visibleNodeIds }: SplitViewProps vm={vm} projectId={projectId} visibleNodeIds={visibleNodeIds} + workState={workState} + refreshWorkState={refreshWorkState} + onOpenConversation={onOpenConversation} parentSplit={{ container: split.id, index: i, @@ -856,9 +1020,21 @@ interface GridViewProps { vm: LayoutViewModel; projectId: string; visibleNodeIds: Set; + workState: ProjectWorkState | null; + refreshWorkState: () => Promise; + onOpenConversation?: (conversationId: string) => void; } -function GridView({ grid, cwd, vm, projectId, visibleNodeIds }: GridViewProps) { +function GridView({ + grid, + cwd, + vm, + projectId, + visibleNodeIds, + workState, + refreshWorkState, + onOpenConversation, +}: GridViewProps) { const cols = normalizeWeights(grid.colWeights) .map((p) => `${p}fr`) .join(" "); @@ -896,6 +1072,9 @@ function GridView({ grid, cwd, vm, projectId, visibleNodeIds }: GridViewProps) { parentSplit={null} projectId={projectId} visibleNodeIds={visibleNodeIds} + workState={workState} + refreshWorkState={refreshWorkState} + onOpenConversation={onOpenConversation} /> ))} diff --git a/frontend/src/features/layout/singletonAgent.test.tsx b/frontend/src/features/layout/singletonAgent.test.tsx index 3aef612..47a9c48 100644 --- a/frontend/src/features/layout/singletonAgent.test.tsx +++ b/frontend/src/features/layout/singletonAgent.test.tsx @@ -158,7 +158,7 @@ describe("singleton agent — dropdown guard (T6)", () => { const a = await agent.createAgent("p1", { name: "Busy", profileId: "p" }); vi.spyOn(agent, "listLiveAgents").mockResolvedValue([ - { agentId: a.id, nodeId: "some-other-node" } as LiveAgent, + { agentId: a.id, nodeId: "some-other-node", kind: "pty" } as LiveAgent, ]); renderGrid(layout, agent); @@ -176,6 +176,41 @@ describe("singleton agent — dropdown guard (T6)", () => { ); }); + it("does not reattach a structured/headless session as an xterm PTY", async () => { + const layout = new MockLayoutGateway(); + const agent = new MockAgentGateway(); + const a = await agent.createAgent("p1", { name: "Headless", profileId: "p" }); + const attach = vi.spyOn(agent, "attachLiveAgent"); + + vi.spyOn(agent, "listLiveAgents").mockResolvedValue([ + { + agentId: a.id, + nodeId: "headless-node", + sessionId: "structured-session", + kind: "structured", + }, + ]); + + const tree = await layout.loadLayout("p1"); + const leafId = leaves(tree)[0].id; + + renderGrid(layout, agent); + + const option = await screen.findByRole("option", { name: /Headless/ }); + expect((option as HTMLOptionElement).disabled).toBe(false); + + const select = screen.getByRole("combobox") as HTMLSelectElement; + fireEvent.change(select, { target: { value: a.id } }); + + await waitFor(async () => { + const updated = await layout.loadLayout("p1"); + const leaf = leaves(updated).find((l) => l.id === leafId)!; + expect(leaf.agent).toBe(a.id); + expect(leaf.session).toBeNull(); + }); + expect(attach).not.toHaveBeenCalled(); + }); + it("the agent pinned on THIS cell stays selectable even while live (same node)", async () => { const layout = new MockLayoutGateway(); const agent = new MockAgentGateway(); diff --git a/frontend/src/features/projects/ProjectsView.tsx b/frontend/src/features/projects/ProjectsView.tsx index 8dda41a..7915fd1 100644 --- a/frontend/src/features/projects/ProjectsView.tsx +++ b/frontend/src/features/projects/ProjectsView.tsx @@ -383,6 +383,7 @@ export function ProjectsView() { projectId={active.id} cwd={active.root} layoutId={activeLayout?.id} + onOpenConversation={setViewerConversationId} /> )}