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:
@ -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?;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user