fix(orchestrator): délégation inter-agent toujours headless, jamais d'injection PTY

La conversation inter-agent (idea_ask_agent) doit rester strictement
headless : la cible répond via une session structured capturée par IdeA,
sans jamais écrire dans le PTY d'une cellule visible ni fermer le terminal
que l'utilisateur observe.

- lifecycle: flag `allow_structured_alongside_pty` — ouvre une session
  structured pour la délégation sans fermer le PTY visible (coexistence).
- orchestrator/service: `ensure_structured_session` ne ferme plus le PTY
  visible ; mapping typé de l'erreur no-reply ; coexistence PTY/structured.
- infrastructure/input: garantit zéro `DelegationReady` et zéro write PTY
  pour une délégation headless, même quand l'entrée est `front_owned`.
- app-tauri (commands/state): câblage du flag de coexistence.
- tests: fixtures portées vers le modèle structured/headless, assertions
  cibles mises à jour (aucun #[ignore] ajouté, aucun test retiré).

Validé réel : cargo build OK ; orchestrator_service 60/0, agent_lifecycle
62/0, structured_launch_d3 22/0, infrastructure input 35/0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 10:22:06 +02:00
parent 1fc7869160
commit a9653bc417
8 changed files with 268 additions and 191 deletions

View File

@ -758,6 +758,7 @@ impl ChangeAgentProfile {
// the minimal declaration. A profile-hot-swap that needs the real
// endpoint is re-driven through the app-tauri launch path.
mcp_runtime: None,
allow_structured_alongside_pty: false,
})
.await?;
Ok(Some(output.session))
@ -897,6 +898,12 @@ pub struct LaunchAgentInput {
/// endpoint/project/requester args) rather than a project-bound one — see
/// [`mcp_server_declaration`].
pub mcp_runtime: Option<McpRuntime>,
/// Internal `ask_agent` seam: when `true`, an existing visible PTY session for
/// the same agent must not block the structured/headless launch. This keeps the
/// human CLI cell and the inter-agent `AgentSession` input channel separate.
///
/// Ordinary UI launches keep this `false` and retain the singleton/rebind guard.
pub allow_structured_alongside_pty: bool,
}
/// OS/runtime facts injected by the composition root (`app-tauri`) to materialise
@ -1449,42 +1456,53 @@ impl LaunchAgent {
// (rend la session existante, pas de respawn) ;
// - **second lancement neuf** : on vise un **autre** node, sans signal de
// réattache ⇒ refus [`AppError::AgentAlreadyRunning`] (node hôte rapporté).
if let Some(existing_id) = self.sessions.session_for_agent(&input.agent_id) {
let existing_pty = self.sessions.session_for_agent(&input.agent_id);
if let Some(existing_id) = existing_pty {
let host_node = self.sessions.node_for_agent(&input.agent_id);
match reattach_decision(input.node_id, host_node, input.conversation_id.as_deref()) {
ReattachDecision::Rebind { node_id } => {
if let Some(session) = self.sessions.rebind_agent_node(&input.agent_id, node_id)
{
return Ok(LaunchAgentOutput {
session,
assigned_conversation_id: None,
engine_session_id: None,
structured: None,
// Réattache (rebind de vue) : aucun profil re-résolu.
profile: None,
if input.allow_structured_alongside_pty && input.node_id.is_none() {
crate::diag!(
"[launch] existing PTY kept while structured launch proceeds: agent={} \
pty_session={existing_id}",
input.agent_id
);
} else {
match reattach_decision(input.node_id, host_node, input.conversation_id.as_deref())
{
ReattachDecision::Rebind { node_id } => {
if let Some(session) =
self.sessions.rebind_agent_node(&input.agent_id, node_id)
{
return Ok(LaunchAgentOutput {
session,
assigned_conversation_id: None,
engine_session_id: None,
structured: None,
// Réattache (rebind de vue) : aucun profil re-résolu.
profile: None,
});
}
}
ReattachDecision::Idempotent => {}
ReattachDecision::Refuse { node_id } => {
return Err(AppError::AgentAlreadyRunning {
agent_id: input.agent_id,
node_id,
});
}
}
ReattachDecision::Idempotent => {}
ReattachDecision::Refuse { node_id } => {
return Err(AppError::AgentAlreadyRunning {
agent_id: input.agent_id,
node_id,
// Idempotent (or a rebind that found no entry to move) — hand back the
// already-registered session, no respawn, nothing new to persist.
if let Some(session) = self.sessions.session(&existing_id) {
return Ok(LaunchAgentOutput {
session,
assigned_conversation_id: None,
engine_session_id: None,
structured: None,
// Idempotent (pas de respawn) : aucun profil re-résolu.
profile: None,
});
}
}
// Idempotent (or a rebind that found no entry to move) — hand back the
// already-registered session, no respawn, nothing new to persist.
if let Some(session) = self.sessions.session(&existing_id) {
return Ok(LaunchAgentOutput {
session,
assigned_conversation_id: None,
engine_session_id: None,
structured: None,
// Idempotent (pas de respawn) : aucun profil re-résolu.
profile: None,
});
}
}
// Garde structurée (§17.4) : même sémantique côté registre IA. R0a appliqué de
// façon identique — rebind de la cellule-vue pour une réattache légitime,
@ -1539,6 +1557,13 @@ impl LaunchAgent {
.into_iter()
.find(|p| p.id == agent.profile_id)
.ok_or_else(|| AppError::NotFound(format!("profile {} for agent", agent.profile_id)))?;
if input.allow_structured_alongside_pty && profile.structured_adapter.is_none() {
return Err(AppError::Invalid(format!(
"agent {}: le lancement headless structuré a été demandé, mais le profil {} \
ne déclare pas d'adaptateur structured/headless",
input.agent_id, profile.id
)));
}
// 3. Compute and create the agent's isolated run directory
// `<root>/.ideai/run/<agent-id>/` (ARCHITECTURE §14.1). The PTY cwd is

View File

@ -68,6 +68,15 @@ fn submit_config_for_profile(profile: &AgentProfile) -> SubmitConfig {
SubmitConfig::new(profile.submit_sequence.clone(), delay_ms)
}
fn structured_no_reply_error(err: &domain::ports::AgentSessionError) -> bool {
matches!(
err,
domain::ports::AgentSessionError::Io(message)
if message.contains("sans événement Final")
|| message.contains("without a structured final")
)
}
/// Bound on the synchronous inter-agent rendezvous (`agent.message` → `AskAgent`).
///
/// A target agent's turn can be long (reasoning + tool use), so the cap is
@ -1233,6 +1242,7 @@ impl OrchestratorService {
// facts to inject here; the real MCP declaration is written when the
// agent is (re)launched through the app-tauri composition root.
mcp_runtime: None,
allow_structured_alongside_pty: false,
})
.await?;
@ -1369,6 +1379,7 @@ impl OrchestratorService {
son profil ne déclare pas d'adaptateur structured/headless"
))
})?;
self.bind_conversation_session(conversation_id, session.id());
self.ask_structured(
project,
agent_id,
@ -1484,8 +1495,18 @@ impl OrchestratorService {
let drain = drain_with_readiness(session, &task, None, input.as_ref(), agent_id);
// 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) };
// pour être enveloppée par le watchdog. Un flux clos sans `Final` correspond
// au no-reply du chemin headless : la cible a rendu la main sans réponse
// exploitable, on expose donc l'erreur métier retryable plutôt qu'un PROCESS.
let wait = async {
drain.await.map_err(|err| {
if structured_no_reply_error(&err) {
AppError::TargetReturnedNoReply(target.to_owned())
} else {
AppError::from(err)
}
})
};
// Borne par la fenêtre d'inactivité (réarmée sur signe de vie) sous plafond absolu.
let result = match self
@ -1889,6 +1910,7 @@ impl OrchestratorService {
.mcp_runtime_provider
.as_ref()
.and_then(|p| p.runtime_for(project, agent_id)),
allow_structured_alongside_pty: false,
})
.await?;
@ -1949,40 +1971,23 @@ 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). `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.
// insère dans CE registre) sans fermer une éventuelle session PTY visible du
// même agent. Le flag interne garde les deux canaux séparés : PTY pour la
// cellule humaine, `AgentSession::send` pour `idea_ask_agent`.
self.launch_agent
.execute(LaunchAgentInput {
project: project.clone(),
agent_id,
rows: DEFAULT_ROWS,
cols: DEFAULT_COLS,
node_id: previous_node,
node_id: None,
conversation_id: None,
mcp_runtime: self
.mcp_runtime_provider
.as_ref()
.and_then(|p| p.runtime_for(project, agent_id)),
allow_structured_alongside_pty: true,
})
.await?;