agent conversation fix

This commit is contained in:
2026-06-27 12:42:37 +02:00
parent c2d1a669c5
commit 1fc7869160
38 changed files with 1173 additions and 1549 deletions

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,9 +109,9 @@ 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).

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

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.

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}
/>
)}
</>