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

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