Compare commits
2 Commits
62e41bda66
...
1fc7869160
| Author | SHA1 | Date | |
|---|---|---|---|
| 1fc7869160 | |||
| c2d1a669c5 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -59,3 +59,4 @@ Thumbs.db
|
||||
# état d'exécution reconstruit au fil de l'eau — not versioned (LS8 §7, design D19-4 ;
|
||||
# seul .ideai/memory/ est le store durable versionné).
|
||||
.ideai/conversations/
|
||||
.ideai/agents.json
|
||||
|
||||
@ -1,47 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"agents": [
|
||||
{
|
||||
"agentId": "a6ced819-b893-4213-b003-9e9dc79b9641",
|
||||
"name": "Main",
|
||||
"mdPath": "agents/main.md",
|
||||
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
|
||||
"synchronized": false
|
||||
},
|
||||
{
|
||||
"agentId": "dce19c75-9669-4e45-b8de-9950025157da",
|
||||
"name": "Architect",
|
||||
"mdPath": "agents/architect.md",
|
||||
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
|
||||
"synchronized": false
|
||||
},
|
||||
{
|
||||
"agentId": "73c853d1-c0fd-463b-ad17-1d24fefa371f",
|
||||
"name": "DevBackend",
|
||||
"mdPath": "agents/devbackend.md",
|
||||
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
|
||||
"synchronized": false
|
||||
},
|
||||
{
|
||||
"agentId": "af7f86da-76bc-48e1-9900-71f45a624800",
|
||||
"name": "DevFrontend",
|
||||
"mdPath": "agents/devfrontend.md",
|
||||
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
|
||||
"synchronized": false
|
||||
},
|
||||
{
|
||||
"agentId": "aefdbd61-e3d4-4bc1-9f42-c259446a97b5",
|
||||
"name": "QA",
|
||||
"mdPath": "agents/qa.md",
|
||||
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
|
||||
"synchronized": false
|
||||
},
|
||||
{
|
||||
"agentId": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5",
|
||||
"name": "Git",
|
||||
"mdPath": "agents/git.md",
|
||||
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
|
||||
"synchronized": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -109,11 +109,11 @@ tu le dis.
|
||||
|
||||
## 5. Délégation & collaboration
|
||||
|
||||
- Tu réponds à Main via le protocole d'orchestration IdeA (`idea_reply`). Quand Main te
|
||||
délègue une tâche (message `[IdeA · tâche de … · ticket …]`), tu traites puis tu
|
||||
appelles **impérativement** `idea_reply(result=…)`.
|
||||
- Quand Main te délègue une tâche via IdeA, tu la traites puis tu termines ton tour avec
|
||||
ta réponse normale. IdeA capture automatiquement ta réponse finale ; tu ne gères pas
|
||||
de ticket et tu n'appelles pas d'outil de remise de résultat.
|
||||
- Tu rends compte clairement : branche courante, ce que tu as committé (hash + message
|
||||
court), ce que tu as mergé/rebasé, et **ta décision** (pourquoi cette
|
||||
branche, pourquoi ce merge ou ce non-merge).
|
||||
- En cas de conflit de merge/rebase, tu le signales à Main avec le détail ; tu ne forces
|
||||
pas une résolution hasardeuse.
|
||||
pas une résolution hasardeuse.
|
||||
|
||||
@ -25,12 +25,12 @@ Exception limitée : tu peux modifier les fichiers de contexte, mémoire, docume
|
||||
Pour déléguer, utilise uniquement les outils IdeA natifs :
|
||||
|
||||
- `idea_list_agents` pour identifier les agents disponibles.
|
||||
- `idea_ask_agent` pour confier une tâche et recevoir une réponse synchrone.
|
||||
- `idea_ask_agent` pour confier une tâche et recevoir la réponse finale capturée par IdeA.
|
||||
- `idea_launch_agent` pour lancer ou rattacher un agent si nécessaire.
|
||||
|
||||
N'utilise jamais les subagents natifs du fournisseur IA pour ce projet.
|
||||
|
||||
Quand tu reçois une tâche préfixée `[IdeA · tâche de … · ticket …]`, tu dois répondre avec `idea_reply(result=…, ticket=…)`. Une réponse texte seule ne débloque pas l'agent appelant.
|
||||
Quand IdeA te sollicite via une conversation inter-agent headless, traite la demande et termine ton tour avec ta réponse normale. Ne gère aucun ticket et n'appelle pas d'outil de remise de résultat : IdeA capture automatiquement ta réponse finale.
|
||||
|
||||
---
|
||||
|
||||
@ -108,4 +108,4 @@ Si la demande utilisateur contredit le cycle, rappelle brièvement la règle et
|
||||
|
||||
---
|
||||
|
||||
*Dernière mise à jour : 2026-06-20*
|
||||
*Dernière mise à jour : 2026-06-20*
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
40
.ideai/memory/headless-interagent-conversation-objective.md
Normal file
40
.ideai/memory/headless-interagent-conversation-objective.md
Normal 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.
|
||||
@ -21,9 +21,12 @@ Les agents peuvent être lancés depuis un dossier d'exécution isolé `.ideai/r
|
||||
Les agents collaborent via les outils IdeA natifs :
|
||||
|
||||
- `idea_list_agents` pour lister les agents.
|
||||
- `idea_ask_agent` pour déléguer une tâche et attendre la réponse.
|
||||
- `idea_ask_agent` pour déléguer une tâche et recevoir la réponse finale capturée par IdeA.
|
||||
- `idea_launch_agent` pour lancer ou rattacher un agent.
|
||||
- `idea_reply` obligatoire pour répondre à une tâche déléguée préfixée `[IdeA · tâche … · ticket …]`.
|
||||
|
||||
Quand un agent est sollicité via IdeA, il répond normalement en fin de tour. IdeA capture
|
||||
automatiquement cette réponse finale ; les agents ne gèrent pas de ticket et n'appellent
|
||||
pas d'outil de remise de résultat.
|
||||
|
||||
Ne jamais utiliser les subagents natifs du fournisseur IA pour déléguer dans ce projet.
|
||||
|
||||
@ -90,4 +93,4 @@ Consulter la mémoire projet selon le besoin au lieu de recopier tous les détai
|
||||
|
||||
---
|
||||
|
||||
*Dernière mise à jour : 2026-06-20*
|
||||
*Dernière mise à jour : 2026-06-20*
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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(),
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
|
||||
@ -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"));
|
||||
|
||||
@ -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::{
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@ -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).
|
||||
///
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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};
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -286,4 +286,3 @@ impl EmbedderProfileStore for FsEmbedderProfileStore {
|
||||
FsEmbedderProfileStore::delete(self, id).await
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -383,6 +383,7 @@ export function ProjectsView() {
|
||||
projectId={active.id}
|
||||
cwd={active.root}
|
||||
layoutId={activeLayout?.id}
|
||||
onOpenConversation={setViewerConversationId}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user