2 Commits

Author SHA1 Message Date
1fc7869160 agent conversation fix 2026-06-27 12:42:37 +02:00
c2d1a669c5 agents profiles added to gitignore 2026-06-27 12:06:25 +02:00
40 changed files with 1174 additions and 1596 deletions

1
.gitignore vendored
View File

@ -59,3 +59,4 @@ Thumbs.db
# état d'exécution reconstruit au fil de l'eau — not versioned (LS8 §7, design D19-4 ;
# seul .ideai/memory/ est le store durable versionné).
.ideai/conversations/
.ideai/agents.json

View File

@ -1,47 +0,0 @@
{
"version": 1,
"agents": [
{
"agentId": "a6ced819-b893-4213-b003-9e9dc79b9641",
"name": "Main",
"mdPath": "agents/main.md",
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
"synchronized": false
},
{
"agentId": "dce19c75-9669-4e45-b8de-9950025157da",
"name": "Architect",
"mdPath": "agents/architect.md",
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
"synchronized": false
},
{
"agentId": "73c853d1-c0fd-463b-ad17-1d24fefa371f",
"name": "DevBackend",
"mdPath": "agents/devbackend.md",
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
"synchronized": false
},
{
"agentId": "af7f86da-76bc-48e1-9900-71f45a624800",
"name": "DevFrontend",
"mdPath": "agents/devfrontend.md",
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
"synchronized": false
},
{
"agentId": "aefdbd61-e3d4-4bc1-9f42-c259446a97b5",
"name": "QA",
"mdPath": "agents/qa.md",
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
"synchronized": false
},
{
"agentId": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5",
"name": "Git",
"mdPath": "agents/git.md",
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
"synchronized": false
}
]
}

View File

@ -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/<ton-agent>/`), **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/<ton-agent>/`. 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.

View File

@ -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/<ton-agent>/`), **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/<ton-agent>/`. 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.

View File

@ -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.
pas une résolution hasardeuse.

View File

@ -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*
*Dernière mise à jour : 2026-06-20*

View File

@ -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/<ton-agent>/`), **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/<ton-agent>/`. 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

View File

@ -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

View File

@ -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.

View File

@ -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*
*Dernière mise à jour : 2026-06-20*

View File

@ -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<StopLiveAgentResponseDto, ErrorDto> {
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 })

View File

@ -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<LiveAgentDto>);
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<LiveSessionSnapshot>) -> 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(),
)

View File

@ -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<dyn LiveStateLeanProvider>),
);
// 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<dyn IdGenerator>,
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<dyn application::HandoffProvider>
)
.with_provider_session_provider(Arc::new(AppProviderSessionProvider)
as Arc<dyn application::ProviderSessionProvider>)
.with_permission_projectors(Arc::clone(&permission_projectors))
.with_live_state_lean(Arc::new(AppLiveStateLeanProvider {
clock: Arc::clone(&clock) as Arc<dyn Clock>,
}) as Arc<dyn LiveStateLeanProvider>)
.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 `<home>/.claude/projects/<encoded-run-dir>/` que l'inspecteur, via
// le même `FileSystem`. Armé par agent supporté au lancement (cf. `arm_turn_watch`).
let turn_watcher: Arc<dyn domain::ports::TurnWatcher> = Arc::new(
infrastructure::ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs_port), home_dir.clone()),
);
let turn_watcher: Arc<dyn domain::ports::TurnWatcher> =
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<Box<dyn std::future::Future<Output = Option<u64>> + Send>>
})
as std::pin::Pin<Box<dyn std::future::Future<Output = Option<u64>> + 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::<u32>().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::<u32>().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::<u32>().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

View File

@ -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());

View File

@ -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<TerminalSessions>, structured: &Arc<StructuredSessions>) -> 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::<Vec<_>>();
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");
}
// ===========================================================================

View File

@ -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"));

View File

@ -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::{

View File

@ -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);
}
}

View File

@ -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<WaitForGraph>,
/// 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<Vec<(AgentId, AgentId)>>,
/// 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<AgentId> {
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<AgentId> {
let mut out = Vec::new();
let mut stack: Vec<AgentId> = 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<AgentId>,
) -> Result<OrchestratorOutcome, AppError> {
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<String, AppError>`
// 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<String, AppError>`
// 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<WaitForGraph>,
active_waits: &'a StdMutex<Vec<(AgentId, AgentId)>>,
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

View File

@ -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<LiveSessions>,
close: Arc<CloseTerminal>,
input: Option<Arc<dyn InputMediator>>,
waits: Option<Arc<OrchestratorService>>,
}
impl StopLiveAgent {
/// Builds the use case from the live-session registry and the close primitive.
#[must_use]
pub fn new(live: Arc<LiveSessions>, close: Arc<CloseTerminal>) -> 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<dyn InputMediator>,
waits: Arc<OrchestratorService>,
) -> 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<StopLiveAgentOutput, AppError> {
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<StopLiveAgentOutput, AppError> {
// 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}"
)))
}
}

View File

@ -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;

View File

@ -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"
);
}
}

View File

@ -955,11 +955,13 @@ const TEST_GUARD: Duration = Duration::from_secs(10);
struct TestMailbox {
queues:
Mutex<HashMap<AgentId, VecDeque<(Ticket, tokio::sync::oneshot::Sender<TurnResolution>)>>>,
completions: Arc<CompletionBus>,
}
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<CompletionBus> {
Arc::clone(&self.completions)
}
}
#[derive(Clone)]
enum TestCompletion {
Replied(String),
NoReply,
Cancelled,
}
#[derive(Default)]
struct CompletionBus {
by_agent: Mutex<HashMap<AgentId, VecDeque<TestCompletion>>>,
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::<TurnResolution>();
@ -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<RecordingLiveState>,
}
struct CompletionSession {
id: SessionId,
agent: AgentId,
completions: Arc<CompletionBus>,
}
#[async_trait]
impl AgentSession for CompletionSession {
fn id(&self) -> SessionId {
self.id
}
fn conversation_id(&self) -> Option<String> {
None
}
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
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<CompletionBus>,
agents_by_path: HashMap<String, AgentId>,
next_id: Arc<Mutex<u128>>,
}
impl CompletionFactory {
fn new(completions: Arc<CompletionBus>, agents_by_path: HashMap<String, AgentId>) -> 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<Arc<dyn AgentSession>, 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<String, AgentId> = 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<dyn AgentSession>;
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<dyn ProfileStore>,
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<dyn ProfileStore>,
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<dyn AgentSessionFactory>,
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<dyn MemoryStore>,
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<OrchestratorService>,
structured: Arc<StructuredSessions>,
sessions: Arc<TerminalSessions>,
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"
);
}

View File

@ -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).
///

View File

@ -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]

View File

@ -762,29 +762,16 @@ impl MediatedInbox {
pub fn mailbox(&self) -> Arc<InMemoryMailbox> {
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 <requester> · ticket <id>]` 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<dyn EventBus>);
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");
}
// ====================================================================

View File

@ -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;

View File

@ -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<bool, FsError> {
@ -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<dyn FileSystem>, "/home/me");
let watcher =
ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs) as Arc<dyn FileSystem>, "/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<dyn FileSystem>, "/home/me");
let watcher =
ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs) as Arc<dyn FileSystem>, "/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<dyn FileSystem>, "/home/me");
let watcher =
ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs) as Arc<dyn FileSystem>, "/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<dyn FileSystem>, "/home/me");
let watcher =
ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs) as Arc<dyn FileSystem>, "/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"
);
}
}

View File

@ -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,
};

View File

@ -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.

View File

@ -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};

View File

@ -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<dyn Fn(String) -> Pin<Box<dyn Future<Output = Option<u64>> + 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<u32>) -> 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<u32>) -> 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<Arc<dyn Fn(&str) + Send + Sync>>,
/// **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<AskActivityProbe>,
}
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<T: Transport>(&self, transport: &mut T) {
let (tx, mut rx) = mpsc::unbounded_channel::<Option<Vec<u8>>>();
// 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<Value, JsonRpcError> {
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<Output = Result<application::OrchestratorOutcome, application::AppError>>,
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<application::OrchestratorOutcome, application::AppError>),
/// 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);
}
}

View File

@ -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> {
},
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 <id>]`). 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 <id>]` 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<ToolDef> {
///
/// `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
// <id>]` 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"));
}

View File

@ -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 <project-root> <prompt>`.
/// - Reprise (id connu) : `codex exec resume <thread_id> --json
/// --skip-git-repo-check --sandbox workspace-write --add-dir <project-root>
/// <prompt>`.
/// - Reprise (id connu) : `codex exec --json
/// --skip-git-repo-check --sandbox workspace-write --add-dir <project-root> resume
/// <thread_id> <prompt>`.
///
/// **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(),

View File

@ -1020,8 +1020,8 @@ mod tests {
let _ = std::fs::remove_file(&argv);
}
/// Idem Codex : `codex exec resume <thread_id> --json --skip-git-repo-check <prompt>`
/// (format RÉEL : sous-commande `resume`, pas un flag `--resume`).
/// Idem Codex : `codex exec --json --skip-git-repo-check resume <thread_id> <prompt>`.
/// 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, <project-root>, <prompt>]
// (resume <id> en tête pour une reprise). Le flag `--ask-for-approval never`
// (`resume <id>` 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, <id>, --json,
/// --skip-git-repo-check, --sandbox, workspace-write, --add-dir, <project-root>,
/// <prompt>]`. Toujours pas de `--ask-for-approval`.
/// REPRISE (seed d'id) : argv EXACT `[exec, --json, --skip-git-repo-check,
/// --sandbox, workspace-write, --add-dir, <project-root>, resume, <id>, <prompt>]`.
/// 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 <id> 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);

View File

@ -286,4 +286,3 @@ impl EmbedderProfileStore for FsEmbedderProfileStore {
FsEmbedderProfileStore::delete(self, id).await
}
}

View File

@ -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<OrchestratorService>) -> 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<Vec<u8>>,
outbound: tokio::sync::mpsc::UnboundedSender<Vec<u8>>,
close: Arc<tokio::sync::Notify>,
}
impl GatedTransport {
fn new(
messages: Vec<Vec<u8>>,
) -> (
Self,
tokio::sync::mpsc::UnboundedReceiver<Vec<u8>>,
Arc<tokio::sync::Notify>,
) {
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<Vec<u8>, 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]

View File

@ -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}
/>
</div>
);
@ -92,9 +100,22 @@ interface NodeViewProps {
parentSplit: { container: string; index: number; siblings: number } | null;
projectId: string;
visibleNodeIds: Set<string>;
workState: ProjectWorkState | null;
refreshWorkState: () => Promise<void>;
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<string>;
workState: ProjectWorkState | null;
refreshWorkState: () => Promise<void>;
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<void> {
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
</button>
)}
</div>
{agentId && (busyByWorkState || historyConversationId) && (
<div
role="status"
aria-live="polite"
style={{
position: "absolute",
top: 30,
left: 4,
zIndex: 3,
display: "flex",
maxWidth: "calc(100% - 8px)",
alignItems: "center",
gap: 4,
overflow: "hidden",
}}
>
{busyByWorkState && (
<span
title={delegatedTicket?.requesterLabel ?? activeTicket?.taskPreview}
style={{
maxWidth: 180,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
border: "1px solid var(--color-warning, #d49b3a)",
borderRadius: 3,
background: "rgba(212, 155, 58, 0.16)",
color: "var(--color-warning, #d49b3a)",
padding: "1px 6px",
fontSize: 11,
fontWeight: 600,
}}
>
{delegatedTicket
? `Busy · ${delegatedTicket.requesterLabel}`
: "Busy"}
</span>
)}
{busyByWorkState && (
<button
type="button"
aria-label={`cancel current turn ${id}`}
title="Cancel current turn"
onClick={() => void interruptCurrentTurn()}
disabled={!input}
style={{
flexShrink: 0,
border: "1px solid var(--color-danger, #d45a5a)",
borderRadius: 3,
background: "var(--color-surface, #1e1e1e)",
color: "var(--color-danger, #d45a5a)",
cursor: input ? "pointer" : "default",
fontSize: 11,
padding: "1px 6px",
}}
>
Cancel
</button>
)}
{historyConversationId && onOpenConversation && (
<button
type="button"
aria-label={`open cell conversation ${id}`}
title="Open conversation history"
onClick={() => onOpenConversation(historyConversationId)}
style={{
flexShrink: 0,
border: "1px solid var(--color-border, #3a3a3a)",
borderRadius: 3,
background: "var(--color-surface, #1e1e1e)",
color: "var(--color-content, #e0e0e0)",
cursor: "pointer",
fontSize: 11,
padding: "1px 6px",
}}
>
History
</button>
)}
</div>
)}
{/* 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<string>;
workState: ProjectWorkState | null;
refreshWorkState: () => Promise<void>;
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<HTMLDivElement | null>(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<string>;
workState: ProjectWorkState | null;
refreshWorkState: () => Promise<void>;
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}
/>
</div>
))}

View File

@ -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();

View File

@ -383,6 +383,7 @@ export function ProjectsView() {
projectId={active.id}
cwd={active.root}
layoutId={activeLayout?.id}
onOpenConversation={setViewerConversationId}
/>
)}
</>