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